Calculated from recorded token losses using historical USD prices at the incident time.
0xd4c7c11c46f81b6bf98284e4921a5b9f0ff97b4c71ebade206cb10507e4503b00x564D4126AF2B195fFAa7fB470ED658b1D9D07A54BSCOn 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.
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 , a global , and a profit‑sharing mechanism using and . Users deposit WBNB through /, paying a fixed entry fee (10%), and receive Stack tokens representing their share of future dividends. Dividends accumulate in and are distributed proportionally over time.
tokenBalanceLedger_tokenSupply_profitPerShare_payoutsTo_buybuyFordividendBalance_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.
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:
buy_amount passed into buyFor/purchaseTokens as fully backed incoming value.buy_amount, without checking that token.balanceOf(address(this)) increased by the same amount._customerAddress to 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:
totalDeposits by X.purchaseTokens, computes _undividedDividends = X * entryFee_ / 100, _amountOfTokens = X - _undividedDividends, and calls allocateFees(_undividedDividends) to increase dividendBalance_ and profitPerShare_.tokenSupply_ and tokenBalanceLedger_[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.
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)WBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, 16,413.345861143662308303) emits a Transfer event but leaves WBNB.balanceOf(BankrollNetworkStack) unchanged.The state diff for the seed transaction (bankroll_storage_diff.json) shows that during this loop:
tokenSupply_ increases from about 3.0e20 to about 4.15e25.totalDeposits increases from about 1.7e22 to about 4.62e25.dividendBalance_ increases from about 1.58 WBNB to about 3,691,086.25 WBNB.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.
Seed transaction trace (cast run -vvvvv) for 0xd4c7…4503b0 shows the following sequence:
0x4645…D66C calls orchestrator 0x8f92…AD14 with zero BNB value.0x4012…547D7 and calls its execute() entrypoint.0x3669…2050 via a Flash-style callback, then calls WBNB.approve(BankrollNetworkStack, 2^256-1).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.BankrollNetworkStack::buyFor(BankrollNetworkStack, 16,413.345861143662308303 WBNB), each time invoking WBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, 16,413.345861143662308303). WBNB Transfer events show the contract transferring WBNB to itself, and a later balanceOf confirms the on-chain WBNB balance remains constant.BankrollNetworkStack::sell(14,400), burning its tokens and adjusting its virtual dividend position via the exit fee.BankrollNetworkStack::withdraw(), receiving exactly 404.459593848661728154 WBNB.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.
0xd4c7…4503b0 shows:// 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)
totalDeposits and dividendBalance_ with no corresponding increase in the actual WBNB balance, corroborating the self-buy accounting inflation.The adversary-related cluster comprises:
0x4645863205b47a0A3344684489e8c446a437D66C – signer of the seed transaction and final WBNB profit recipient.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.0x40122cEcaAaD5dd1c1da4d8cEc42120565C547D7 – created and used only within the seed transaction; its decompiled execute() entrypoint hard-codes the flash loan pool 0x3669…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.
Transaction sequence b consists of a single adversary-crafted transaction:
0xd4c7c11c46f81b6bf98284e4921a5b9f0ff97b4c71ebade206cb10507e4503b0High-level flow:
0x4645…D66C sends a zero-value transaction to orchestrator 0x8f92…AD14, specifying parameters that configure the flash loan and BankrollNetworkStack interaction.0x4012…547D7 and instructs pool 0x3669…2050 to grant a 16,000 WBNB flash loan to the helper.BankrollNetworkStack::buyFor(helper, 16,000 WBNB) – normal deposit, minting 14,400 tokens.BankrollNetworkStack::buyFor(BankrollNetworkStack, X) using the contract’s entire WBNB balance and WBNB::transferFrom(BankrollNetworkStack, BankrollNetworkStack, X), inflating internal accounting.BankrollNetworkStack::sell(14,400) to rebalance its dividend position.BankrollNetworkStack::withdraw() to withdraw 404.459593848661728154 WBNB.0x4645…D66C.The exploit constitutes an ACT opportunity:
0x3669…2050, and BankrollNetworkStack itself are publicly accessible contracts.buyFor.sell, and withdraw sequence.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.
In the observed incident transaction:
0x4012…547D7 via withdraw(), with these dividends not backed by new WBNB deposits in that transaction.0x4645…D66C.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.
0xd4c7…4503b0 (including trace.cast.log and QuickNode receipt.json), used to reconstruct the call sequence, flash loan amounts, WBNB transfers, and gas costs.0x564D4126AF2B195fFAa7fB470ED658b1D9D07A54 (Contract.sol), used to derive the invariant, breakpoint, and detailed accounting behavior.bankroll_storage_diff.json, bankroll_storage_layout.json) derived from debug_traceTransaction, used to quantify changes in tokenSupply_, totalDeposits, and dividendBalance_ during the exploit.0x40122cEcaAaD5dd1c1da4d8cEc42120565C547D7 decompilation, used to confirm flash loan orchestration and profit forwarding to the EOA.0x8f921E27e3AF106015D1C3a244eC4F48dBFcAD14 decompilation and ABI, used to confirm how the attacker parameterizes the exploit and routes calls through the helper and victim contract.