BankrollNetworkStack self-buy dividend inflation exploit
Exploit Transactions
0xd4c7c11c46f81b6bf98284e4921a5b9f0ff97b4c71ebade206cb10507e4503b0Victim Addresses
0x564D4126AF2B195fFAa7fB470ED658b1D9D07A54BSCLoss Breakdown
Similar Incidents
BankrollNetworkStack WBNB Flashswap Dividend Drain
48%BRAToken self-transfer tax bug inflates pool and drains USDT
36%SlurpyCoin BuyOrSell flaw drains BNB via flash-loan swaps
33%Helio Plugin Donation Inflation
32%LifeProtocol mispriced buy/sell logic drains USDT under flash loans
32%Mosca double-withdrawal exploit via helper on BNB
32%Root Cause Analysis
BankrollNetworkStack self-buy dividend inflation exploit
1. Incident Overview TL;DR
On BNB Chain (chainid 56, block 42,481,611), an attacker-controlled orchestrator and helper used a 16,000 WBNB flash loan to exploit the BankrollNetworkStack contract at 0x564D4126AF2B195fFAa7fB470ED658b1D9D07A54. Within a single transaction (0xd4c7c11c46f81b6bf98284e4921a5b9f0ff97b4c71ebade206cb10507e4503b0), they repeatedly invoked buyFor with the contract itself as the customer, causing BankrollNetworkStack’s internal dividend and deposit accounting to grow by tens of millions of WBNB units without any corresponding increase in its actual WBNB balance.
After inflating these internal variables, the helper sold its tokens and called withdraw, extracting 404.459593848661728154 WBNB in dividends that were not backed by new deposits in this transaction. The helper repaid the flash loan principal plus fee and forwarded the remaining 404.459593848661728154 WBNB to the adversary EOA 0x4645863205b47a0A3344684489e8c446a437D66C, realizing a large net profit after paying ~0.4475 BNB in gas. The root cause is a pure accounting bug: BankrollNetworkStack assumes that the buy_amount passed into buyFor/purchaseTokens is always fully backed by new WBNB entering the contract, even when _customerAddress is the contract itself and the underlying WBNB transfer leaves the contract’s token balance unchanged.
2. Key Background
BankrollNetworkStack is a dividend-drip style staking contract that accepts a single ERC‑20 token (on BNB Chain this is WBNB at 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c). It tracks user positions via tokenBalanceLedger_, a global tokenSupply_, and a profit‑sharing mechanism using profitPerShare_ and payoutsTo_. Users deposit WBNB through buy/buyFor, paying a fixed entry fee (10%), and receive Stack tokens representing their share of future dividends. Dividends accumulate in dividendBalance_ and are distributed proportionally over time.
Withdrawals are handled by withdraw(), which calls myDividends() / dividendsOf(msg.sender) to compute a user’s dividends as:
// BankrollNetworkStack dividend mechanics (excerpt)
function dividendsOf(address _customerAddress) public view returns (uint256) {
return (uint256)((int256)(profitPerShare_ * tokenBalanceLedger_[_customerAddress]) - payoutsTo_[_customerAddress]) / magnitude;
}
function withdraw() onlyStronghands public {
uint256 _dividends = myDividends();
address _customerAddress = msg.sender;
payoutsTo_[_customerAddress] += (int256) (_dividends * magnitude);
token.transfer(_customerAddress, _dividends);
// stats and events omitted
}
The intended invariant is that dividendBalance_ and profitPerShare_ only increase when enough WBNB has actually entered the contract, so that cumulative withdrawals are always backed by the on-chain WBNB balance returned by token.balanceOf(address(this)).
The contract exposes buyFor(address _customerAddress, uint buy_amount), which is meant to deposit WBNB on behalf of any address and mint Stack tokens accordingly:
// BankrollNetworkStack::buyFor (excerpt)
function buyFor(address _customerAddress, uint buy_amount) public returns (uint256) {
require(token.transferFrom(_customerAddress, address(this), buy_amount));
totalDeposits += buy_amount;
uint amount = purchaseTokens(_customerAddress, buy_amount);
// leaderboard + distribute() omitted
return amount;
}
This function assumes that token.transferFrom(_customerAddress, address(this), buy_amount) always moves buy_amount WBNB from an external holder into the contract. That assumption is violated in the exploit.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an accounting mismatch between BankrollNetworkStack’s internal variables (dividendBalance_, totalDeposits, tokenSupply_, profitPerShare_, payoutsTo_) and the actual WBNB balance it holds. Specifically, the contract:
- Treats the
buy_amountpassed intobuyFor/purchaseTokensas fully backed incoming value. - Credits Stack tokens and dividends based solely on
buy_amount, without checking thattoken.balanceOf(address(this))increased by the same amount. - Allows
_customerAddressto be any address, including the contract itself.
When _customerAddress = address(this) and the underlying WBNB token allows transferFrom(address(this), address(this), X) to succeed without changing the contract’s balance, each buyFor(address(this), X) call:
- Increments
totalDepositsbyX. - In
purchaseTokens, computes_undividedDividends = X * entryFee_ / 100,_amountOfTokens = X - _undividedDividends, and callsallocateFees(_undividedDividends)to increasedividendBalance_andprofitPerShare_. - Increases
tokenSupply_andtokenBalanceLedger_[address(this)]as if fresh WBNB had been deposited.
Because the real on-chain WBNB balance stays constant, repeated self-buys let an adversary create arbitrarily large internal fee and dividend accounting without providing additional WBNB. After inflating these values, the attacker adjusts their virtual dividend position via sell() and then calls withdraw() to extract unbacked WBNB from the contract.
In the incident transaction, a helper funded by a flash loan performs exactly this pattern, causing dividendBalance_ to jump from ~1.58 WBNB to ~3,691,086.25 WBNB and totalDeposits to jump from ~17,136.99 WBNB to ~46,154,638.86 WBNB, even though only 16,000 WBNB actually enters the contract and 404.459593848661728154 WBNB is later withdrawn as profit.
4. Detailed Root Cause Analysis
4.1 Code-level mechanics
The critical code path is buyFor → purchaseTokens → allocateFees:
// BankrollNetworkStack purchase flow (excerpt)
function buy() public payable returns (uint256) {
uint buy_amount = msg.value;
return buyFor(msg.sender, buy_amount);
}
function buyFor(address _customerAddress, uint buy_amount) public returns (uint256) {
require(token.transferFrom(_customerAddress, address(this), buy_amount));
totalDeposits += buy_amount;
uint amount = purchaseTokens(_customerAddress, buy_amount);
// events + distribute() omitted
return amount;
}
function purchaseTokens(address _customerAddress, uint256 _incomingeth)
internal
returns (uint256)
{
uint256 _undividedDividends = SafeMath.mul(_incomingeth, entryFee_) / 100;
uint256 _amountOfTokens = SafeMath.sub(_incomingeth, _undividedDividends);
allocateFees(_undividedDividends);
// tokenSupply_, tokenBalanceLedger_ and payoutsTo_ updates omitted
}
allocateFees takes a fraction of _undividedDividends into dividendBalance_ and distributes the remainder via profitPerShare_. The design implicitly assumes that _incomingeth corresponds to actual WBNB entering the contract.
However, nothing prevents calls like buyFor(address(this), X). In the exploit, the helper ensures the contract already holds ~16,413.345861143662308303 WBNB, then causes a loop of:
BankrollNetworkStack::buyFor(BankrollNetworkStack, 16,413.345861143662308303)- internally,
WBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, 16,413.345861143662308303)emits aTransferevent but leavesWBNB.balanceOf(BankrollNetworkStack)unchanged.
The state diff for the seed transaction (bankroll_storage_diff.json) shows that during this loop:
tokenSupply_increases from about3.0e20to about4.15e25.totalDepositsincreases from about1.7e22to about4.62e25.dividendBalance_increases from about1.58WBNB to about3,691,086.25WBNB.
Meanwhile, the contract’s actual WBNB balance remains near 16,413.35 WBNB. This proves that the internal accounting has decoupled from the real asset backing.
Invariant (as encoded in root_cause.json):
For all reachable states, the total withdrawable dividend obligations encoded by dividendBalance_, profitPerShare_, payoutsTo_, and tokenBalanceLedger_ must be bounded by the actual WBNB holdings of BankrollNetworkStack. Operationally, each increase in dividendBalance_ and totalDeposits must correspond to WBNB that has actually been transferred into the contract from external holders.
Breakpoint:
In BankrollNetworkStack::buyFor(address _customerAddress, uint buy_amount), the contract always calls:
require(token.transferFrom(_customerAddress, address(this), buy_amount));
totalDeposits += buy_amount;
uint amount = purchaseTokens(_customerAddress, buy_amount);
and purchaseTokens/allocateFees treat buy_amount as new value regardless of whether token.balanceOf(address(this)) increased. When _customerAddress is address(this) and the WBNB token permits transferFrom(address(this), address(this), buy_amount) to succeed without net balance change, this breakpoint violates the invariant by inflating dividendBalance_ and totalDeposits with no real WBNB inflow.
4.2 On-chain execution in the incident transaction
Seed transaction trace (cast run -vvvvv) for 0xd4c7…4503b0 shows the following sequence:
- EOA
0x4645…D66Ccalls orchestrator0x8f92…AD14with zero BNB value. - The orchestrator deploys or invokes helper
0x4012…547D7and calls itsexecute()entrypoint. - The helper obtains a flash loan of 16,000 WBNB (plus 8 WBNB fee) from pool
0x3669…2050via aFlash-style callback, then callsWBNB.approve(BankrollNetworkStack, 2^256-1). - The helper calls
BankrollNetworkStack::buyFor(helper, 16,000 WBNB), performing a normal deposit that leaves BankrollNetworkStack with ~16,413.35 WBNB and mints 14,400 Stack tokens to the helper. - The helper then causes a long loop of
BankrollNetworkStack::buyFor(BankrollNetworkStack, 16,413.345861143662308303 WBNB), each time invokingWBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, 16,413.345861143662308303). WBNBTransferevents show the contract transferring WBNB to itself, and a laterbalanceOfconfirms the on-chain WBNB balance remains constant. - After enough self-buys, the helper calls
BankrollNetworkStack::sell(14,400), burning its tokens and adjusting its virtual dividend position via the exit fee. - Finally, the helper calls
BankrollNetworkStack::withdraw(), receiving exactly 404.459593848661728154 WBNB. - The helper repays 16,008 WBNB to the flash loan pool and transfers 404.459593848661728154 WBNB to the EOA.
The trace log and storage diff together demonstrate that the large dividend payout cannot be justified by real WBNB inflows in this transaction; instead, it is created by the repeated invariant-violating self-buys.
4.3 Code or trace snippets used
- Seed transaction trace (cast run -vvvvv) for
0xd4c7…4503b0shows:
// Seed transaction trace (excerpt)
BankrollNetworkStack::buyFor(0x4012…547D7, 16000000000000000000000)
…
BankrollNetworkStack::buyFor(0x564D…7A54, 16413345861143662308303)
WBNB::transferFrom(0x564D…7A54, 0x564D…7A54, 16413345861143662308303)
…
BankrollNetworkStack::sell(14400)
BankrollNetworkStack::withdraw()
WBNB::transfer(0x4012…547D7, 404459593848661728154)
…
WBNB::transfer(0x4645…D66C, 404459593848661728154)
- Bankroll storage diff for the same transaction shows dramatic increases in
totalDepositsanddividendBalance_with no corresponding increase in the actual WBNB balance, corroborating the self-buy accounting inflation.
5. Adversary Flow Analysis
5.1 Adversary-related cluster
The adversary-related cluster comprises:
- EOA
0x4645863205b47a0A3344684489e8c446a437D66C– signer of the seed transaction and final WBNB profit recipient. - Orchestrator contract
0x8f921E27e3AF106015D1C3a244eC4F48dBFcAD14– repeatedly deployed and invoked by the EOA; decompiled code shows it accepts target addresses/amounts, performs ERC‑20 approvals and loan setup, and encodes profit routing to the EOA. - Helper contract
0x40122cEcaAaD5dd1c1da4d8cEc42120565C547D7– created and used only within the seed transaction; its decompiledexecute()entrypoint hard-codes the flash loan pool0x3669…2050, WBNB token address, and EOA profit recipient.
These accounts are linked deterministically by tx history and decompiled control flow and are sufficient to realize the exploit.
5.2 End-to-end transaction sequence b
Transaction sequence b consists of a single adversary-crafted transaction:
- Chain: BNB Chain (56)
Tx:0xd4c7c11c46f81b6bf98284e4921a5b9f0ff97b4c71ebade206cb10507e4503b0
Role: adversary-crafted
High-level flow:
- EOA → Orchestrator:
0x4645…D66Csends a zero-value transaction to orchestrator0x8f92…AD14, specifying parameters that configure the flash loan and BankrollNetworkStack interaction. - Orchestrator → Helper (deploy) and → Flash pool:
The orchestrator deploys or selects helper0x4012…547D7and instructs pool0x3669…2050to grant a 16,000 WBNB flash loan to the helper. - Helper executes exploit sequence:
- Approves BankrollNetworkStack to spend its WBNB.
- Calls
BankrollNetworkStack::buyFor(helper, 16,000 WBNB)– normal deposit, minting 14,400 tokens. - Repeatedly calls
BankrollNetworkStack::buyFor(BankrollNetworkStack, X)using the contract’s entire WBNB balance andWBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, X), inflating internal accounting. - Calls
BankrollNetworkStack::sell(14,400)to rebalance its dividend position. - Calls
BankrollNetworkStack::withdraw()to withdraw 404.459593848661728154 WBNB.
- Helper → Flash pool and EOA:
- Transfers 16,008 WBNB back to the pool (principal + fee).
- Transfers the remaining 404.459593848661728154 WBNB to
0x4645…D66C.
5.3 ACT opportunity and inclusion feasibility
The exploit constitutes an ACT opportunity:
- All components are permissionless: WBNB, the flash loan pool at
0x3669…2050, and BankrollNetworkStack itself are publicly accessible contracts. - The adversary uses only standard EVM transactions and canonical on-chain data (no private keys beyond their own, no admin keys or whitelisting).
- Any unprivileged EOA on BNB Chain can deploy its own orchestrator and helper contracts that replicate this logic:
- Obtain a large WBNB flash loan from the same or a similar pool.
- Route the WBNB into BankrollNetworkStack via
buyFor. - Execute the same self-buy loop,
sell, andwithdrawsequence. - Repay the loan and keep the residual WBNB.
While the observed orchestrator includes checks tying msg.sender/tx.origin to the attacker’s EOA, this does not limit ACT feasibility, because a different EOA can deploy functionally equivalent contracts without those checks and reproduce the strategy.
6. Impact & Losses
In the observed incident transaction:
- BankrollNetworkStack transfers 404.459593848661728154 WBNB to helper
0x4012…547D7viawithdraw(), with these dividends not backed by new WBNB deposits in that transaction. - The helper repays the flash loan (16,008 WBNB) and forwards the full 404.459593848661728154 WBNB to the adversary EOA
0x4645…D66C. - The EOA pays 447,548,563,040,686,233 wei in gas (~0.447548563040686233 BNB), so the net WBNB‑denominated profit from this single transaction is strictly greater than 404 WBNB.
The protocol’s effective loss is at least 404.459593848661728154 WBNB of value previously held for users in BankrollNetworkStack. Those WBNB tokens are now in the attacker’s possession, and earlier participants suffer a corresponding reduction in the remaining dividend pool and effective token value. The precise distribution of losses across individual users depends on the pre‑incident holder set and is not reconstructed here.
7. References
- Seed transaction metadata and trace for
0xd4c7…4503b0(includingtrace.cast.logand QuickNodereceipt.json), used to reconstruct the call sequence, flash loan amounts, WBNB transfers, and gas costs. - Verified BankrollNetworkStack source code at
0x564D4126AF2B195fFAa7fB470ED658b1D9D07A54(Contract.sol), used to derive the invariant, breakpoint, and detailed accounting behavior. - BankrollNetworkStack storage diff and layout (
bankroll_storage_diff.json,bankroll_storage_layout.json) derived fromdebug_traceTransaction, used to quantify changes intokenSupply_,totalDeposits, anddividendBalance_during the exploit. - Helper contract
0x40122cEcaAaD5dd1c1da4d8cEc42120565C547D7decompilation, used to confirm flash loan orchestration and profit forwarding to the EOA. - Orchestrator contract
0x8f921E27e3AF106015D1C3a244eC4F48dBFcAD14decompilation and ABI, used to confirm how the attacker parameterizes the exploit and routes calls through the helper and victim contract.