Calculated from recorded token losses using historical USD prices at the incident time.
0x5446bf2b57749abdab01813a50ce36246177f3437599f3a56bc1554f596b2c3a0xb1de93dae1cddf429eec9db30b78759d17495758BSC0x74f5fe81f67fa30a679d3547f7f9b97a2dd46ba5BSCEXgirl on BSC exposed a permissionless drain at block 39123846 because its buy-accounting path trusted the live USDT balance of the EXgirl/USDT Pancake pair minus stale reserves on every pair-origin transfer, including zero-value transfers. In seed transaction 0x5446bf2b57749abdab01813a50ce36246177f3437599f3a56bc1554f596b2c3a, the adversary used a flash-funded setup to create a large reserve gap, replayed that same gap with repeated EXgirl.transferFrom(pair, attacker, 0) calls, skimmed the parked USDT back out, and then triggered EXgirl._distribute() so distributor-held EXgirl was dumped into the pair. The ACT root cause is this zero-amount-reachable accounting flaw in EXgirl, not the later attacker-specific EXboy monetization leg that happened in the observed transaction.
EXgirl (0xb1de93dae1cddf429eec9db30b78759d17495758) is an ERC20 token with custom accounting inside _update(). When transfers originate from its Pancake pair 0x74f5fe81f67fa30a679d3547f7f9b97a2dd46ba5, EXgirl interprets the transfer as a buy, computes purchaseAmount = tokenBal - reserve0, and accumulates that value into purchasedAmount. Later, on a non-pair transfer, _distribute() translates purchasedAmount into tokenDistributor.distributeA(amountIn), which sells distributor-held EXgirl back into the pair.
Three background details make the bug exploitable. First, ERC20 is valid as long as allowance is at least zero, so a zero-value pair-origin transfer still reaches EXgirl's buy branch. Second, a direct USDT transfer into the pair increases immediately while reserves remain stale until sync-sensitive pair logic runs. Third, the EXgirl distributor already held enough inventory for to sell a large amount of EXgirl once was artificially inflated.
transferFrom(..., 0)tokenBaldistributeApurchasedAmountThe vulnerability is an attack-class accounting flaw in EXgirl's buy detection. The verified source shows that _update() treats any pair-origin transfer that is not a remove-liquidity path as a buy and computes purchaseAmount entirely from the current pair USDT balance minus the stored reserve snapshot. That computation is not conditioned on value > 0, so a transfer that moves zero EXgirl still increments purchasedAmount. The computation also does not consume or snapshot the reserve gap after first use, so the same parked USDT can be counted repeatedly before reserves are synchronized. Once purchasedAmount becomes large enough, a later ordinary EXgirl transfer executes _distribute(), resets purchasedAmount to 1, and sells distributor inventory into the pair. The drain therefore comes from fabricated demand accounting reaching the distribution mechanism, which nearly empties the pair's USDT balance.
The vulnerable code path is visible in the verified EXgirl source:
} else if (from == pair && !isRemoveLiquidity()) {
(uint256 reserve0, , ) = IUniswapV2Pair(pair).getReserves();
uint256 tokenBal = IERC20(token0).balanceOf(pair);
uint256 purchaseAmount = tokenBal - reserve0;
purchasedAmount += purchaseAmount;
}
...
function _distribute() internal {
if (purchasedAmount <= 1) return;
uint256 pendingSaleAmount = purchasedAmount * advantageInfo.rebalanceRatio / PRECISION - 1;
purchasedAmount = 1;
uint256 amountIn = pendingSaleAmount * PRECISION / getPrice();
tokenDistributor.distributeA(amountIn);
}
The exploit starts from BSC pre-state immediately before the seed transaction. The EXgirl/USDT pair already had material liquidity, EXgirl's purchasedAmount was modest, and the distributor held EXgirl inventory. The attacker contract 0x30eeec783c7c4d5b557c69620a54239bed9abf1c, funded and triggered by EOA 0xb6911dee6a5b1c65ad1ac11a99aec09c2cf83c0e, first performed a real 1e18 USDT buy so later pair-origin transfers would traverse the buy branch. The trace then shows a large USDT transfer into the pair, increasing live pair balance while reserves remained unchanged.
The decisive invariant break is that purchasedAmount must only reflect distinct real buys. Instead, the trace shows repeated zero-value transfers from the pair to the attacker contract while the pair's reserve snapshot and live balance stayed far apart:
EXgirl::transferFrom(PancakePair, 0x30EEEc..., 0)
PancakePair::getReserves() -> reserve0 = 70782381269747275279461
BEP20USDT::balanceOf(PancakePair) -> 469782381269747275279461
emit Transfer(from: PancakePair, to: 0x30EEEc..., value: 0)
storage @7 increases
Those repeated calls re-added the same reserve gap to purchasedAmount without moving EXgirl. Because the pair was still unsynced, the attacker could loop this accounting inflation many times. After the loop, the attacker recovered the parked USDT via PancakePair::skim(address(this)), proving the parked capital itself was not the loss source. The next non-pair EXgirl transfer then triggered _distribute(), which translated the fabricated purchasedAmount into distributeA(amountIn) and sold distributor-held EXgirl into the pair until the pair's USDT balance was almost exhausted.
The balance diff confirms the economic effect on the victim pool. The pair's USDT balance changed from 70781381269747275279461 to 139589603922874094572, a loss of 70641791665824401184889 raw units. The same artifact also shows that the attacker contract's remaining USDT balance of 40332492809251555297 came from later monetization behavior in the seed transaction rather than from the ACT predicate itself. The root cause is therefore fully explained by EXgirl's zero-value buy accounting and distributor sell path.
The adversary flow has four stages, all visible inside transaction 0x5446bf2b57749abdab01813a50ce36246177f3437599f3a56bc1554f596b2c3a.
399000 USDT into the EXgirl pair so that tokenBal > reserve0.EXgirl.transferFrom(pair, attacker, 0). The trace shows these calls in a dense loop, and EXgirl storage slot 7 grows on each iteration even though each emitted Transfer had value 0.PancakePair::skim, recovered the parked USDT, then performed a small EXgirl transfer to a third-party recipient. Because that transfer was not pair-origin, EXgirl ran _distribute() and forced the distributor to dump EXgirl into the pair.The seed trace therefore supports the auditor's separation between the reproducible exploit predicate and the attacker's incidental monetization path.
The reproducible ACT impact is depletion of the EXgirl/USDT pair's USDT liquidity and corruption of EXgirl's demand accounting. The measured pool loss in the seed transaction is:
70641791665824401184889 raw units (decimal = 18)The pair ended with only 139589603922874094572 raw-unit USDT, which is effectively dust relative to the starting reserve. This is sufficient to classify the incident as an attack against EXgirl's accounting and distribution logic.
0x5446bf2b57749abdab01813a50ce36246177f3437599f3a56bc1554f596b2c3a0xb1de93dae1cddf429eec9db30b78759d174957580xdf4895cd8247284ae3a7b3e28cf6c03113fada5f