Bankroll Stack Dividend Drain
Exploit Transactions
0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fdVictim Addresses
0x16d0a151297a0393915239373897bcc955882110BSCLoss Breakdown
Similar Incidents
BankrollNetworkStack self-buy dividend inflation exploit
43%BankrollNetworkStack WBNB Flashswap Dividend Drain
42%Gangster Finance Over-Distribution
34%RLToken Incentive Drain
33%EEECOIN Public Helper LP Drain
33%CS Pair Balance Burn Drain
33%Root Cause Analysis
Bankroll Stack Dividend Drain
1. Incident Overview TL;DR
On BNB Smart Chain transaction 0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd at block 51698204, the adversary EOA 0x172dca3e72e4643ce8b7932f4947347c1e49ba6d called helper contract 0x92c56dd0c9eee1da9f68f6e0f70c4a77de7b2b3c, borrowed 28,300 BUSD from PancakeV3 pool 0x4f3126d5de26413abdcf6948943fb9d0847d9818, and used the funds to manipulate BankrollNetworkStack 0x16d0a151297a0393915239373897bcc955882110. The attacker bought stack shares, triggered the stale dividend settlement path, sold the shares, withdrew inflated BUSD, repaid the flash loan plus 2.83 BUSD fee, and retained 5,385.806950443863798406 BUSD.
The root cause is a deterministic accounting flaw in BankrollNetworkStack. buyFor(address,uint256) mints new shares through purchaseTokens(address,uint256) before calling distribute(), and distribute() calculates payout using the full stale interval since lastPayout against the current post-buy dividendBalance_. It then applies dividendBalance_ = dividendBalance_.safeSub(profit), which zero-clamps the pool instead of capping the credit to available dividends. A flash buyer can therefore enter immediately before settlement, dominate supply, capture synthetic dividends for time before its shares existed, and exit with profit in the same transaction.
2. Key Background
BankrollNetworkStack is a fixed-price internal-share ledger funded in BUSD. A buy deposits BUSD, mints only 90% of the amount as internal shares, and routes the 10% fee into internal fee buckets including dividendBalance_. A sell burns shares and turns 90% of the burned amount into a withdrawable BUSD claim. The actual BUSD transfer happens later through withdraw().
The contract does not stream rewards continuously. Dividend settlement is lazy and only happens when user-facing functions call distribute(). At the attack pre-state, lastPayout had not advanced for 22,975,573 seconds, so the next settlement would process a large stale interval in one shot.
The relevant victim logic is visible in the verified BankrollNetworkStack source:
function buyFor(address _customerAddress, uint buy_amount) public returns (uint256) {
require(token.transferFrom(msg.sender, address(this), buy_amount));
totalDeposits += buy_amount;
uint amount = purchaseTokens(_customerAddress, buy_amount);
...
distribute();
return amount;
}
function purchaseTokens(address _customerAddress, uint256 _incomingeth) internal returns (uint256) {
uint256 _undividedDividends = SafeMath.mul(_incomingeth, entryFee_) / 100;
uint256 _amountOfTokens = SafeMath.sub(_incomingeth, _undividedDividends);
...
tokenSupply_ += _amountOfTokens;
allocateFees(_undividedDividends);
tokenBalanceLedger_[_customerAddress] = SafeMath.add(tokenBalanceLedger_[_customerAddress], _amountOfTokens);
payoutsTo_[_customerAddress] += int256(profitPerShare_ * _amountOfTokens);
return _amountOfTokens;
}
This ordering matters because the fresh shares are already part of tokenSupply_ and tokenBalanceLedger_ when distribute() runs.
3. Vulnerability Analysis & Root Cause Summary
The issue is a protocol accounting bug, not a privileged-operation failure. The contract allows a newly minted position to participate in a dividend settlement covering time before that position existed, and it does so using a payout formula that can exceed the real dividend pool.
The critical settlement logic is:
function distribute() private {
if (SafeMath.safeSub(now, lastPayout) > distributionInterval && tokenSupply_ > 0) {
uint256 share = dividendBalance_.mul(payoutRate_).div(100).div(24 hours);
uint256 profit = share * now.safeSub(lastPayout);
dividendBalance_ = dividendBalance_.safeSub(profit);
profitPerShare_ = SafeMath.add(profitPerShare_, (profit * magnitude) / tokenSupply_);
lastPayout = now;
}
}
The invariant that should hold is: dividend settlement must never credit more withdrawable BUSD than the current dividendBalance_, and balances created at time t must not earn rewards for the interval before t. BankrollNetworkStack breaks both parts. profit is computed from the stale elapsed time and the current pool after the attacker's buy adds fresh fees, then safeSub merely clamps the remaining pool to zero instead of limiting profit. Because settlement occurs after minting, the attacker receives a dominant share of the synthetic payout.
4. Detailed Root Cause Analysis
The exploitable pre-state was the BSC state immediately before the attack transaction. The validated fork and the seed artifacts agree on three key storage values: lastPayout = 1727315895, dividendBalance_ = 1344546356577870970, and totalSupply = 3034018064951572146162. The next execution timestamp was 1750291468, so the contract had accumulated more than 22.9 million seconds of stale settlement time.
When the attacker bought 28,300 BUSD of stack, purchaseTokens() minted 25,470 internal shares first and increased fee-backed balances before any settlement. Only after that did buyFor() invoke distribute(). The stale interval was therefore applied to the enlarged post-buy state rather than to the historical holder set that actually existed during the elapsed time.
The seed transaction trace shows the exact victim interaction order:
0x4f3126d5...::flash(...)
0x16d0a151...::buy(28300000000000000000000)
0x16d0a151...::sell(25470000000000000000000)
0x16d0a151...::withdraw()
The validated Foundry reproduction shows the same numerical effect. After the buy, the attacker held 25,470e18 stack shares. After sell(), the attacker's withdrawable claim exceeded 33,000 BUSD, proving that the claim was materially larger than the normal 22,923 BUSD sell proceeds and therefore included synthetic dividends created by the stale-settlement bug.
The payout realization is visible in the execution log:
BankrollNetworkStack::myDividends() [staticcall]
← [Return] 33688636481875051588537
BankrollNetworkStack::withdraw()
BUSD::transfer(attacker, 33688636481875051588537)
After withdrawal, the attacker repaid the flash pool with fee:
BUSD::transfer(PancakeV3 USDT/BUSD 1bp Pool, 28302830000000000000000)
The seed balance diff confirms the economic result. BankrollNetworkStack lost 5,388.636950443863798406 BUSD, of which 2.83 BUSD became the flash fee and 5,385.806950443863798406 BUSD ended in the adversary EOA balance. No privileged keys, governance actions, or non-public state were required; the exploit depends only on public state, public methods, and public flash liquidity.
5. Adversary Flow Analysis
The adversary strategy is a one-transaction ACT attack using a helper contract solely as an execution wrapper.
- The EOA
0x172dca3e72e4643ce8b7932f4947347c1e49ba6dsent tx0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fdto helper contract0x92c56dd0c9eee1da9f68f6e0f70c4a77de7b2b3c. - The helper called PancakeV3 pool
flash(address,uint256,uint256,bytes)and received28,300BUSD. - The helper approved BankrollNetworkStack and called
buy(uint256), which internally reachedbuyFor(address,uint256),purchaseTokens(address,uint256), and thendistribute(). - Because
distribute()settled against the stale interval after minting, the helper acquired an inflated dividend claim. - The helper immediately called
sell(uint256)for all25,470newly minted shares, preserving the inflated withdrawal entitlement while burning the temporary position. - The helper called
withdraw(), receiving33,688.636481875051588537BUSD in the validator run and33,688.636950443863798406BUSD in the incident artifacts; the small difference is bounded rounding noise and does not change the exploit mechanism. - The helper repaid
28,302.83BUSD to PancakeV3 and forwarded the remaining BUSD profit to the attacker EOA.
The ACT property is clear: every primitive in the sequence is public. The flash-loan pool is permissionless, the BankrollNetworkStack functions are public, and the decisive condition is an observable stale lastPayout value. The helper contract's own access control does not matter because any unprivileged party can deploy an equivalent helper and repeat the same sequence.
6. Impact & Losses
The victim contract was BankrollNetworkStack at 0x16d0a151297a0393915239373897bcc955882110 on BNB Smart Chain. The measured loss in the attack transaction was 5,388.636950443863798406 BUSD from the victim contract balance. The final distribution of value was:
5,385.806950443863798406BUSD to attacker EOA0x172dca3e72e4643ce8b7932f4947347c1e49ba6d2.83BUSD to PancakeV3 pool0x4f3126d5de26413abdcf6948943fb9d0847d9818as the flash fee
The balance-diff evidence is explicit:
{
"holder": "0x16d0a151297a0393915239373897bcc955882110",
"delta": "-5388636950443863798406"
}
{
"holder": "0x172dca3e72e4643ce8b7932f4947347c1e49ba6d",
"delta": "5385806950443863798406"
}
7. References
- Attack transaction:
0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd - Victim contract: BankrollNetworkStack
0x16d0a151297a0393915239373897bcc955882110 - Flash-liquidity venue: PancakeV3 pool
0x4f3126d5de26413abdcf6948943fb9d0847d9818 - Seed metadata:
/workspace/session/artifacts/collector/seed/56/0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd/metadata.json - Seed trace:
/workspace/session/artifacts/collector/seed/56/0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd/trace.cast.log - Seed balance diff:
/workspace/session/artifacts/collector/seed/56/0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd/balance_diff.json - Victim verified source:
https://bscscan.com/address/0x16d0a151297a0393915239373897bcc955882110#code - Validator execution log:
/workspace/session/artifacts/auditor/forge-test.log