Reproduced Exploit
BEVO Exploit — Reflective-Token `deliver()` Pool-Balance Inflation Drain (PancakeSwap Flash)
BEVO is a "reflective" (rebasing-fee) ERC20 that keeps two ledgers: a real balance and a much larger reflection balance (_rOwned, scaled by _rTotal / _tTotal). A holder's displayed balance is rOwned / rate, where rate = rSupply / tSupply. The public deliver(tAmount) function lets any
Loss
144 BNB (~$40K) — tx 0xb97502d3…
Chain
BNB Chain
Category
Logic / State
Date
Jan 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-01-BEVO_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/BEVO_exp.sol.
Vulnerability classes: vuln/logic/incorrect-state-transition · vuln/defi/slippage
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: CoinToken.sol (the BEVO token,
CoinTokenimplementation at0xc6Cb12…).
Key info#
| Loss | 144 BNB (~$40K) — tx 0xb97502d3… |
| Vulnerable contract | BEVO (reflective ERC20, CoinToken) — 0xc6Cb12df4520B7Bf83f64C79c585b8462e18B6Aa |
| Victim pool | BEVO/WBNB PancakeSwap pair — 0xA6eB184a4b8881C0a4F7F12bBF682FD31De7a633 |
| Attacker EOA (frontrunner) | 0xd3455773c44bf0809e2aeff140e029c632985c50 |
| Attacker EOA (original) | 0x68fa774685154d3d22dec195bc77d53f0261f9fd |
| Attacker contract (frontrunner) | 0xbec576e2e3552f9a1751db6a4f02e224ce216ac1 |
| Attack tx | 0xb97502d3976322714c828a890857e776f25c79f187a32e2d548dda1c315d2a7d |
| Chain / block / date | BSC / 25,230,702 / Jan 2023 |
| Flash source | WBNB/USDC PancakeSwap pair — 0xd99c7F6C65857AC913a8f880A4cb84032AB2FC5b |
| Compiler | Solidity v0.8.2 (v0.8.2+commit.661d1103), optimizer enabled (200 runs) |
| Bug class | Reflective-token deliver() lets a caller inflate the AMM pair's apparent BEVO balance above its cached reserve, then skim() the phantom excess and dump it back for the WBNB side |
TL;DR#
BEVO is a "reflective" (rebasing-fee) ERC20 that keeps two ledgers: a real balance and a much
larger reflection balance (_rOwned, scaled by _rTotal / _tTotal). A holder's displayed
balance is rOwned / rate, where rate = rSupply / tSupply. The public
deliver(tAmount) function lets any
non-excluded holder "donate" tokens to all other holders: it burns the caller's reflections and
shrinks _rTotal without touching anyone else's _rOwned. Because rate is rSupply/tSupply,
shrinking the numerator inflates every remaining holder's displayed balance — in particular the
PancakeSwap pair's.
The pair is a normal, non-excluded reflection holder, so its balanceOf is
tokenFromReflection(_rOwned[pair]) (CoinToken.sol:519-521).
deliver() does not move a single BEVO, yet it makes the pair's reported balance balloon. The
attacker then calls pair.skim(attacker), which transfers that phantom excess out, and dumps it
straight back into the pair for the WBNB side.
The full attack, all inside the pancakeCall flash callback:
- Flash-borrow 192.5 WBNB from the WBNB/USDC pair via
pair.swap(0, 192.5e18, this, 0x00)(output.txt:1588). - Buy BEVO with the 192.5 WBNB through the router; the BEVO/WBNB pair sends ≈ 3,028 BEVO to the attacker (output.txt:1611-1615).
deliver(3,028 BEVO)— burns the attacker's reflections, shrinking_rTotal. The pair's reported BEVO balance jumps from 2,298,813,336,114,922,094 (≈ 2.298 BEVO) to 6,844,218,532,359,160,336 (≈ 6.844 BEVO) — a phantom +4,545,405,196,244,238,242 (output.txt:1631 vs output.txt:1655-1656).pair.skim(attacker)— sweeps the entire phantom excess (4,545,405,196,244,238,242 BEVO) into the attacker's wallet (output.txt:1657).deliver(excess)again to re-inflate any residual, thenpair.swap(337 WBNB out)— sell the skimmed BEVO back for 337 WBNB (output.txt:1681-1693).- Repay 193 WBNB to the WBNB/USDC pair (the 192.5 WBNB principal + 0.5 WBNB flash fee) (output.txt:1697).
Net: the attacker received 337 WBNB from the BEVO/WBNB pair and only paid back 193 WBNB to the
WBNB/USDC flash source. Profit = 144 WBNB (output.txt:1718) — exactly the BEVO/WBNB
pool's WBNB reserve that the deliver→skim→swap loop extracted.
Background — what BEVO does#
BEVO (CoinToken.sol, deployed as CoinToken) is a
fixed-supply reflective ERC20. Reflective tokens maintain two parallel accounting systems:
_tTotal/_tOwned— the "true" token space (the supply users think they hold)._rTotal/_rOwned— the "reflection" space, a much larger integer that is divided down by the currentrateto recover a t-balance. Fees are collected by shrinking_rTotal, which silently redistributes value to every holder (theirrOwned / raterises) — no per-holder writes needed.
The core invariant is rate = rSupply / tSupply
(CoinToken.sol:807-822). A holder's balance is
function balanceOf(address account) public view returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]); // rOwned / rate
}
Three fee knobs are configured at construction — _TAX_FEE (reflection), _BURN_FEE, and
_CHARITY_FEE — each applied at fee / 10000 of every taxed transfer
(CoinToken.sol:783-787). In this deployment the
on-chain trace shows each of the three components taking ≈ 1 bp (0.01%) per transfer, i.e. a combined
≈ 3 bps on the buy the attacker performs (the full-fee path), evidenced by the ≈ 908,632,296,901,839
wei of fees stripped from a 3,028,774,323,006,137,313-wei outbound BEVO transfer
(output.txt:1611-1615).
On-chain parameters at the fork block (block 25,230,702):
| Parameter | Value | Source |
|---|---|---|
BEVO/WBNB reserve0 (WBNB) | 145,721,197,780,523,651,391 (≈ 145.72 WBNB) | output.txt:1608 |
BEVO/WBNB reserve1 (BEVO) | 5,327,282,266,398,899,539 (≈ 5.327 BEVO) | output.txt:1608 |
BEVO/WBNB token0 | WBNB (0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c) | trace labels |
BEVO/WBNB token1 | BEVO (0xc6Cb12df…) | trace labels |
Pair's _isExcluded status | false (balance is reflection-derived; proven by the inflation) | inferred |
| Flash-borrow amount | 192.5 WBNB (192_500_000_000_000_000_000) | output.txt:1588 |
| Flash repayment | 193 WBNB (192.5 principal + 0.5 fee) | output.txt:1697 |
The pool's WBNB reserve (≈ 145.7 WBNB) is the prize. Because BEVO is reflection-based and the pair is
not on the _isExcluded list, that reserve is priced against a balanceOf(pair) value that anyone
can manipulate upward for free via deliver().
The vulnerable code#
1. deliver() shrinks _rTotal from a public, permissionless entry point#
function deliver(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount); // ⚠️ shrinks the reflection pool
_tFeeTotal = _tFeeTotal.add(tAmount);
}
deliver is the canonical "I gift my tokens to all other holders" helper of every
taxable/rebate-token template (it is
borrowed verbatim from the popular "Reflect" EIP draft). It is permissionless — only blocked for
_isExcluded senders — and its entire effect is to delete _rOwned[sender] and _rTotal in lock
step. It does not move tokens, and it does not touch _tOwned/_rOwned of any other account.
2. The pair's balance is rOwned / rate, and rate is _rTotal-derived#
function balanceOf(address account) public view 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);
}
(CoinToken.sol:519-L521, :594-L598, :807-L810)
When _rTotal drops (because someone called deliver), rSupply drops while tSupply is unchanged,
so rate falls. The pair's _rOwned[pair] is untouched, but dividing it by a smaller rate yields
a larger t-balance. balanceOf(pair) therefore rises with no underlying transfer — the AMM's cached
reserve is now stale and below the pair's real reported balance.
3. PancakeSwap skim() then cashes the phantom balance out#
PancakeV2's skim(to) transfers balance0 - reserve0 (and likewise for token1) to to. It exists
to let anyone reclaim tokens sent to the pair without going through swap. Combined with the
deliver()-inflated balanceOf, it becomes the extraction primitive:
// PancakePair (verified, 0xA6eB18…) — standard UniswapV2 skim
function skim(address to) external lock {
address _token0 = token0; // WBNB
address _token1 = token1; // BEVO
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
}
The attacker calls bevo_wbnb.skim(address(this))
(BEVO_exp.sol:57) right after deliver(), pulling the full phantom BEVO
excess into their own wallet.
4. The fee-on-transfer path on swap lets the skimmed BEVO buy the WBNB side back#
After skim the attacker holds genuine BEVO. The final
bevo_wbnb.swap(337 ether, 0, address(this), "") (BEVO_exp.sol:59) sells
those BEVO into the pair. Because swap re-prices off reserve (not the inflated balanceOf), and
the attacker's deliver calls have already desynchronized the pool, the dump pulls 337 WBNB out —
more than the 192.5 WBNB the attacker originally injected.
Root cause — why it was possible#
The bug is a composition of two individually-documented but jointly-catastrophic design choices in the reflective-token template, neither of which is safe to deploy against a vanilla AMM pair:
-
deliver()mutates a global (_rTotal) that determines other accounts' balances. Reflective tokens deliberately let holders redistribute their own value to everyone else. That is fine inside the token's own economy. It is not fine when one of those "other accounts" is a Uniswap-style AMM pair whoseswap/skim/syncmachinery treatsIERC20.balanceOfas ground truth. The template never considers that a non-transfer call can move a pair's apparent balance. -
The AMM pair is a non-excluded reflection holder. A pair's balances must be stable in t-space. Had the deployment
excludeAccount(pair)(moving the pair onto the_tOwnedledger, wherebalanceOfreturns a stored constant),deliver()would be inert against it. The deployment did not, so the pair's reported balance isrOwned / rate— andrateis attacker-controllable.
The chain of invariants that the attack breaks:
deliver()⇒_rTotal ↓⇒rate ↓⇒balanceOf(pair) ↑(with zero token movement) ⇒pair.skim()harvests the gap ⇒ skimmed BEVO is genuine balance ⇒pair.swap()trades it for the WBNB side ⇒ pool WBNB drained.
The PancakeSwap pair's own k-check never fires because the extraction does not go through the
fee-on-transfer swap path until step 5; steps 3-4 ride deliver (outside the pair) and skim
(which intentionally bypasses the invariant). By the time the BEVO is sold back, the pool has already
been debited via the inflated balanceOf.
This is structurally the same bug class as the FDP / TINU / Sheep family of "reflective/fee-on-transfer
token vs. vanilla Pancake pair" drains on BSC in late 2022 / early 2023: the pair cannot reconcile
balance changes it did not cause through mint/burn/swap.
Preconditions#
- The victim token is a reflective (rebasing) ERC20 that exposes a public, permissionless
deliver()-style function which mutates the global rate. (Satisfied — CoinToken.sol:574-581.) - The AMM pair holding the token is not on the token's
_isExcludedlist, so itsbalanceOfis reflection-derived and thereforedeliver-sensitive. (Satisfied — proven by the inflation from 2.298e18 → 6.844e18 in output.txt:1631 → output.txt:1655.) - Flash-loanable working capital in WBNB. The attack only needs 192.5 WBNB intra-tx, fully repaid
inside the same
pancakeCall. The PoC sources it from the WBNB/USDC Pancake pair's ownswapflash callback (BEVO_exp.sol:39).
Attack walkthrough (with on-chain numbers from the trace)#
token0 = WBNB, token1 = BEVO, so reserve0 = WBNB and reserve1 = BEVO. All figures are taken
directly from the Swap / Sync / Transfer events and getReserves / balanceOf returns in
output.txt. Raw wei first, human approximation in parentheses.
| # | Step | BEVO/WBNB WBNB reserve (r0) | BEVO/WBNB BEVO reserve (r1) | Pair BEVO balanceOf | Effect |
|---|---|---|---|---|---|
| 0 | Initial (getReserves, output.txt:1608) | 145,721,197,780,523,651,391 (≈ 145.72 WBNB) | 5,327,282,266,398,899,539 (≈ 5.327 BEVO) | 5,327,282,266,398,899,539 (in sync) | Honest pool. |
| 1 | Flash borrow — WBNB/USDC swap(0, 192.5 WBNB, attacker, 0x00) (output.txt:1588-1590) | — | — | — | +192.5 WBNB to attacker (to be repaid in-tx). |
| 2 | Buy BEVO — router swapExactTokensForTokensSupportingFeeOnTransferTokens(192.5 WBNB → BEVO); pair sends 3,028,774,323,006,137,313 BEVO, attacker nets 3,027,865,690,709,235,474 after ≈3 bps reflective fee (output.txt:1611-1615) | 338,221,197,780,523,651,391 (≈ 338.22 WBNB) (output.txt:1631) | 2,298,813,336,114,922,094 (≈ 2.298 BEVO) (output.txt:1631) | 2,298,813,336,114,922,094 (in sync) | Attacker holds ≈ 3,028 BEVO. |
| 3 | deliver(3,028,267,986,646,483,923) (output.txt:1643) — burns attacker's reflections, shrinks _rTotal | 338,221,197,780,523,651,391 (unchanged) | 2,298,813,336,114,922,094 (reserve unchanged) | 6,844,218,532,359,160,336 (≈ 6.844 BEVO) (output.txt:1655-1656) | balanceOf desyncs above reserve by +4,545,405,196,244,238,242 BEVO. |
| 4 | skim(attacker) (output.txt:1649-1672) — sweeps balance1 − reserve1 = 4,545,405,196,244,238,242 BEVO to attacker (output.txt:1657) | 338,221,197,780,523,651,391 (unchanged) | 2,298,813,336,114,922,094 (unchanged — skim doesn't Sync) | 2,298,813,336,114,922,094 (back to reserve) | Attacker now holds the phantom BEVO as real balance. |
| 5 | deliver(4,544,947,785,463,603,859) again (output.txt:1675) — re-shrink rate to maximise the subsequent swap's edge | 338,221,197,780,523,651,391 (unchanged) | 2,298,813,336,114,922,094 (unchanged) | inflated further (728,234,950,164,515,176,689 pre-final-swap, output.txt:1690) | Sets up the dump. |
| 6 | pair.swap(337 WBNB out, 0, attacker, "") (output.txt:1681) — sells BEVO in (725,936,136,828,400,254,595 net after fee, output.txt:1693); pair sends 337 WBNB out (output.txt:1683) | 1,221,197,780,523,651,391 (≈ 1.221 WBNB) (output.txt:1692) | 728,234,950,164,515,176,689 (≈ 728.2 BEVO) (output.txt:1692) | in sync post-Swap | 337 WBNB extracted from the BEVO/WBNB pool. |
| 7 | Repay flash — transfer 193 WBNB to WBNB/USDC pair (output.txt:1697); WBNB/USDC swap returns 192.5 WBNB to attacker and keeps 0.5 WBNB fee (output.txt:1711) | — | — | — | Flash closed; 0.5 WBNB fee paid. |
| 8 | Final — attacker WBNB balance = 144,000,000,000,000,000,000 (144 WBNB) (output.txt:1718) | — | — | — | Net +144 WBNB. |
Why deliver inflates the pair's balance: rate = rSupply / tSupply. Burning
rAmount of reflections via deliver lowers rSupply (the _rTotal term) but leaves both
_rOwned[pair] and tSupply untouched. A smaller rate means _rOwned[pair] / rate is larger, so
balanceOf(pair) rises with zero tokens moving. The pair had no idea its balance changed, so its
cached reserve1 is now stale and skim redeems the difference for free.
Profit / loss accounting (WBNB)#
| Direction | Amount (wei) | ≈ Human |
|---|---|---|
Received — step 6, BEVO/WBNB swap WBNB-out | 337,000,000,000,000,000,000 | 337.00 WBNB |
| Paid — step 7, flash repayment to WBNB/USDC | 193,000,000,000,000,000,000 | 193.00 WBNB |
| Net profit | 144,000,000,000,000,000,000 | 144.00 WBNB |
Reconciled exactly to the log_named_decimal_uint line
WBNB balance after exploit: 144.000000000000000000 (output.txt:1564,
output.txt:1718-1719). Of the 193 WBNB repaid, 192.5 WNB is the flash principal and
0.5 WBNB is the WBNB/USDC pair's 0.25% flash fee on the borrowed amount.
Diagrams#
Sequence of the attack#
Pair state evolution (BEVO reserve vs. BEVO balanceOf)#
The flaw inside deliver() + balanceOf()#
Why the extraction bypasses the AMM invariant#
Why each magic number#
192.5 etherflash-borrow (BEVO_exp.sol:39): the WBNB used to buy the initial BEVO from the pair. Sized so the buy leaves a non-trivial BEVO balance in the attacker's hands to fuel the firstdeliver(); the trace shows it yields ≈ 3,028 BEVO (output.txt:1615).193 etherrepayment (BEVO_exp.sol:61): the 192.5 WBNB principal plus the WBNB/USDC pair's flash fee. The trace confirms 0.5 WBNB is retained by the flash pair (output.txt:1711:amount1In: 193e18→amount1Out: 192.5e18).337 etherWBNB-out on the final BEVO/WBNB swap (BEVO_exp.sol:59): the attacker pre-computed the BEVO/WBNB pool's post-deliver/post-skimstate and requested exactly the WBNB amount the dump could produce. The pair honours it because, after the fee-on-transfer BEVO is received (725,936,136,828,400,254,595 net, output.txt:1693), thek-check passes against the now-massive BEVO reserve.0x00/new bytes(1)flash payload (BEVO_exp.sol:39): a non-empty bytes value is required to trigger PancakeV2'sswapflash callback (pancakeCall). The content is unused — the attacker's contract is the callback target.- No magic constant for
deliver's amount: bothdelivercalls passbevo.balanceOf(this)at call time (BEVO_exp.sol:56, BEVO_exp.sol:58), i.e. "burn everything I currently hold." This maximises the_rTotalshrink per round and therefore thebalanceOf(pair)inflation.
Remediation#
- Never list an AMM pair as a reflection (non-excluded) holder. After deploying the pair, the
token owner must call
excludeAccount(pair)so the pair'sbalanceOfreturns the constant_tOwned[pair]and is immune to_rTotalchanges. This single change defusesdeliver-based inflation entirely. (Equivalently, the token could auto-exclude any address whose code exposesgetReserves()/token0().) - Gate or remove
deliver().deliveris a vestige of the "Reflect" template and has no legitimate product purpose for most deployments. Restrict it toonlyOwner, or remove it. At minimum, refuse to operate if the sender or any major holder is an AMM pair. - Use a fee-aware AMM pair. Vanilla PancakeV2 pairs assume
balanceOfonly changes viatransfer/mint/burn/swap. For reflective or fee-on-transfer tokens, deploy a pair that (a) prices on received amounts (amountIn = balanceAfter - balanceBefore) and (b) treats reflection/rebase events as explicit, pair-aware rebalances, not as freeskimopportunities. Several "rebase-aware" Uniswap forks (e.g., pair contracts that callsyncdefensively and capskim) already exist. - Make
_rTotal/ratechanges observable to the pair. If rebase/reflection must exist, emit an event and have the pair (or an off-chain keeper) callsyncto re-bindreservetobalanceOfbefore any furtherswap/skim— closing the arbitrage window. The current pair onlySyncs inside its ownswap/mint/burn, so an externaldeliverleaves it permanently stale. - Front-run / MEV hygiene on BSC. The on-chain record shows this attack was itself frontrun
(
0xd3455773…sandwiched0x68fa7746…). Projects listing reflective tokens should monitor fordeliver()+skim()+swap()call sequences in the same transaction and pause trading on detection.
How to reproduce#
The PoC runs offline against a locally-served anvil fork state (the createSelectFork in
setUp targets http://127.0.0.1:8546 with anvil_state.json pinned to block 25,230,702;
no public RPC is required):
_shared/run_poc.sh 2023-01-BEVO_exp --mt testExploit -vvvvv
- Fork: BSC state at block 25,230,702, served from the local anvil snapshot
(
anvil_state.json).foundry.tomlsetsevm_version = 'cancun'. - The test function is
testExploit()(the contract isBEVOExploit). The flash logic lives in thepancakeCallcallback (BEVO_exp.sol:43-62). - Result:
[PASS] testExploit()withWBNB balance after exploit: 144.000000000000000000.
Expected tail (from output.txt:1562-1564 and output.txt:1718-1724):
[PASS] testExploit() (gas: 414461)
Logs:
WBNB balance after exploit: 144.000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.82s (14.17s CPU time)
Ran 1 test suite in 16.83s (16.82s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Reference: QuillAudits — https://twitter.com/QuillAudits/status/1620377951836708865 (BEVO, BSC, Jan 2023, 144 BNB).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-01-BEVO_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
BEVO_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "BEVO 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.