All incidents

Bankroll Stack Dividend Drain

Share
Jun 19, 2025 00:04 UTCAttackLoss: 5,388.64 BUSDPending manual check1 exploit txWindow: Atomic
Estimated Impact
5,388.64 BUSD
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Jun 19, 2025 00:04 UTC → Jun 19, 2025 00:04 UTC

Exploit Transactions

TX 1BSC
0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd
Jun 19, 2025 00:04 UTCExplorer

Victim Addresses

0x16d0a151297a0393915239373897bcc955882110BSC

Loss Breakdown

5,388.64BUSD

Similar Incidents

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.

  1. The EOA 0x172dca3e72e4643ce8b7932f4947347c1e49ba6d sent tx 0x0706425beba4b3f28d5a8af8be26287aa412d076828ec73d8003445c087af5fd to helper contract 0x92c56dd0c9eee1da9f68f6e0f70c4a77de7b2b3c.
  2. The helper called PancakeV3 pool flash(address,uint256,uint256,bytes) and received 28,300 BUSD.
  3. The helper approved BankrollNetworkStack and called buy(uint256), which internally reached buyFor(address,uint256), purchaseTokens(address,uint256), and then distribute().
  4. Because distribute() settled against the stale interval after minting, the helper acquired an inflated dividend claim.
  5. The helper immediately called sell(uint256) for all 25,470 newly minted shares, preserving the inflated withdrawal entitlement while burning the temporary position.
  6. The helper called withdraw(), receiving 33,688.636481875051588537 BUSD in the validator run and 33,688.636950443863798406 BUSD in the incident artifacts; the small difference is bounded rounding noise and does not change the exploit mechanism.
  7. The helper repaid 28,302.83 BUSD 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.806950443863798406 BUSD to attacker EOA 0x172dca3e72e4643ce8b7932f4947347c1e49ba6d
  • 2.83 BUSD to PancakeV3 pool 0x4f3126d5de26413abdcf6948943fb9d0847d9818 as 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