DBXen did not lose roughly $149,000 to a flash loan, a compromised key, or a reentrancy loop. It lost the money because one execution path answered the question “who is the user?” in two different ways.
That sounds like a two-character typo: msg.sender versus _msgSender(). In an ERC-2771 meta-transaction, however, those names can identify different accounts. DBXen credited burn activity to the signer while updating the signer’s cycle history under the forwarder. The resulting fresh account appeared to have contributed batches without ever having its settlement clock advanced, so claimFees() and claimRewards() treated old value as newly claimable.
The $149K answer in one ledger
The headline is an incident-time estimate, not an exact dollar-denominated transfer. The exploit paid native assets and DXN on two chains, and their USD values moved while researchers were reporting the incident.
| Chain | Attack transaction | Observed extraction | Contemporary estimate |
|---|---|---|---|
| Ethereum | 0x914a…08bc37 | 65.36 ETH in fees and 2,305.4 DXN rewards | About $133K for the ETH leg |
| BNB Chain | 0xe66e…65d366 | 23.12 BNB gross, 22.53 BNB net after costs, and 9,676.9 DXN | About $13.9K for the gross BNB leg |
| Combined | Both transactions | 65.36 ETH, 23.12 BNB gross, and 11,982.3 DXN | Roughly $147K before or around DXN valuation; about $149K in BlockSec’s incident estimate |
The Ethereum amount and transaction are documented in NomosLabs’ reconstruction. The BNB Chain trace separates the 23.12 BNB contract payout from the attacker’s 22.53 BNB net result in DARKNAVY’s call-level analysis. BlockSec’s weekly incident report gives the widely repeated aggregate of approximately $149K across both deployments and records the public incident date as March 12. Later reconstructions place the Ethereum execution on March 11, so “March 11 execution, March 12 disclosure” is the least ambiguous timeline.
Adding the published native-asset estimates gives about $146.9K. The 11,982.3 DXN rewards and price movement account for why reasonable reports cluster around $147K to $149K rather than one audit-grade USD figure. The important number for engineering is not whether the final mark was $147,000 or $149,000. It is that the same identity error reached two deployments and emptied accumulated fee value on both.
The call path split one user into two identities
ERC-2771 exists so a relayer can pay gas for a signer. A user signs a request off-chain; a forwarder verifies the signature and nonce, submits the call, and appends the signer’s 20-byte address to calldata. In the recipient contract, raw msg.sender is the forwarder, while _msgSender() recovers the signer. The ERC-2771 specification requires the recipient to trust the forwarder precisely because that appended identity becomes security-sensitive.
Both identities are legitimate. The bug appears when one logical operation switches between them:
| Stage | msg.sender as seen by DBXen | _msgSender() | State or value attributed to |
|---|---|---|---|
| Signer prepares request | Not yet in DBXen | Signer EOA | The account expected to receive credit |
Forwarder calls burnBatch() | Trusted forwarder | Signer EOA from calldata suffix | Two identities now coexist |
gasWrapper records batches | Trusted forwarder | Signer EOA | accCycleBatchesBurned[signer] increases |
burnBatch() calls xen.burn(msg.sender, …) | Trusted forwarder | Signer EOA | XEN is burned from the forwarder |
XEN calls onTokenBurned(user, …) | XEN token contract | Not the relevant actor | lastActiveCycle[forwarder] advances |
| Signer claims | Trusted forwarder | Signer EOA | Settlement reads the signer’s stale cycle fields |
The vulnerable flow crossed a modifier, the burnBatch() body, an external token contract, and a callback. Simplified to its two decisive writes, it looked like this:
// gasWrapper: credit the meta-transaction signer
accCycleBatchesBurned[_msgSender()] += batchNumber;
// burnBatch: debit the EVM caller, which is the forwarder
IBurnableToken(xen).burn(msg.sender, batchNumber * XEN_BATCH_AMOUNT);XEN then passed that second address back to onTokenBurned(), which updated lastActiveCycle[user]. The credit belonged to the signer; the activity timestamp belonged to the forwarder. Each line can be locally valid. Together they violate the accounting model.
The same defect was present in the Ethereum DBXen contract at 0xf5c8…2abd and the BNB Chain deployment at 0x9caf…e6de. BlockSec’s technical incident analysis traced the Ethereum attacker’s forwarded burn of 13.9 billion XEN before the 65.36 ETH fee claim. On BNB Chain, the attacker sent two forwarded burns totaling 18,000 batches. The signer’s accCycleBatchesBurned became 18,000 while lastActiveCycle[signer] remained zero.
Stale cycle zero became a payout on both chains
DBXen distributed DXN and accumulated protocol fees by cycle. Its settlement code depended on several address-keyed fields moving together: the number of batches an account burned, the account’s last active cycle, and the last cycle for which fees had been credited.
A fresh signer made the mismatch especially valuable. Its cycle fields began at zero. After a forwarded burn, its credited batch count was nonzero, but the callback had advanced the forwarder’s cycle fields instead. When settlement inspected the signer, the state resembled an old, unprocessed contribution rather than a new contribution whose metadata had been recorded elsewhere.
| Deployment | Credited account | Field left stale | claimFees() result | claimRewards() result |
|---|---|---|---|---|
| Ethereum | Forwarded signer/chosen account | Burn and fee-update cycle remained at zero | 65.36 ETH | 2,305.4 DXN |
| BNB Chain | Attacker signer EOA | lastActiveCycle remained zero after 18,000 batches | 23.12 BNB gross | 9,676.9 DXN |
Inside updateStats(), a check equivalent to “the current cycle is later than this account’s last cycle, and this account has batches” now passed for the wrong reason. The fee calculation could span history from cycle zero. The reward calculation saw batches on an account whose lifecycle marker had never caught up. Calling the two public claim paths converted that inconsistent state into native-asset fees and newly minted DXN.
This distinction matters: the forwarder did not forge the attacker’s signature, and ERC-2771 did not fail to recover the signer. The forwarder did its job. DBXen failed to keep the recovered signer consistent across debit, credit, callback, and settlement. Gas sponsorship expanded the set of identities visible during execution; protocol accounting treated those identities as interchangeable only where convenient.
Why an ordinary scan can miss two legal lines
Pattern-based analyzers are strong at recognizable local hazards such as unchecked transfers, controlled delegate calls, dangerous tx.origin, and common reentrancy shapes. Slither’s published detector catalog, for example, covers a broad set of Solidity defects. That catalog does not by itself prove that every address-keyed state update across a modifier, an external call, and a callback uses one canonical actor.
The DBXen invariant was semantic and relational:
For one burn, the account debited, the account credited, and the account whose cycle advances must represent the same logical user.
A scanner that sees _msgSender() in a meta-transaction-aware modifier and msg.sender in a token call may see two intentional choices. It needs call-graph context, the callback’s propagated user argument, and knowledge of which mappings must stay paired to prove they are inconsistent. Direct-call unit tests hide the bug too, because without a forwarder msg.sender == _msgSender().
This was not the first warning that contextual identity can compose badly. OpenZeppelin’s 2023 ERC2771Context/Multicall disclosure showed that forwarded calldata combined with self-delegatecall could corrupt sender recovery. DBXen was a different exploit: no Multicall spoofing was required. The common lesson is that _msgSender() is not a cosmetic replacement for msg.sender; it defines a second execution context whose safety depends on every surrounding component.
Post-incident tooling can find this class. Olympix reports that its system generated failing Foundry proofs against the exploited contracts after the fact, including a test where accCycleBatchesBurned[realUser] changed but lastActiveCycle[realUser] stayed zero. That is useful evidence of detectability, but it is also a vendor-authored retrospective, not proof that all automated scanners would have caught the deployment beforehand.
Five checks that turn identity into an invariant
The immediate patch is simple: resolve the logical actor once and use it for every user-dependent action.
function burnBatch(uint256 batchNumber) external payable {
address actor = _msgSender();
xen.burn(actor, batchNumber * XEN_BATCH_AMOUNT);
// All accounting and callback validation must refer to actor.
}The durable fix is a testable contract rule, not a search-and-replace operation:
- Resolve once. Compute
actor = _msgSender()at the external boundary. Passactorexplicitly rather than resolving identity again in nested paths. - Pair every debit and credit. For a burn, assert that the token owner, credited batch owner, emitted event account, and cycle owner are the same logical actor.
- Treat callbacks as hostile integration boundaries. Authenticate the token contract as caller, then validate that the callback’s
usermatches the actor stored for the pending operation. Caller authentication alone does not validate the payload’s identity. - Make settlement idempotent. A contribution should have an explicit processed marker or a state transition that cannot pay twice. Do not rely on two independently updated mappings remaining synchronized by convention.
- Test direct and forwarded equivalence. Run the same scenario through a direct call and a real forwarder, then compare all user-keyed storage. Include a fresh address, a cycle transition,
claimFees(),claimRewards(), and both deployed chain configurations.
A minimal invariant test should fail on the old code before it checks payout size:
function invariant_forwardedBurnKeepsIdentityAtomic() public {
forwardBurn(realUser, batches);
assertEq(dbxen.accCycleBatchesBurned(realUser), batches);
assertEq(dbxen.lastActiveCycle(realUser), dbxen.currentCycle());
assertEq(dbxen.accCycleBatchesBurned(address(forwarder)), 0);
}Then add a differential property: after an equivalent direct burn and forwarded burn, the user-keyed accounting state must be identical. Advance a cycle and call each claim function twice; the second call must not increase the user’s balance. That combination catches the identity split, the fresh-address backdating effect, and non-idempotent settlement without hard-coding the attacker’s exact transaction.
Gasless UX moves trust; it does not remove it
ERC-2771 improves onboarding by letting someone other than the user pay gas. It does not make caller identity simpler. It moves identity verification into a forwarder and asks every recipient to distinguish the transport caller from the logical actor consistently.
DBXen’s contracts accepted that complexity at the entry point but lost it across the accounting path. The result was not a theoretical footgun: two deployments, two native-asset fee pools, nearly 12,000 DXN in rewards, and a combined incident estimate near $149K.
The engineering rule is compact enough for every meta-transaction code review: if one user action can be named by both msg.sender and _msgSender(), write down which identity owns every debit, credit, callback, event, and settlement marker. If the answers are not the same logical account—or deliberately reconciled at a boundary—the system has two users where its accounting assumes one.
Sources
- BlockSec: Weekly Web3 Security Incident Roundup, March 9–15, 2026
- NomosLabs: DBXen
burnBatch()sender-mismatch exploit - DARKNAVY: DBXen ERC-2771 context-confusion trace
- Ethereum Improvement Proposals: ERC-2771
- OpenZeppelin: ERC2771Context/Multicall public disclosure
- Slither detector documentation
- Olympix: Post-incident DBXen analysis



