Reproduced Exploit
BIGFI Exploit — Reflection-Token `burn()` That Shrinks Supply Without Shrinking Reflection Space
1. BIGFI is a reflection-token (the DxMint "DxBurn" template, RDeflationERC20). It keeps balances in two spaces: a reflection space (_rOwned, summed by _rTotal) and a real space (_tOwned / _tTotal). Every holder's reported balanceOf is _rOwned[holder] / rate, where rate = _rTotal / _tTotal.
Loss
30,306.103328283570349973 USDT drained from the BIGFI/USDT PancakeSwap pair — tx 0x9fe19093a62a7037d04617b3ac…
Chain
BNB Chain
Category
Logic / State
Date
Mar 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-03-BIGFI_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/BIGFI_exp.sol.
Vulnerability classes: vuln/defi/slippage · vuln/logic/incorrect-state-transition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: sources/DxBurnToken_d3d4B4/DxBurnToken.sol (BIGFI) and sources/PancakePair_A26955/PancakePair.sol (the victim PancakeSwap pair).
Key info#
| Loss | 30,306.103328283570349973 USDT drained from the BIGFI/USDT PancakeSwap pair — tx 0x9fe19093a62a7037d04617b3ac4fbf5cb2d75d8cb6057e7e1b3c75cbbd5a5adc (output.txt:167) |
| Vulnerable contract | BIGFI (DxMint DxBurnToken, a deflation/reflection ERC20) — 0xd3d4B46Db01C006Fb165879f343fc13174a1cEeB |
| Victim pool | BIGFI/USDT PancakeSwap V2 pair — 0xA269556EdC45581F355742e46D2d722c5F3f551a |
| Flash source | Saddle-style SwapFlashLoan — 0x28ec0B36F0819ecB5005cAB836F4ED5a2eCa4D13 |
| Attacker EOA | not materialized in this PoC (the attack contract is the test); the real attacker EOA was 0x… (see the tx link above) |
| Attacker contract | (PoC) ContractTest 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 — forks as a flash-loan receiver |
| Attack tx | 0x9fe19093a62a7037d04617b3ac4fbf5cb2d75d8cb6057e7e1b3c75cbbd5a5adc |
| Chain / block / date | BSC (chainId 56) / block 26,685,503 / Mar 2023 |
| Compiler / optimizer | BIGFI: Solidity v0.8.7, optimizer 1 (= ENABLED), 200 runs (_meta.json); Pair: v0.5.16, optimizer 0 |
| Bug class | Reflection-token burn() desynchronizes _tTotal from _rTotal, so a holder can collapse every other holder's balanceOf (and hence a PancakeSwap pair's reserve) to ~1 wei without moving any tokens — then sync()+swap() drains the pool |
TL;DR#
-
BIGFI is a reflection-token (the DxMint "DxBurn" template,
RDeflationERC20). It keeps balances in two spaces: a reflection space (_rOwned, summed by_rTotal) and a real space (_tOwned/_tTotal). Every holder's reportedbalanceOfis_rOwned[holder] / rate, whererate = _rTotal / _tTotal. -
The token exposes a public
burn(uint256 amount)(DxBurnToken.sol#L829-L831) that, internally, decrements_rOwned[msg.sender]and_tTotal— but never decrements_rTotal(DxBurnToken.sol#L949-L954). Burning supply while leaving the reflection space unchanged makesrate = _rTotal / _tTotalexplode, so every other holder'sbalanceOfis re-scaled downward — for free. -
The attacker flashes 200,000 USDT from Saddle's
SwapFlashLoan(output.txt:25), swaps it all to BIGFI through the PancakeSwap pair, and ends up holding ~5.87e21 BIGFI while the pair still reports ~3.26e21 BIGFI of reserves (output.txt:84-87). -
The attacker then calls
BIGFI.burn(burnAmount)withburnAmountchosen by the closed-form formulatotalSupply() - 2 * (totalSupply() / balanceOf(Pair))(BIGFI_exp.sol#L61) — this is the exact quantity that drives the pair'sbalanceOfto 1 wei. The burn is20999908387034241742894360BIGFI (output.txt:92-93). -
A
Pair.sync()then forces the pair to re-read BIGFI'sbalanceOf: BIGFI reserve goes from3259991215302060148662→ 1 (output.txt:98-106). The USDT reserve is untouched. The constant-product invariantkis now ~307e21 · 1— a pair priced as if BIGFI is essentially priceless. -
The attacker swaps back its leftover 3 wei BIGFI (the dust left over by the deflation transfer math) for 230,466.103328283570349973 USDT (output.txt:130-147).
-
It repays the flash loan principal 200,000 USDT + the 160 USDT fee (output.txt:35, output.txt:149-150) and pockets 30,306.103328283570349973 USDT (output.txt:164, output.txt:167) — the pair's honest USDT liquidity, minus trivial dust.
This is the same root-cause family as FDP (Feb 2023), TINU (Jan 2023), and Sheep (Feb 2023) — all
linked in the PoC header (BIGFI_exp.sol#L10-L12): a reflection /
deflation token placed in a vanilla Uniswap-V2 pair, where an out-of-band balance mutation breaks the
pair's k-invariant accounting.
Background — what BIGFI does#
BIGFI is the on-chain name for a token deployed via DxMint (the DxBurnToken template). It is a
reflection / deflation ERC20: it charges a per-transfer fee split into _taxFee (reflected back to all
holders by shrinking _rTotal), _devFee (sent to a dev wallet), and _burnFee (burned to 0x…dEaD).
On-chain parameters at the fork block (block 26,685,503, read from the trace and the verified source):
| Parameter | Value | Source |
|---|---|---|
totalSupply() (BIGFI _tTotal) before attack | 20,999,908,387,034,241,742,907,242 wei (~20.999e24, 18-dec) | output.txt:89 |
Pair BIGFI reserve (reserve1) before attack | 9,310,990,259,680,030,849,404 wei (~9.311e21) | output.txt:55 |
Pair USDT reserve (reserve0) before attack | 107,480,664,198,219,600,542,112 wei (~107,480.66) | output.txt:55 |
| Implied BIGFI price | ~5,448 BIGFI / USDT (USDT reserve / BIGFI reserve) | derived |
| Flash-loan principal (USDT) | 200,000 × 1e18 = 200,000,000,000,000,000,000,000 | output.txt:25 |
| Flash-loan fee (USDT, 8 bps) | 160 × 1e18 = 160,000,000,000,000,000,000 | output.txt:35 |
| PancakeSwap swap fee | 25 / 10000 = 0.25% (pair's swap uses amountIn.mul(25)) | PancakePair.sol#L473-L475 |
token0 / token1 of the pair | USDT / BIGFI (so reserve0=USDT, reserve1=BIGFI) | output.txt:55, output.txt:77 |
The _rTotal reflection space is initialized at deployment as MAX - (MAX % _tTotal) and only ever
shrinks (via _reflectFee). The pair's BIGFI holdings are tracked purely through
balanceOf(pair) = _rOwned[pair] / rate; the pair is not in _isExcluded, so it has no _tOwned.
The vulnerable code#
1. The public burn() — anyone may call it#
function burn(uint256 _value) public {
_burn(msg.sender, _value);
}
No access control. Any holder of BIGFI can destroy an arbitrary amount of their own balance.
2. The internal _burn mutates _rOwned and _tTotal but NOT _rTotal#
function _burn(address _addr, uint256 _value) private {
require(_value <= _rOwned[_addr]);
_rOwned[_addr] = _rOwned[_addr].sub(_value);
_tTotal = _tTotal.sub(_value);
emit Transfer(_addr, dead, _value);
}
Compare to _reflectFee, which is the correct way the token reduces supply — it shrinks both
_rTotal and accounts the fee symmetrically:
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 tFee, uint256 tDev, uint256 tBurn) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn);
_tFeeTotal = _tFeeTotal.add(tFee);
_tDevTotal = _tDevTotal.add(tDev);
_tBurnTotal = _tBurnTotal.add(tBurn);
}
_burn is the odd one out: it pulls _value out of _rOwned[caller] and out of _tTotal, but it
leaves _rTotal alone. Because every holder's reported balance is _rOwned[h] / (_rTotal / _tTotal),
reducing _tTotal while keeping _rTotal fixed raises the rate, and every other holder's balance is
re-scaled downward by the same factor.
3. How balanceOf derives from the (now broken) rate#
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
(DxBurnToken.sol#L722-L725, :L793-L797, :L884-L887)
The PancakeSwap pair is not in _isExcluded, so balanceOf(pair) is _rOwned[pair] / rate. When
_burn slashes _tTotal, rate jumps and balanceOf(pair) collapses — with no tokens ever leaving
the pair. That is exactly what the next sync() will lock in as the new reserve.
4. The PancakeSwap pair's sync() trusts token balances#
// force reserves to match balances
function sync() external lock {
_update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}
sync() re-reads the live balanceOf for each side and writes it into reserve0/reserve1. Because
BIGFI's balanceOf(pair) was corrupted by the burn (Section 2), sync() happily records the
near-zero BIGFI reserve while leaving the USDT reserve untouched.
Root cause — why it was possible#
The bug is an accounting invariant violation in the reflection model, then amplified by AMM trust:
-
_burndesynchronizes_tTotalfrom_rTotal. In a correctly-implemented reflection token, any operation that reduces_tTotalmust reduce_rTotalproportionally (that is what_reflectFeedoes for the fee path)._burnskips the_rTotaladjustment, so after a burn the invariant_rTotal / _tTotal == rateno longer holds —ratedrifts upward, andbalanceOf(h) = _rOwned[h] / rateshrinks for every holder whose_rOwnedwas not burned. -
burn()is permissionless. Any holder can drive this drift at will, choosing the size of the burn to be whatever makes some other holder's balance hit any target. -
The closed-form burn size. The attacker needs
balanceOf(pair) == 1. Working backwards:balanceOf(pair) == _rOwned[pair] * _tTotal' / _rTotal, and_tTotal' = _tTotal - burnAmount, while_rOwned[pair]and_rTotalare unchanged. Solving gives the PoC's exact expressionburnAmount = _tTotal - 2 * (_tTotal / balanceOf(pair))(see Why each magic number). This is what makes the attack deterministic, not a guess-and-check. -
A vanilla PancakeSwap pair can't defend against out-of-band balance mutation. Its
swap'sPancake: Kcheck (PancakePair.sol#L475) only enforcesbalance0Adjusted · balance1Adjusted ≥ reserve0 · reserve1 · 10000². Aftersync()re-reads the corrupted BIGFI balance intoreserve1, that check is satisfied trivially becausereserve1is now ~1 — selling a couple of wei of BIGFI into a pool priced ~307e21 USDT per BIGFIextracts essentially the entire USDT side.
The composition is the same one that took down FDP, TINU and Sheep: a reflection / fee-on-transfer token placed in an unmodified Uniswap-V2 fork, where the token's internal accounting can mutate the pair's reserves behind the pair's back.
Preconditions#
- The attacker must hold (or be able to acquire) BIGFI to burn. The PoC obtains it by flashing USDT and swapping into the pair first (BIGFI_exp.sol#L57, output.txt:43-85).
- A flash-loan source for USDT on BSC. The Saddle-style
SwapFlashLoan(output.txt:25-26) lends 200,000 USDT for an 8-bps fee (160 USDT, output.txt:35). - The pair's BIGFI reserve before the burn must be > 0 (trivially true for any live pool).
- No access control on
burn(verified: DxBurnToken.sol#L829-L831).
No admin key, no oracle, no governance action, and no privileged role is needed — the entire attack is permissionless once the attacker holds any BIGFI.
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = USDT, token1 = BIGFI, so reserve0 is USDT and reserve1 is BIGFI. All
figures are raw 18-decimal wei straight from the trace; human approximations are in parentheses.
USDT and BIGFI both use 18 decimals here.
| # | Step | USDT reserve (r0) | BIGFI reserve (r1) | Effect / source |
|---|---|---|---|---|
| 0 | Initial getReserves | 107,480,664,198,219,600,542,112 (~107,480.66) | 9,310,990,259,680,030,849,404 (~9,310.99) | Honest pool. output.txt:55 |
| 1 | Flash-loan 200,000 USDT (200,000,000,000,000,000,000,000) from swapFlashLoan | — | — | Fee quoted = 160 USDT. output.txt:25, output.txt:35 |
| 2 | USDT→BIGFI swap — swapExactTokensForTokensSupportingFeeOnTransferTokens pays 200,000.01e18 USDT into the pair (output.txt:43-45); pair sends out 6,051,008,437,863,879,112,730 BIGFI (amount1Out, output.txt:77), of which 5,869,478,184,727,962,739,349 lands at the attacker (deflation fee diverts ~3% to 0x…dEaD, output.txt:59-61) | 307,480,674,198,219,600,542,112 (~307,480.67) | 3,259,991,215,302,060,148,662 (~3,259.99) | Post-swap Sync (output.txt:76); attacker BIGFI balance = 5,869,495,097,356,201,647,072 (~5,869.50) (output.txt:84). |
| 3 | BIGFI.burn(20,999,908,387,034,241,742,894,360) — the attacker burns almost the entire supply (burn value output.txt:92-93) | 307,480,674,198,219,600,542,112 (unchanged) | _tTotal reduced by 20,999,908,387,034,241,742,894,360; _rTotal NOT reduced (DxBurnToken.sol#L949-L954). _rOwned[attacker] after burn = 12882 reflection units (output.txt:95). | rate explodes; pair's balanceOf collapses. (The post-burn _tTotal is not directly printed by the trace; the collapse is proven mechanically by the next sync() reading balanceOf(pair) == 1, step 4.) |
| 4 | Pair.sync() re-reads BIGFI balanceOf(pair) | 307,480,674,198,219,600,542,112 (unchanged) | 1 (output.txt:102, output.txt:103) | Sync(reserve0=307480674198219600542112, reserve1=1) (output.txt:103). USDT side untouched. |
| 5 | BIGFI→USDT swap — sell the leftover 3 wei BIGFI (output.txt:114); swap pulls 230,466,103,328,283,570,349,973 USDT out of the pair (amount0Out, output.txt:130-132) | 77,014,570,869,936,030,192,139 (~77,014.57) (output.txt:138) | 4 (output.txt:141) | Selling 3 wei into a pool with r1 == 1 buys ~all the USDT. Swap(... amount0Out=230466103328283570349973 …) (output.txt:142). |
| 6 | Repay flash — 200,160,000,000,000,000,000,000 USDT (principal + 160 fee, output.txt:149-150) | — | — | Attacker keeps the surplus. |
| 7 | Attacker final balance | 30,306,103,328,283,570,349,973 (~30,306.103328…) | — | Logged as Attacker USDT balance after exploit (output.txt:164, output.txt:167). |
Why "3 wei of BIGFI buys ~everything": PancakeSwap's getAmountOut is
out = (in·9975·reserveOut) / (reserveIn·10000 + in·9975). After the burn+sync reserveIn (=r1) == 1,
so for in == 3: out = (3·9975·r0) / (1·10000 + 3·9975) = (29925·r0) / 39925 ≈ 0.7495·r0. Three wei
into a pool whose BIGFI side is 1 wei pull roughly three-quarters of the USDT reserve out — which here
is the entire honest USDT liquidity (the rest stays only because r1 ticks up to 4 during the swap).
Profit / loss accounting (USDT, raw wei)#
| Direction | Amount (wei) | ~Human |
|---|---|---|
| Received — final BIGFI→USDT swap (step 5) | 230,466,103,328,283,570,349,973 | 230,466.103328… |
| Repaid — flash principal | 200,000,000,000,000,000,000,000 | 200,000 |
| Repaid — flash fee | 160,000,000,000,000,000,000 | 160 |
| Net kept by attacker | 30,306,103,328,283,570,349,973 | 30,306.103328… |
Sources: received output.txt:147; repayment output.txt:149-150; final balance output.txt:164. The accounting reconciles to the wei: 230,466.103328… − 200,160 = 30,306.103328… — exactly the final logged balance.
Diagrams#
Sequence of the attack#
Pair state evolution#
The flaw inside burn / _burn#
Constant-product before vs. after the sync()#
Why each magic number#
200_000 * 1e18flash-loan size (BIGFI_exp.sol#L43): large enough to swap the pool deep into BIGFI so that the attacker holds a meaningful BIGFI balance to burn, while keeping the flash fee tiny. Saddle'sSwapFlashLoancharged 8 bps → exactly 160 USDT (output.txt:35), which is immaterial next to the ~30k USDT profit.BIGFI.totalSupply() - 2 * (BIGFI.totalSupply() / BIGFI.balanceOf(address(Pair)))asburnAmount(BIGFI_exp.sol#L61): the closed-form quantity that drivesbalanceOf(pair)to exactly 1. Derivation:balanceOf(pair) = _rOwned[Pair] * _tTotal' / _rTotal, where_tTotal' = _tTotal - burnAmountand_rOwned[Pair],_rTotalare unchanged by the burn. Before the burn,balanceOf(Pair) = _rOwned[Pair] * _tTotal / _rTotal. We want the post-burnbalanceOf(Pair) = 1. Substituting and cancelling gives_tTotal' ≈ 2 * _tTotal * 1 / balanceOf(Pair), henceburnAmount = _tTotal - 2 * (_tTotal / balanceOf(Pair)). The integer division truncates in the attacker's favor, leaving the pair at exactly 1 wei (verified: output.txt:102).Pair.sync()after the burn (BIGFI_exp.sol#L63): forces the pair to re-read BIGFI's now-corruptedbalanceOfand write it toreserve1. Withoutsync()the pair's cachedreserve1would still be the old value and the subsequent swap would revert on theKcheck.- Selling
BIGFI.balanceOf(address(this))(= 3 wei) on the way back (BIGFI_exp.sol#L85): after the burn the attacker's own BIGFI balance has also been re-scaled down to dust (3 wei, output.txt:113). Into a pool withr1 == 1, 3 wei is enough to pull ~75% of the USDT reserve (output.txt:130) — i.e. all the honest liquidity plus most of what the attacker itself injected.
Remediation#
- Fix
_burnto keep the reflection invariant. Any reduction of_tTotalmust be mirrored by a proportional reduction of_rTotal(or the equivalent adjustment in_rOwned). The minimal fix is to compute the reflection-space amountrValue = amount * rateand do_rOwned[caller] -= rValue; _rTotal -= rValue; _tTotal -= amount;— i.e. burn in reflection units, not raw t-units. This is the root-cause fix; without it,burnwill always corrupt balances. - Gate
burn(). Even with the accounting fixed,burnshould not be callable by arbitrary holders in a way that affects other holders' balances. Restrict it to the token contract itself or a trusted burner; holders who want to "destroy" their tokens should send them to0x…dEaDvia a normal (fee-paying) transfer instead. - Do not list reflection / fee-on-transfer / deflation tokens in unmodified Uniswap-V2 pairs.
PancakeSwap's
swap(Pancake: K) andsyncwere designed for plain ERC20s whosebalanceOfonly changes throughtransfer/transferFrom. Either wrap the token in a fee-aware pair, or use an AMM that prices off cached reserves rather thanbalanceOfon every swap. - Add a
sync()/reserve-consistency sanity check in the token: if the token knows it is paired in an AMM, it should refuse any state-changing operation that would move another address'sbalanceOfby more than a tiny epsilon without that address's involvement. - Audit all DxMint-template tokens.
_burn's asymmetric_rOwned/_tTotal/_rTotalhandling is in the verified source template (DxBurnToken.sol#L949-L954); every token minted from this template that exposesburn()is likely affected by the same class of bug (this is exactly why FDP/TINU/Sheep/BIGFI share a root cause).
How to reproduce#
The PoC runs fully offline through the shared harness, which serves the fork from a local
anvil_state.json on a 127.0.0.1 anvil port (the test's createSelectFork points at
http://127.0.0.1:8546, BIGFI_exp.sol#L34) — no public BSC RPC is required.
_shared/run_poc.sh 2023-03-BIGFI_exp --mt testExploit -vvvvv
- Test function name (from BIGFI_exp.sol#L42):
testExploit. - Chain / fork block: BSC, block 26,685,503 (
evm_version = cancun, foundry.toml). - Expected tail (verbatim from output.txt:170-172):
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.67s (12.83ms CPU time)
Ran 1 test suite in 1.67s (1.67s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
The [PASS] testExploit() (gas: 366190) line and the
Attacker USDT balance after exploit: 30306.103328283570349973 log
(output.txt:4, output.txt:6) confirm a successful drain.
Reference: BIGFI reflection-token pair drain, BSC, Mar 2023 — same class as FDP (2023-02-07), TINU
(2023-01-26) and Sheep (2023-02-10); see the DeFiHackLabs entries linked in
test/BIGFI_exp.sol#L10-L12 and tx
0x9fe19093a62a7037d04617b3ac4fbf5cb2d75d8cb6057e7e1b3c75cbbd5a5adc.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-03-BIGFI_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
BIGFI_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "BIGFI Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.