EST BNBDeposit Claim Abuse and Pair Reserve Manipulation
On 2026-03-27, the EST / BNBDeposit system on BNB Smart Chain was exploited through a flash-loan-assisted reward-accounting flaw in BNBDeposit, amplified by fee-exempt routing and pair-state manipulation in EST. The attacker borrowed 250,000 WBNB, built a temporary claim-bearing share in BNBDeposit via 34 deposits of 0.3 BNB, routed 400 WBNB worth of EST into the fee-exempt deposit contract, and triggered onTokenReceived with exactly 1 EST. BNBDeposit then paid out 20,569,915.273855479094886078 EST from its live balance. After routing another 245,000 WBNB worth of EST into the same sink and running 150 skim() cycles against the EST/WBNB pair, the attacker exited 19,730,833.203602432219877940 EST back to WBNB. Over the full transaction, the EST/WBNB pair lost 154.571495162546593567 WBNB net, while the attacker realized 150.689451187958247246 WBNB in liquid profit before gas, excluding any residual value of the temporary internal LP position.
Root Cause
Vulnerable Contracts
Contract:
BNBDepositAddress:
0xe71547170c5ad5120992b85cf1288fab23d29a61Proxy: No
Source type: Verified
Contract:
ESTTokenAddress:
0xd4524be41cd452576ab9ff7b68a0b89af8498a91Proxy: No
Source type: Verified
The observed profit path in this transaction relied on both contracts:
BNBDepositincorrectly treats its entire live EST balance as immediately claimable reward inventory.ESTTokenprovides the deterministic callback path used in this transaction: an exact1 ESTtransfer todepositContracttriggersBNBDeposit.onTokenReceived(from)when the sender also satisfiestx.origin == user.ESTTokenalso exempts the deposit contract from transfer fees and mutates pair reserves withsync()during sells, which makes the attacker’s exit materially more favorable.
Vulnerable Functions
BNBDeposit.onTokenReceived(address user)BNBDeposit._claimToken(address user)ESTToken._transfer(address from, address to, uint256 amount)
The primary accounting flaw is in BNBDeposit._claimToken. In this transaction, the attacker used ESTToken._transfer as a convenient trigger path because it calls onTokenReceived when exactly 1 EST is transferred to the configured depositContract.
Vulnerable Code
// BNBDeposit.sol
function onTokenReceived(address user) external {
require(!_locked, "Reentrant");
require(msg.sender == address(token), "Only token");
require(tx.origin == user, "Only EOA");
require(userInfo[user].lpAmount > 0, "No LP");
require(userInfo[user].claimedValueInUSDT < userInfo[user].lpValueInUSDT * 5, "Already reached 5x limit");
_locked = true;
_claimToken(user);
_locked = false;
}
function _claimToken(address user) internal {
UserInfo storage info = userInfo[user];
require(block.timestamp >= info.lastClaimTime + claimInterval, "Claim too frequent");
uint256 contractBalance = token.balanceOf(address(this)); // <-- VULNERABILITY: uses full live token balance
uint256 claimable = contractBalance * info.lpAmount / totalLP; // <-- VULNERABILITY: arbitrary inbound EST becomes claimable
require(claimable > 0, "Nothing to claim");
uint256 claimValueUSDT = _getTokenValueInUSDT(claimable);
// ...
token.transfer(user, claimable);
}
Snippet 1 Explained
onTokenReceived is the externally reachable claim callback. It can only be called by the EST token contract, and it also requires tx.origin == user, so the eventual claimant must be the transaction origin and must already hold a positive lpAmount. Once those checks pass, _claimToken calculates rewards from token.balanceOf(address(this)), which is the contract’s entire live EST balance, not a separately tracked reward bucket. That means any EST routed into BNBDeposit immediately becomes part of the claim base and can be extracted pro rata by any address that first acquires temporary LP share.
// ESTToken.sol
address public burnReceiver = address(0xE71547170c5ad5120992B85Cf1288FAb23d29A61);
address public depositContract = address(0xE71547170c5ad5120992B85Cf1288FAb23d29A61);
constructor() {
_isExcludedFromFee[burnReceiver] = true; // <-- fee exemption also applies to depositContract
// ...
}
function _transfer(address from, address to, uint256 amount) private {
// ...
if (takeFee) {
uint256 feeAmount = amount.mul(totalFee).div(100);
uint256 transferAmount = amount.sub(feeAmount);
// ...
if (isSell) {
_pendingSellBurn += transferAmount;
}
}
// ...
if (to == depositContract && depositContract != address(0) && amount == 1 * 10 ** uint256(_decimals)) {
IBNBDeposit(depositContract).onTokenReceived(from); // <-- exact 1 EST transfer triggers claim path
}
}
Snippet 2 Explained
This snippet wires EST directly into the vulnerable claim path. burnReceiver and depositContract are configured to the same address, and the constructor excludes burnReceiver from fees, so transfers into BNBDeposit also become fee-exempt. In _transfer, an exact 1 EST transfer to depositContract calls onTokenReceived(from). Together, these rules give the attacker two useful properties at once: large EST inflows can be parked inside BNBDeposit without normal transfer tax, and a deterministic 1 EST transfer can trigger the claim at the exact moment the injected balance is most favorable.
// ESTToken.sol
if (_pendingSellBurn > 0 && !ammPairs[from] && uniswapV2Pair != address(0) && _tTotal > minSupply) {
uint256 burnAmount = _pendingSellBurn;
_pendingSellBurn = 0;
// ...
_tOwned[uniswapV2Pair] = _tOwned[uniswapV2Pair].sub(burnAmount);
_tTotal = _tTotal.sub(burnAmount);
emit Transfer(uniswapV2Pair, address(0), burnAmount);
IUniswapV2Pair(uniswapV2Pair).sync(); // <-- pair reserves are mutated mid-exit
}
Snippet 3 Explained
This is not the root cause of the theft, but it makes the exit materially better for the attacker. During sell-side flow, EST burns tokens from the pair balance and then immediately calls sync(), which updates the pair’s stored reserves to the reduced EST side. Combined with the attacker’s repeated skim() loop, this lets the pair sit in a distorted state before the final EST-to-WBNB swap, so the attacker unwinds the claimed EST position at a more favorable price than would be possible in a normal reserve flow.
Why It’s Vulnerable
Expected behavior: BNBDeposit should pay rewards only from an explicitly tracked reward pool or from value accrued through a controlled emission mechanism. Arbitrary EST sent into the contract should not become instantly claimable by any address that temporarily acquires LP share. The token hook should not expose a one-transfer trigger that bypasses a normal user-facing claim flow. Fee exemptions should not create a privileged sink that can receive large token inflows without the normal transfer tax.
Actual behavior:
- Live-balance accounting:
BNBDeposit._claimTokenusestoken.balanceOf(address(this))as the reward base. Any EST transferred into the contract immediately inflates the reward pool. - Hook-triggered claim: EST’s
_transfercallsBNBDeposit.onTokenReceived(from)whenever exactly1 ESTis sent todepositContract. The hook is not universally triggerable by an arbitrary helper contract becauseBNBDepositalso requirestx.origin == user; in this exploit that check passed because the transaction sender itself was the same address as the attack contract. - Fee-exempt sink:
depositContractandburnReceiverare the same address, andburnReceiveris excluded from fees in the constructor. Large EST buys can therefore be routed intoBNBDepositwithout paying the token’s 5% transfer fee. - Exit amplifier: During subsequent sells, EST’s delayed sell-burn and
sync()logic, combined with repeatedskim()calls, lets the attacker keep the pair’s EST side artificially low before the final EST-to-WBNB exit.
What this means economically:
- The attacker first buys EST into the fee-exempt deposit contract.
- The attacker then claims a proportional slice of that injected EST by sending exactly
1 EST. - After claiming EST into their own balance, the attacker uses a second large fee-exempt buy plus repeated pair perturbations to exit the claimed EST position at an inflated WBNB price.
The core flaw is therefore not a standard skim() bug in PancakeSwap itself. The true bug is that BNBDeposit exposes its entire live EST balance as claimable inventory, while EST simultaneously provides:
- a deterministic
1 ESTtrigger for claims when the LP holder is also the transaction origin, - a fee-exempt deposit sink,
- and pair-state mutation during sells.
Attack Execution
High-Level Flow
- The local transaction artifact records the top-level sender and callee as the same address,
0xcf300de6f177ec10db0d7f756ced3ae2d2203bfd, which executesstart(...). - It flash-loans
250,000 WBNBfrom0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c. - Inside
onMoolahFlashLoan, it unwraps15 WBNBto native BNB. - It sends
0.3 BNBintoBNBDeposit34times, creating a temporary internal LP share. Total BNB sent toBNBDeposit:10.2 BNB. - It calls PancakeRouter to swap
400 WBNB -> ESTwith recipientBNBDeposit. This routes822,411,955.122453151617438286 ESTinto the deposit contract. - It transfers exactly
1 ESTtoBNBDeposit, triggeringonTokenReceived(attacker). This succeeds because the top-level transaction sender is also0xcf300de6..., sotx.origin == userpasses. BNBDeposittransfers20,569,915.273855479094886078 ESTto the attacker. From the tracedEST.balanceOf(BNBDeposit)return of832,455,814.625476530077095246 ESTimmediately before the transfer, this is approximately2.4709918427454223%of the deposit contract’s EST balance at claim time.- It then performs a second fee-exempt
245,000 WBNB -> ESTbuy intoBNBDeposit, routing330,866,039.650425264248436964 ESTinto the deposit contract and heavily pumping the EST/WBNB price. - The attacker enters a
150-iteration loop:- transfer small amounts of EST into the EST/WBNB pair,
- let EST’s sell-path logic queue or execute burns and
sync(), - call
skim(BNBDeposit).
- Across those loops, approximately
797,145.066740394531257707 ESTis redirected from the pair toBNBDeposit. - After the loop, the attacker sends one additional dust-sized
100 wei ESTtransfer into the pair, triggering one more sell-pathsync()before exit. - The attacker finally swaps
19,730,833.203602432219877940 EST -> WBNB; due to EST’s 5% fee,18,744,291.543422310608884043 ESTreaches the pair and986,541.660180121610993897 ESTis sent to the fee wallet. - The attacker receives
245,560.889451187958247246 WBNB, re-wraps to WBNB, repays the250,000 WBNBflash loan, and keeps the remainder.
Detailed Call Trace
The trace below is reconstructed directly from decoded_calls.json and trace_callTracer.json. One important artifact caveat: tx.json records from == to == 0xcf300de6... for this legacy transaction. The trace and receipt are internally consistent with that, and it explains why tx.origin == user passes in BNBDeposit.onTokenReceived, but this sender model is unusual enough that it should be treated as an observed artifact rather than a fully explained environmental property.
AttackerContract (0xcf300de6...)
CALL AttackerContract.start(...) [0x707a4e96]
CALL FlashLoanProvider (0x8f73b65b...).flashLoan(WBNB, 250000 WBNB, "") [0xe0232b42]
CALL WBNB.transfer(attacker, 250000 WBNB) [0xa9059cbb]
CALL AttackerContract.onMoolahFlashLoan(250000 WBNB, "") [0x13a1a562]
CALL WBNB.approve(router, max) [0x095ea7b3]
CALL EST.approve(router, max) [0x095ea7b3]
CALL WBNB.approve(flashLoanProvider, max) [0x095ea7b3]
CALL WBNB.withdraw(15 WBNB) [0x2e1a7d4d]
[34x] CALL BNBDeposit.receive() with 0.3 BNB
-> distribute fee splits
-> swap 0.093 BNB -> EST
-> add liquidity
-> credit internal LP share
CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
400 WBNB, [WBNB, EST], BNBDeposit, ...
) [0x5c11d795]
-> Pair.swap(...)
-> EST transferred from pair to BNBDeposit: 822,411,955.122453... EST
CALL EST.transfer(BNBDeposit, 1 EST) [0xa9059cbb]
CALL BNBDeposit.onTokenReceived(attacker) [0x6c069868]
CALL EST.balanceOf(BNBDeposit) [0x70a08231]
CALL router.getAmountsOut(...) [0xd06ca61f]
CALL EST.transfer(attacker, 20,569,915.273855... EST) [0xa9059cbb]
CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
245000 WBNB, [WBNB, EST], BNBDeposit, ...
) [0x5c11d795]
-> Pair.swap(...)
-> EST transferred from pair to BNBDeposit: 330,866,039.650425... EST
[150-cycle loop]
CALL EST.transfer(Pair, small amount) [0xa9059cbb]
-> EST _transfer()
-> delayed sell burn / sync path may execute
CALL Pair.skim(BNBDeposit) [0xbc25cf77]
CALL EST.transfer(Pair, 100 wei) [0xa9059cbb]
-> final dust sell / sync trigger before exit
CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
19,730,833.203602432219877940 EST, [EST, WBNB], attacker, ...
) [0x5c11d795]
-> EST.transferFrom(attacker, pair, 19,730,833.203602432219877940 EST)
-> final post-fee EST delivered to pair: 18,744,291.543422... EST
-> Pair.swap(...)
-> WBNB.transfer(attacker, 245,560.889451... WBNB)
CALL WBNB.deposit() with 4.8 BNB [0xd0e30db0]
CALL WBNB.transferFrom(attacker, flashLoanProvider, 250000 WBNB) [0x23b872dd]
Financial Impact
Direct on-chain loss: the EST/WBNB PancakeSwap V2 pair (0x74986cd86caf54961dd70eedcaf7cb3fe813c0b9) lost a net 154.571495162546593567 WBNB over the full transaction.
This can be verified directly from the pair’s WBNB transfers:
- WBNB sent into the pair during the
34setup deposits:6.317956025411653679 WBNB - WBNB sent into the pair by the two large attacker buys:
400 + 245000 = 245,400 WBNB - Total WBNB sent into the pair during the transaction:
245,406.317956025411653679 WBNB - WBNB sent out from the pair to the attacker on the final exit:
245,560.889451187958247246 WBNB - Net pair WBNB loss:
154.571495162546593567 WBNB
Gas cost: 0.0017449786 BNB
Realized attacker profit before gas:
245,560.889451187958247246 - 245,400 - 10.2
= 150.689451187958247246 WBNB
This realized-profit figure excludes any residual value of the temporary internal LP share created by the 34 deposits.
Attacker profit after gas:
150.689451187958247246 - 0.0017449786
= 150.687706209358247246 WBNB
Who lost funds:
- Primary direct victims: liquidity providers in the EST/WBNB pair
Important nuance:
- The attacker also extracted
20,569,915.273855479094886078 ESTfromBNBDeposit. - However, that EST was not a clean protocol loss in the same sense as the WBNB drained from the pair, because the attacker had just routed
400 WBNBworth of EST into the deposit contract immediately before claiming it. - The economically meaningful profit was realized when the attacker later exited to WBNB at a manipulated price, but the realized liquid profit is lower than the pair’s net WBNB loss because the attacker also spent
10.2 BNBto create the temporary LP share used for claiming.
Evidence
- Transaction hash:
0x2f1c33eaaaace728f6101ff527793387341021ef465a4a33f53a0037f5bd1626 - Block:
89060337 - Timestamp:
2026-03-27T15:40:34Z - Status: success
- Gas used:
17,449,786 - Local artifact caveat:
tx.jsonrecords the top-level sender as0xcf300de6..., the same address as the attack contract. This is self-consistent with the trace, but unusual enough that it remains a residual confidence caveat unless independently explained from a live explorer or chain-specific execution model.
Validated call counts and amounts:
34calls intoBNBDeposit.receive()with0.3 BNBeach150calls toskim(address)151direct attackerEST.transfer(...)calls into the pair, plus the final router-drivenEST.transferFrom(...)on exit822,411,955.122453151617438286 ESTbought intoBNBDepositbefore the claim trigger20,569,915.273855479094886078 ESTtransferred fromBNBDepositto the attacker after the1 ESTtrigger330,866,039.650425264248436964 ESTbought intoBNBDepositafter the claim797,145.066740394531257707 ESTredirected from the pair toBNBDepositacross the skim loop- Final exit input:
19,730,833.203602432219877940 EST - Final exit output:
245,560.889451187958247246 WBNB
Selector verification:
0x707a4e96=start(uint256,uint256,uint256,uint256)0xe0232b42=flashLoan(address,uint256,bytes)0x13a1a562=onMoolahFlashLoan(uint256,bytes)0x5c11d795=swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)0x6c069868=onTokenReceived(address)0xbc25cf77=skim(address)0x022c0d9f=swap(uint256,uint256,address,bytes)
Code-path evidence:
BNBDeposit.receive()auto-deposits on qualifying BNB sends.BNBDeposit._claimToken()calculates rewards fromtoken.balanceOf(address(this)).ESTToken._transfer()triggersonTokenReceived(from)whenamount == 1e18andto == depositContract.BNBDeposit.onTokenReceived()additionally requirestx.origin == user; this exploit satisfies that because the transaction sender intx.jsonis the same0xcf300de6...address that holds the LP share and sends the1 EST.depositContractis equal toburnReceiver.burnReceiveris explicitly excluded from transfer fees in the EST constructor.
These points together match the executed trace exactly:
- the attacker routes EST into the fee-exempt deposit contract,
- triggers the hook with
1 EST, - receives a pro-rata claim payout,
- then uses a second fee-exempt buy and repeated
skim()calls to keep the pair favorable for the final exit.
Related URLs
- Transaction: https://bscscan.com/tx/0x2f1c33eaaaace728f6101ff527793387341021ef465a4a33f53a0037f5bd1626
- BNBDeposit: https://bscscan.com/address/0xe71547170c5ad5120992b85cf1288fab23d29a61
- ESTToken: https://bscscan.com/address/0xd4524be41cd452576ab9ff7b68a0b89af8498a91
- EST/WBNB Pair: https://bscscan.com/address/0x74986cd86caf54961dd70eedcaf7cb3fe813c0b9
- Flash-loan provider: https://bscscan.com/address/0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c
- Attacker contract: https://bscscan.com/address/0xcf300de6f177ec10db0d7f756ced3ae2d2203bfd