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: BNBDeposit

  • Address: 0xe71547170c5ad5120992b85cf1288fab23d29a61

  • Proxy: No

  • Source type: Verified

  • Contract: ESTToken

  • Address: 0xd4524be41cd452576ab9ff7b68a0b89af8498a91

  • Proxy: No

  • Source type: Verified

The observed profit path in this transaction relied on both contracts:

  1. BNBDeposit incorrectly treats its entire live EST balance as immediately claimable reward inventory.
  2. ESTToken provides the deterministic callback path used in this transaction: an exact 1 EST transfer to depositContract triggers BNBDeposit.onTokenReceived(from) when the sender also satisfies tx.origin == user.
  3. ESTToken also exempts the deposit contract from transfer fees and mutates pair reserves with sync() 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:

  1. Live-balance accounting: BNBDeposit._claimToken uses token.balanceOf(address(this)) as the reward base. Any EST transferred into the contract immediately inflates the reward pool.
  2. Hook-triggered claim: EST’s _transfer calls BNBDeposit.onTokenReceived(from) whenever exactly 1 EST is sent to depositContract. The hook is not universally triggerable by an arbitrary helper contract because BNBDeposit also requires tx.origin == user; in this exploit that check passed because the transaction sender itself was the same address as the attack contract.
  3. Fee-exempt sink: depositContract and burnReceiver are the same address, and burnReceiver is excluded from fees in the constructor. Large EST buys can therefore be routed into BNBDeposit without paying the token’s 5% transfer fee.
  4. Exit amplifier: During subsequent sells, EST’s delayed sell-burn and sync() logic, combined with repeated skim() 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 EST trigger 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

  1. The local transaction artifact records the top-level sender and callee as the same address, 0xcf300de6f177ec10db0d7f756ced3ae2d2203bfd, which executes start(...).
  2. It flash-loans 250,000 WBNB from 0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c.
  3. Inside onMoolahFlashLoan, it unwraps 15 WBNB to native BNB.
  4. It sends 0.3 BNB into BNBDeposit 34 times, creating a temporary internal LP share. Total BNB sent to BNBDeposit: 10.2 BNB.
  5. It calls PancakeRouter to swap 400 WBNB -> EST with recipient BNBDeposit. This routes 822,411,955.122453151617438286 EST into the deposit contract.
  6. It transfers exactly 1 EST to BNBDeposit, triggering onTokenReceived(attacker). This succeeds because the top-level transaction sender is also 0xcf300de6..., so tx.origin == user passes.
  7. BNBDeposit transfers 20,569,915.273855479094886078 EST to the attacker. From the traced EST.balanceOf(BNBDeposit) return of 832,455,814.625476530077095246 EST immediately before the transfer, this is approximately 2.4709918427454223% of the deposit contract’s EST balance at claim time.
  8. It then performs a second fee-exempt 245,000 WBNB -> EST buy into BNBDeposit, routing 330,866,039.650425264248436964 EST into the deposit contract and heavily pumping the EST/WBNB price.
  9. 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).
  10. Across those loops, approximately 797,145.066740394531257707 EST is redirected from the pair to BNBDeposit.
  11. After the loop, the attacker sends one additional dust-sized 100 wei EST transfer into the pair, triggering one more sell-path sync() before exit.
  12. The attacker finally swaps 19,730,833.203602432219877940 EST -> WBNB; due to EST’s 5% fee, 18,744,291.543422310608884043 EST reaches the pair and 986,541.660180121610993897 EST is sent to the fee wallet.
  13. The attacker receives 245,560.889451187958247246 WBNB, re-wraps to WBNB, repays the 250,000 WBNB flash 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 34 setup 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 EST from BNBDeposit.
  • 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 WBNB worth 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 BNB to 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.json records the top-level sender as 0xcf300de6..., 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:

  • 34 calls into BNBDeposit.receive() with 0.3 BNB each
  • 150 calls to skim(address)
  • 151 direct attacker EST.transfer(...) calls into the pair, plus the final router-driven EST.transferFrom(...) on exit
  • 822,411,955.122453151617438286 EST bought into BNBDeposit before the claim trigger
  • 20,569,915.273855479094886078 EST transferred from BNBDeposit to the attacker after the 1 EST trigger
  • 330,866,039.650425264248436964 EST bought into BNBDeposit after the claim
  • 797,145.066740394531257707 EST redirected from the pair to BNBDeposit across 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 from token.balanceOf(address(this)).
  • ESTToken._transfer() triggers onTokenReceived(from) when amount == 1e18 and to == depositContract.
  • BNBDeposit.onTokenReceived() additionally requires tx.origin == user; this exploit satisfies that because the transaction sender in tx.json is the same 0xcf300de6... address that holds the LP share and sends the 1 EST.
  • depositContract is equal to burnReceiver.
  • burnReceiver is 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.