DBXen ERC2771 Meta-Transaction Context Confusion
The DBXen protocol on BNB Chain was exploited at block 86,063,902 through an ERC2771 meta-transaction context confusion vulnerability in the burnBatch() function. The attacker abused the inconsistency between _msgSender() (used in the gasWrapper modifier) and msg.sender (passed as the user argument to xen.burn()) to split the state updates for burned-batch accounting across two different addresses. This desynchronized accCycleBatchesBurned and lastActiveCycle for the actual user (the attacker’s EOA), causing the reward and fee calculation in updateStats() to believe the EOA had unprocessed burns from a previous cycle. The attacker then called claimFees() to drain approximately 23.12 BNB (~$13,900 at the time) in accumulated protocol fees from the contract, plus received 9,676.9 DXN tokens via claimRewards(), for a net profit of ~22.53 BNB and ~9,677 DXN after accounting for costs.
Root Cause
Vulnerable Contract
- Name: DBXen
- Address:
0x9caf6c4e5b9e3a6f83182befd782304c7a8ee6de - Proxy: No (direct implementation)
- Source type: Verified (
contracts/DBXen.sol)
Vulnerable Function
- Function:
burnBatch(uint256 batchNumber)withgasWrapper(batchNumber)modifier - Selector:
0x937c5414(verified:cast sig "burnBatch(uint256)"=0x937c5414) - File:
contracts/DBXen.sol
The vulnerability also involves onTokenBurned(address user, uint256 amount) (selector 0x543746b1) which is the callback that creates the inconsistent state.
Vulnerable Code
// contracts/DBXen.sol
modifier gasWrapper(uint256 batchNumber) {
uint256 startGas = gasleft();
_;
uint256 discount = (batchNumber * (MAX_BPS - 5 * batchNumber));
uint256 protocolFee = ((startGas - gasleft() + 39400) * tx.gasprice * discount) / MAX_BPS;
require(msg.value >= protocolFee , "DBXen: value less than protocol fee");
totalNumberOfBatchesBurned += batchNumber;
cycleTotalBatchesBurned[currentCycle] += batchNumber;
accCycleBatchesBurned[_msgSender()] += batchNumber; // <-- VULNERABILITY: uses _msgSender() (EOA)
cycleAccruedFees[currentCycle] += protocolFee;
sendViaCall(payable(msg.sender), msg.value - protocolFee);
}
function burnBatch(
uint256 batchNumber
)
external
payable
nonReentrant()
gasWrapper(batchNumber)
{
require(batchNumber <= 10000, "DBXen: maxim batch number is 10000");
require(batchNumber > 0, "DBXen: min batch number is 1");
require(xen.balanceOf(msg.sender) >= batchNumber * XEN_BATCH_AMOUNT, "DBXen: not enough tokens for burn");
IBurnableToken(xen).burn(msg.sender, batchNumber * XEN_BATCH_AMOUNT); // <-- VULNERABILITY: uses msg.sender (Forwarder)
}
function onTokenBurned(address user, uint256 amount) external {
require(msg.sender == address(xen), "DBXen: illegal callback caller");
calculateCycle();
updateCycleFeesPerStakeSummed();
setUpNewCycle();
updateStats(user);
lastActiveCycle[user] = currentCycle; // <-- VULNERABILITY: user = Forwarder, not EOA
emit Burn(user, amount);
}
The XENCrypto.burn() function propagates the caller identity mismatch:
// contracts/XENCrypto.sol (DXN token, 0x2ab0e9e4ee70fff1fb9d67031e44f6410170d00e)
function burn(address user, uint256 amount) public {
// ...
_spendAllowance(user, _msgSender(), amount); // _msgSender() = DBXen contract
_burn(user, amount);
userBurns[user] += amount;
IBurnRedeemable(_msgSender()).onTokenBurned(user, amount); // calls DBXen.onTokenBurned(user, amount)
// user = whoever burnBatch passed as first arg = msg.sender of burnBatch = Forwarder
}
Why It’s Vulnerable
Expected behavior: When a user calls burnBatch() through the ERC2771 forwarder, both accCycleBatchesBurned and lastActiveCycle should be updated for the same address — the actual user (as extracted by _msgSender()). At the end of the cycle, updateStats() can then correctly compute the user’s reward share.
Actual behavior: When burnBatch() is called via the trusted forwarder (0x8c229...):
msg.sender= Forwarder (0x8c229...)_msgSender()= EOA (0xe92fa...) — extracted from the trailing 20 bytes appended by the forwarder per ERC2771
The gasWrapper modifier updates accCycleBatchesBurned[_msgSender()] — that is, for the EOA. But burnBatch() calls xen.burn(msg.sender, ...) — that is, burns tokens from the Forwarder’s balance. The XENCrypto burn() function then calls DBXen.onTokenBurned(user=Forwarder, amount). Inside onTokenBurned, lastActiveCycle[user] is set for the Forwarder, not the EOA.
This creates a split state:
accCycleBatchesBurned[EOA]= 18,000 (updated bygasWrapper)lastActiveCycle[EOA]= 0 (never updated —onTokenBurnedset it for the Forwarder instead)
When claimFees() subsequently calls updateStats(EOA):
function updateStats(address account) internal {
if (
currentCycle > lastActiveCycle[account] && // currentCycle > 0 => TRUE
accCycleBatchesBurned[account] != 0 // 18000 != 0 => TRUE
) {
uint256 lastCycleAccReward = (accCycleBatchesBurned[account] * rewardPerCycle[lastActiveCycle[account]]) /
cycleTotalBatchesBurned[lastActiveCycle[account]];
accRewards[account] += lastCycleAccReward;
accCycleBatchesBurned[account] = 0;
}
// fee accrual: accAccruedFees[account] += accRewards[account] * feeRateDelta / SCALING_FACTOR
// ...
}
The EOA’s 18,000 batches are attributed to rewardPerCycle[lastActiveCycle[EOA]] (cycle 0, with initial reward of 10,000 DXN) divided by cycleTotalBatchesBurned[0]. Since cycle 0 had very few or no prior burns, the denominator is small or the ratio is computed against the wrong cycle’s fee accumulation, making the EOA’s computed accRewards disproportionately large. This inflated accRewards then entitles the EOA to a large share of cycleAccruedFees — the protocol fees accumulated from all legitimate users — which the attacker drains via claimFees().
Normal flow vs Attack flow:
| Step | Normal user | Attacker via Forwarder |
|---|---|---|
gasWrapper modifier | accCycleBatchesBurned[user] += N | accCycleBatchesBurned[EOA] += N |
xen.burn(who, ...) | xen.burn(user, ...) → onTokenBurned(user, ...) | xen.burn(Forwarder, ...) → onTokenBurned(Forwarder, ...) |
onTokenBurned | lastActiveCycle[user] = currentCycle | lastActiveCycle[Forwarder] = currentCycle |
| State after burn | accCycleBatchesBurned[user] and lastActiveCycle[user] both updated for same address | accCycleBatchesBurned[EOA] updated, lastActiveCycle[EOA] NOT updated |
updateStats in claimFees | Correctly computes reward for user | Incorrectly computes inflated reward for EOA (wrong cycle denominator) |
Attack Execution
High-Level Flow
- Attacker EOA deploys helper contract (
0x53ce...) with 2.2 BNB budget. - Helper wraps 0.1 BNB into WBNB and swaps for ~319 billion bXEN via PancakeSwap V3.
- Helper transfers all bXEN to the DBXenForwarder (
0x8c229...) to fund the meta-transaction burns. - Helper calls
Forwarder.registerDomainSeparator()to register the EIP-712 domain for subsequent signed meta-transactions. - Helper calls
Forwarder.execute()with a signedburnBatch(10000)request (1 BNB sent), forwarded to DBXen. The Forwarder appends the EOA’s address to calldata, butxen.burn(msg.sender=Forwarder, ...)is called, soonTokenBurnedsetslastActiveCycle[Forwarder]rather thanlastActiveCycle[EOA]. - Helper repeats
Forwarder.execute()with a signedburnBatch(8000)request (1 BNB sent), accumulatingaccCycleBatchesBurned[EOA] = 18,000total whilelastActiveCycle[EOA]remains unset. - Helper calls
Forwarder.execute()with a signedclaimFees()request (no BNB). DBXen callsupdateStats(EOA), computes an inflatedaccRewardsusing the stalelastActiveCycle[EOA], then transfers 23.12 BNB of accumulated protocol fees directly to the EOA. - Helper calls
Forwarder.execute()with a signedclaimRewards()request. DBXen mints 9,676.9 DXN tokens to the EOA viaDBXenRewardMinter.mintReward(). - Helper forwards remaining ETH balance (1.607 BNB) back to the EOA.
Detailed Call Trace
[depth 0] EOA (0xe92fa...) → AttackerContract (0x53ce...) CALL 0x49fd379e, value=2.2 BNB
[depth 1] AttackerContract → Forwarder (0x8c229...) CALL registerDomainSeparator(string,string) [0x9c7b4592], value=0
[depth 1] AttackerContract → WBNB (0xbb4cdb...) CALL deposit() [0xd0e30db0], value=0.1 BNB
[depth 1] AttackerContract → WBNB CALL approve(PancakeV3Router, MAX) [0x095ea7b3], value=0
[depth 1] AttackerContract → PancakeV3Router (0xb971ef...) CALL exactInputSingle(WBNB→DXN) [0x04e45aaf], value=0
[depth 2] PancakeV3Router → PancakeV3Pool_DXN_WBNB (0x85d3f8...) CALL swap(...) [0x128acb08]
[depth 3] Pool → DXNToken (0x2ab0e9...) CALL transfer(AttackerContract, 319,304B bXEN) [0xa9059cbb]
[depth 3] Pool → PancakeV3Router CALL uniswapV3SwapCallback(...) [0xfa461e33]
[depth 4] Router → WBNB CALL transferFrom(AttackerContract→Pool, 0.1 WBNB) [0x23b872dd]
[depth 1] AttackerContract → DXNToken CALL transfer(Forwarder, 319,304B bXEN) [0xa9059cbb]
(Forwarder now holds bXEN to fund the burns)
-- First burnBatch via Forwarder (10,000 batches, 1 BNB) --
[depth 1] AttackerContract → Forwarder CALL execute(...) [0xe024dc7f], value=1 BNB
[depth 2] Forwarder → ecrecover (0x01) STATICCALL (signature verification)
[depth 2] Forwarder → DXNToken CALL approve(DBXen, MAX) [0x095ea7b3]
NOTE: calldata has EOA appended: Forwarder's _msgSender() = EOA for this approve
[depth 2] Forwarder → DBXen (0x9caf6c...) CALL burnBatch(10000) [0x937c5414], value=1 BNB
NOTE: calldata = abi.encodePacked(selector, batchNumber, req.from=EOA)
- msg.sender = Forwarder; _msgSender() extracts EOA from trailing 20 bytes
gasWrapper modifier executes AFTER burnBatch body:
accCycleBatchesBurned[EOA] += 10000
burnBatch body:
xen.balanceOf(msg.sender=Forwarder) checked
[depth 3] DBXen → DXNToken STATICCALL balanceOf(Forwarder) [0x70a08231]
[depth 3] DBXen → DXNToken CALL burn(Forwarder, 25,000,000,000 bXEN) [0x9dc29fac]
[depth 4] DXNToken → DBXen STATICCALL supportsInterface(IBurnRedeemable) [0x01ffc9a7]
[depth 4] DXNToken → DBXen CALL onTokenBurned(user=Forwarder, amount=25B bXEN) [0x543746b1]
NOTE: msg.sender here = DXNToken (XENCrypto) — passes require check
updateStats(Forwarder) — Forwarder has no prior state, no-op
lastActiveCycle[Forwarder] = currentCycle ← WRONG ADDRESS updated
[depth 3] DBXen → Forwarder CALL (ETH, 0.6329 BNB change returned) [native transfer]
[depth 2] Forwarder → AttackerContract CALL (ETH, 0.6329 BNB forwarded) [native transfer]
-- Second burnBatch via Forwarder (8,000 batches, 1 BNB) --
[depth 1] AttackerContract → Forwarder CALL execute(...) [0xe024dc7f], value=1 BNB
[depth 2] Forwarder → ecrecover (0x01) STATICCALL (signature verification)
[depth 2] Forwarder → DBXen CALL burnBatch(8000) [0x937c5414], value=1 BNB
gasWrapper: accCycleBatchesBurned[EOA] += 8000 → total = 18,000
burnBatch body:
[depth 3] DBXen → DXNToken STATICCALL balanceOf(Forwarder)
[depth 3] DBXen → DXNToken CALL burn(Forwarder, 20,000,000,000 bXEN) [0x9dc29fac]
[depth 4] DXNToken → DBXen STATICCALL supportsInterface(IBurnRedeemable)
[depth 4] DXNToken → DBXen CALL onTokenBurned(user=Forwarder, 20B bXEN) [0x543746b1]
lastActiveCycle[Forwarder] = currentCycle (again, wrong address)
[depth 3] DBXen → Forwarder CALL (ETH, 0.8748 BNB change returned)
[depth 2] Forwarder → AttackerContract CALL (ETH, 0.8748 BNB forwarded)
-- claimFees via Forwarder --
[depth 1] AttackerContract → Forwarder CALL execute(...) [0xe024dc7f], value=0
[depth 2] Forwarder → ecrecover (0x01) STATICCALL (signature verification)
[depth 2] Forwarder → DBXen CALL claimFees() [0xd294f093]
NOTE: calldata = abi.encodePacked(0xd294f093, EOA_address)
_msgSender() = EOA; updateStats(EOA) triggered
updateStats: currentCycle > lastActiveCycle[EOA] (0) AND accCycleBatchesBurned[EOA]=18000 != 0
→ inflated accRewards[EOA] computed; fee accrual runs
[depth 3] DBXen → EOA (0xe92fa...) CALL (ETH, 23.1201 BNB) ← DRAIN
returns success
-- claimRewards via Forwarder --
[depth 1] AttackerContract → Forwarder CALL execute(...) [0xe024dc7f], value=0
[depth 2] Forwarder → ecrecover (0x01) STATICCALL
[depth 2] Forwarder → DBXen CALL claimRewards() [0x372500ab]
_msgSender() = EOA; updateStats(EOA) runs again
[depth 3] DBXen → DBXenRewardMinter (0xccd09b...) CALL mintReward(EOA, 9676.9 DXN) [0x9a49090e]
DXN minted to EOA (Transfer from 0x0 to EOA)
[depth 1] AttackerContract → EOA CALL (ETH, 1.6077 BNB remaining balance)
Financial Impact
- Protocol fees drained: 23.12 BNB sent directly to the attacker EOA via
claimFees() - DXN reward tokens minted: 9,676.9 DXN (bDXN) minted to the attacker EOA via
claimRewards() - Attacker net ETH profit: 22.53 BNB (23.12 BNB received from
claimFees()+ 1.61 BNB residual, minus 2.2 BNB initial investment) - Attack cost: 2.2 BNB initial + 0.1 BNB for bXEN swap = effectively funded by the protocol fee refunds from
burnBatch
From funds_flow.json (attacker_gains):
22.527867313166268137BNB (ETH field — native BNB on BSC)9676.899091446696414171bDXN tokens
At approximate market prices at the time of the attack (~$600/BNB, ~$13–15/DXN):
- BNB profit: ~$13,500
- DXN profit: ~$125,000–$145,000 (DXN was valued significantly higher than $0.5)
- Total estimated loss: consistent with the ~$150K figure reported in the alert
The funds were drained from the DBXen protocol’s accumulated cycleAccruedFees mapping — fees that had been contributed by all legitimate users of the protocol. The contract’s fee pool was materially depleted, impacting legitimate stakers who cannot claim their rightful share of those fees.
Evidence
Selector Verification
| Selector | Function | Verified via |
|---|---|---|
0x937c5414 | burnBatch(uint256) | cast sig "burnBatch(uint256)" = 0x937c5414 ✓ |
0x543746b1 | onTokenBurned(address,uint256) | cast sig "onTokenBurned(address,uint256)" = 0x543746b1 ✓ |
0xd294f093 | claimFees() | cast sig "claimFees()" = 0xd294f093 ✓ |
0x372500ab | claimRewards() | cast sig "claimRewards()" = 0x372500ab ✓ |
0xe024dc7f | execute(...) (Forwarder) | ABI-resolved ✓ |
0x9a49090e | mintReward(address,uint256) | ABI-resolved ✓ |
Key On-Chain Evidence
ERC2771 sender extraction confirmed: In
decoded_calls.jsonindex 19 (firstburnBatchcall to DBXen), the raw calldata is0x937c5414...00002710...e92fa2a5fef535479a91ab9ed90b26256ff276f1. The trailing 20 bytes (e92fa...) are thereq.from(EOA address) appended by the Forwarder’sexecute(). This is what_msgSender()extracts in DBXen.onTokenBurnedpasses Forwarder as user: Indecoded_calls.jsonindex 23 and 32, theonTokenBurnedcalls passuser = 0x8c229a2e3178f1be5f5f4fcdc2d5833c8a60e831(the Forwarder address) — confirmed by the ABI-decoded argument in the first 32 bytes after the selector.claimFees sends 23.12 BNB to EOA:
decoded_calls.jsonindex 38 shows a native ETH transfer of0x140db399271596ee9= 23,120,136,413,166,268,137 wei = 23.12 BNB directly from DBXen to EOA (0xe92fa...).DXN minted to EOA: Log index 439 in
funds_flow.jsonconfirms a Transfer event from the zero address to EOA for 9,676.899 DXN (DBXenRewardMinter contract).Transaction receipt status: Block 86,063,902, tx index 0x34. Receipt status = success.
Burn amounts: 45,000,000,000 bXEN total burned (25 billion in first
burn()call + 20 billion in second), corresponding to 10,000 + 8,000 = 18,000 batches ofXEN_BATCH_AMOUNT = 2,500,000 ether.
Related URLs
- Transaction: https://bscscan.com/tx/0xe66e54586827d6a9e1c75bd1ea42fa60891ad341909d29ec896253ee2365d366
- DBXen Contract: https://bscscan.com/address/0x9caf6c4e5b9e3a6f83182befd782304c7a8ee6de
- DBXenForwarder: https://bscscan.com/address/0x8c229a2e3178f1be5f5f4fcdc2d5833c8a60e831
- Attacker Contract: https://bscscan.com/address/0x53ce337ebae95cee44365d436ba0a9bf87c8b498
- Attacker EOA: https://bscscan.com/address/0xe92fa2a5fef535479a91ab9ed90b26256ff276f1