This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0xd6ba15ecf3df9aaae37450df8f79233267af41535793ee1f69c565b50e28f7da0x384b9fb6e42dab87f3023d87ea1575499a69998eBSC0x4c736d24d72d874cc2465553500c1ff3fc7b3bdaBSC0x824eb9fadfb377394430d2744fa7c42916de3eceBSC0x1fcc3b22955e76ca48bf025f1a6993685975bb9eBSCProtocol and context. The FortuneWheel contract on BSC is a casino-style game that maintains per-casino liquidity in BNBP, BNB, and other ERC20 tokens. It uses Chainlink VRF for randomness and periodically swaps accumulated fees into BNBP and LINK to fund the game and its VRF subscription.
High-level incident summary. In block 45640246 on BSC (chainid 56), an unprivileged adversary EOA 0xf1e73123594cb0f3655d40e4dd6bde41fa8806e8 deployed a helper contract, took a large WBNB flash loan from a Pancake V3 pool, and temporarily distorted AMM prices in the WBNB–BEP20LINK and BNBP–WBNB pools. While these pools were manipulated, the helper contract externally called FortuneWheel.swapProfitFees() on 0x384b9fb6E42dab87F3023D87ea1575499A69998E. Because this function relies directly on PancakeRouter spot prices (getAmountsIn / getAmountsOut) and can draw additional casino liquidity when fees are insufficient, it over-consumed FortuneWheel’s BNBP and BNB liquidity under the attacker-chosen price configuration. The subsequent swaps and VRF funding flows left the adversary’s helper contract with a large net WBNB surplus; 30.968 WBNB was immediately forwarded to the EOA, and the remainder stayed on the helper.
Root cause (brief). The fundamental bug is protocol-level price manipulation in a public fee-swap routine. swapProfitFees():
PancakeRouter.getAmountsIngetAmountsOutUnder a flash-loan-induced price distortion, an adversary can deterministically force FortuneWheel to swap an attacker-chosen amount of its liquidity into LINK and BNB in a way that ultimately increases the adversary’s WBNB holdings.
FortuneWheel design.
tokenIdToCasino mapping, including liquidity, roundLiquidity, locked, fee, profit, and LINK consumption via linkSpent[tokenId].swapProfitFees(), which:
linkSpent and PancakeRouter prices.Helper contract role.
0x818CD70bE0C9DEC3B0bc52eFAACEb06469Ce587F is not a public protocol component. Its decompiled source shows:
tx.origin check).claimToken(address token) function that transfers the entire token balance of token to the owner.0x818C… an adversary-controlled vault for aggregating profits from attack scripts.AMM environment.
0x172fcD41E0913e95784454622d1c3724f546f849 (WBNB-based).0x824eb9faDFb377394430d2744fa7C42916DE3eCe.0x4C736d24d72D874cc2465553500c1Ff3Fc7b3BDA.Code evidence – FortuneWheel fee-swap and price usage.
The FortuneWheel source (verified and collected) shows swapProfitFees() as a public function with no access control, and price helpers that rely on PancakeRouter:
// FortuneWheel fee-swap and price helpers (FortuneWheel.sol)
function getTokenAmountForLink(address tokenAddr, uint256 linkAmount) public view returns (uint256) {
IPancakeRouter02 router = IPancakeRouter02(pancakeRouterAddr);
address[] memory path;
if (tokenAddr == address(0) || tokenAddr == wbnbAddr) {
path = new address[](2);
path[0] = wbnbAddr;
path[1] = linkTokenAddr;
} else {
path = new address[](3);
path[0] = tokenAddr;
path[1] = wbnbAddr;
path[2] = linkTokenAddr;
}
return router.getAmountsIn(linkAmount, path)[0];
}
function getLinkAmountForToken(address tokenAddr, uint256 tokenAmount) public view returns (uint256) {
IPancakeRouter02 router = IPancakeRouter02(pancakeRouterAddr);
address[] memory path;
bool isBNB = tokenAddr == address(0) || tokenAddr == wbnbAddr;
if (isBNB) {
path = new address[](2);
path[0] = wbnbAddr;
path[1] = linkTokenAddr;
} else {
path = new address[](3);
path[0] = tokenAddr;
path[1] = wbnbAddr;
path[2] = linkTokenAddr;
}
return router.getAmountsOut(tokenAmount, path)[isBNB ? 1 : 2];
}
Snippet origin: Collected FortuneWheel source for contract 0x384b9f…, demonstrating direct reliance on PancakeRouter AMM spot prices.
Pre-state σ_B at block 45640246.
0x384b9fb6E42dab87F3023D87ea1575499A69998E.0x818CD70bE0C9DEC3B0bc52eFAACEb06469Ce587F.0x172fcD41E0913e95784454622d1c3724f546f849.0x824eb9faDFb377394430d2744fa7C42916DE3eCe (WBNB–BEP20LINK), 0x4C736d24d72D874cc2465553500c1Ff3Fc7b3BDA (BNBP–WBNB).0x1FCc3B22955e76Ca48bF025f1A6993685975Bb9e.0xF8A0BF9cF54Bb92F17374d9e9A321E6a111a51bD, LINK677 0x404460C6A5EdE2D891e8297795264fDe62ADBB75, VRF coordinator 0xc587d9053cd1118f25F645F9E08BB98c9712A4EE.0xf1e73123594cb0f3655d40e4dd6bde41fa8806e8.prestateTracer-based state diff for FortuneWheel.Transaction sequence B.
0xd6ba15ecf3df9aaae37450df8f79233267af41535793ee1f69c565b50e28f7da.0xf1e7….swapProfitFees().0x172f….FortuneWheel.swapProfitFees() while prices are distorted so it over-consumes casino liquidity and LINK reserves.0x818C… back to the EOA.Exploit predicate (profit).
0xf1e73123594cb0f3655d40e4dd6bde41fa8806e8, jointly analyzed with helper 0x818C… as an adversary cluster.0x172f… (difference between 4.3e21 WBNB borrowed and 4.30043e21 WBNB repaid).value_before_in_reference_asset: unknown (the initial WBNB holdings of the cluster are not fully reconstructed).value_after_in_reference_asset: unknown for the same reason.Transfer logs involving either:
0xf1e7…, or0x818C….+4.314136937387847602508e21 wei = +4314.136937387847602508 WBNB.Trace evidence – WBNB flows.
// Key WBNB transfers touching the adversary cluster (cast trace)
WBNB::transfer(0x818C…, 4300000000000000000000 [4.3e21]) // flash-loan in from V3 pool
WBNB::transfer(PancakeRouter: 0x10ED…, 16831183026189930161) // helper -> router during swaps
WBNB::transfer(0x818C…, 4331398120414037532669 [4.331e21]) // WBNB–LINK pair -> helper
WBNB::transfer(PancakeV3Pool: 0x172f…, 4300430000000000000000) // helper -> pool (principal + fee)
WBNB::transfer(0xf1e7…, 30968120414037532669 [3.096e19]) // helper -> EOA payout
Snippet origin: Seed transaction trace (trace.cast.log) for tx 0xd6ba15…, highlighting WBNB flows to and from the adversary cluster.
Vulnerability brief.
swapProfitFees() is a public fee-swap routine that:
getAmountsIn / getAmountsOut) as an on-chain oracle for how much token value is needed for LINK funding.Under flash-loan manipulation of the underlying AMM pools, this logic allows an adversary to force an oversized conversion of FortuneWheel’s liquidity into LINK and BNB, generating a deterministic profit in WBNB for an adversary-controlled account.
Detailed root-cause mechanics.
Per-casino profit and fee computation.
tokenId in tokenIdToCasino:
availableProfit is max(casino.profit, 0) but capped at casino.liquidity.gameFee starts as availableProfit * casino.fee / 100.amountForLinkFee is computed via getTokenAmountForLink(casino.tokenAddress, linkSpent[i]), which uses PancakeRouter spot prices.Liquidity top-up when fees are insufficient.
gameFee < amountForLinkFee, the function directly draws the shortfall from casino.liquidity:
(amountForLinkFee - gameFee)._updateProfitInfo() then adjusts casino.liquidity, casino.profit, and lastSwapTime, while _updateLinkConsumptionInfo() uses getLinkAmountForToken() to adjust linkSpent.Swapping casino tokens into BNB, LINK, and BNBP.
swapProfitFees() approves PancakeRouter and calls swapExactTokensForETH(gameFee + amountForLinkFee, ...) using a [token, WBNB] path.totalBNBForGame).totalBNBForLink).totalBNBForLink > 0, it calls swapExactETHForTokens on path [WBNB, LINK] to buy LINK, then:
LinkTokenInterface(link677TokenAddr).transferAndCall(coordinatorAddr, linkAmount, ...) to fund the Chainlink VRF subscription.totalBNBForGame > 0, it swaps BNB into BNBP and passes the resulting BNBP into a tokenomics pool via addAdminTokenValue.Public exposure and oracle misuse.
swapProfitFees() is declared external and has no onlyOwner or onlyCasinoOwner modifier, making it publicly callable by any account at any time.getTokenAmountForLink() and getLinkAmountForToken() use current AMM reserves (via PancakeRouter) without TWAPs, sanity bounds, or manipulation checks.How this becomes exploitable.
swapProfitFees() while pool prices are skewed, so amountForLinkFee is calculated under attacker-chosen prices.swapProfitFees() to:
Evidence from the seed transaction.
0xd6ba15… shows:
4.3e21 WBNB from Pancake V3 pool 0x172f… to helper 0x818C….0x824e… that significantly distort pool reserves.FortuneWheel.swapProfitFees() from the helper contract.swapProfitFees():
3.659785525e22 BNBP from FortuneWheel into the BNBP–WBNB pair 0x4C736d….linkSpent and related storage slots) is updated accordingly.Adversary strategy summary.
A single-transaction attack using:
swapProfitFees()) that trusts these manipulated prices.Adversary-related accounts.
0xf1e73123594cb0f3655d40e4dd6bde41fa8806e8.-0.001770583 BNB).3.0968120414037532669e19 wei (~30.968 WBNB) as a direct payout at the end of the tx.0x818CD70bE0C9DEC3B0bc52eFAACEb06469Ce587F.3.0968120414037532669e19 WBNB to the EOA, with no WBNB outflows from the EOA.claimToken(token) that transfers full token balances to a privileged address.Victim candidates.
0x384b9fb6E42dab87F3023D87ea1575499A69998E.swapProfitFees() under manipulated prices.0x4C736d24d72D874cc2465553500c1Ff3Fc7b3BDA.3.659785525e22 BNBP from FortuneWheel and swaps it into WBNB.0x824eb9faDFb377394430d2744fa7C42916DE3eCe.1.6278878565747828785e19 BEP20LINK, which is routed via PegSwap into ERC677 LINK.0x1FCc3B22955e76Ca48bF025f1A6993685975Bb9e.0x404460C6A5EdE2D891e8297795264fDe62ADBB75.0xc587d9053cd1118f25F645F9E08BB98c9712A4EE.Lifecycle stages of the exploit.
Flash loan and price priming.
4.3e21 WBNB from pool 0x172f….swapExactTokensForTokensSupportingFeeOnTransferTokens, the helper routes WBNB through the WBNB–BEP20LINK pair 0x824e…, distorting the spot price and reserves.getTokenAmountForLink() and getLinkAmountForToken() that will be used by swapProfitFees().trace.cast.log shows the flash loan, WBNB transfers, and WBNB–LINK swaps.swapProfitFees() execution under manipulated prices.
FortuneWheel.swapProfitFees():
getTokenAmountForLink() and getLinkAmountForToken() against manipulated AMM reserves.amountForLinkFee, then:
_updateProfitInfo() to reduce casino.liquidity and casino.profit._updateLinkConsumptionInfo() to adjust linkSpent.3.659785525e22 BNBP to PancakeRouter and swaps it into WBNB via the BNBP–WBNB pair 0x4C736d….casino.liquidity and related fields.linkSpent and VRF funding counters).Unwind, repayment, and profit realization.
swapProfitFees():
3.0968120414037532669e19 wei (~30.968 WBNB) to the EOA 0xf1e7….Transfer events show outflows from the EOA, so the cluster’s WBNB position strictly increases.Token-level loss overview (per transaction 0xd6ba15…).
Transfer logs and prestate-based accounting.3.659785525e22 BNBP into the BNBP–WBNB pair 0x4C736d…, corresponding to 36,597.85525 BNBP when expressed in token units.0x824e… loses 1.6278878565747828785e19 BEP20LINK.Qualitative impact.
liquidity and profit entries for affected casinos.[1] Seed transaction trace.
Full cast-style execution trace for tx 0xd6ba15ecf3df9aaae37450df8f79233267af41535793ee1f69c565b50e28f7da on BSC, including all internal calls, events, and storage changes for FortuneWheel, WBNB, BNBP, BEP20LINK, AMM pools, PegSwap, and Chainlink VRF.
[2] FortuneWheel source and ABI.
Verified Solidity source and ABI for 0x384b9fb6E42dab87F3023D87ea1575499A69998E, showing the implementation of swapProfitFees(), getTokenAmountForLink(), getLinkAmountForToken(), casino accounting structures, and LINK consumption tracking.
[3] FortuneWheel prestateTracer state diff.
Pre/post state diff for FortuneWheel in the seed transaction, highlighting changes in liquidity, profit, LINK-related storage slots (linkSpent), and VRF funding counters.
[4] Extended prestate-based balance diff.
Per-address balance diffs (including WBNB and other tokens) around tx 0xd6ba15…, used to cross-check WBNB and LINK movements and native BNB gas expenditure.
[5] Helper contract decompiled source.
Heimdall decompilation of helper contract 0x818C…, demonstrating its owner-gated claimToken function and generic call/withdraw capabilities, confirming its role as a private adversary vault rather than a public protocol contract.