Reproduced Exploit
A second push blocks Derby rebalance step 5
pushVaultAmounts has no record of which chains were already pushed. A deposit at or above minimumAmount does fundsReceived++. After the keeper pushes three active vaults and chain 1's withdrawal is answered, fundsReceived is 3, equal to activeVaults, so step 5 would send chain 2's 12,000 USDC. One…
Loss
12,000 USDC stays in the controller. Step 5 reverts
Chain
Ethereum
Category
dos
Date
Jan 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 12294-h-12-attacker-can-prevent-the-system-from-entering-step-5-du. Standalone Foundry PoC and full write-up: 12294-h-12-attacker-can-prevent-the-system-from-entering-step-5-du_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/dos/frozen-funds · vuln/logic/missing-check
Key info#
| Field | Value |
|---|---|
| Loss | 12,000 USDC stays in the controller. Step 5 reverts |
| Vulnerable contract | XChainController.sendXChainAmount |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2023-01 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | Permissionless push increments fundsReceived with no per-chain sent mark |
TL;DR#
pushVaultAmounts has no record of which chains were already pushed. A deposit at or above minimumAmount does fundsReceived++. After the keeper pushes three active vaults and chain 1's withdrawal is answered, fundsReceived is 3, equal to activeVaults, so step 5 would send chain 2's 12,000 USDC. One extra push of chain 2 makes the count 4. sendFundsToVault requires equality and reverts. The 12,000 USDC stays in the controller and the chain 2 vault receives 0. A sent mark rejects the second push, the count stays 3, and step 5 moves the 12,000.
The vulnerable code#
} else if (_amountDeposit >= minimumAmount) {
receivingFunds = true;
setAmountToDeposit(_vaultNumber, _chainId, _amountDeposit);
// @> VULN: no per-chain sent mark, so a second push increments fundsReceived again
vaultStage[_vaultNumber].fundsReceived++;
}
Step 5 is sendFundsToVault, gated by:
require(vaultStage[vaultNumber].fundsReceived == vaultStage[vaultNumber].activeVaults, "Not all funds received");
Root cause#
The stage counter is a tally, not a set of chains. pushVaultAmounts is callable by anyone once underlyings have been reported. Calling it again for a chain whose deposit is still at least minimumAmount (1,000 USDC) counts that chain a second time. The callback that answers a large withdrawal counts separately. The total can only go up, so it can pass activeVaults and never land back on it.
Preconditions#
- Vault 1 has three active chains. Allocations are 30, 30, and 20. Total underlying is 80,000 USDC: 50,000 on chain 1, 18,000 on chain 2, 12,000 on chain 3. No withdrawal requests.
- That split makes chain 1 withdraw 20,000 USDC, chain 2 deposit 12,000 USDC, and chain 3 deposit 8,000 USDC. All three are above the 1,000 USDC minimum.
- A large withdrawal does not increment
fundsReceiveduntilupFundsReceived. A large deposit increments immediately. - The controller holds the 12,000 USDC that step 5 should forward to chain 2.
Attack walkthrough#
- The controller is minted 12,000 USDC (
12000000000base units) (output.txt). - Push chain 1. It is a 20,000 USDC withdrawal, so
fundsReceivedstays 0 andamountToSendis 20,000 USDC. - Push chain 2. The 12,000 USDC deposit increments
fundsReceivedto 1 and storesamountToDeposit. - Push chain 3. The 8,000 USDC deposit increments
fundsReceivedto 2. - The xProvider reports chain 1's funds.
fundsReceivedis 3, equal toactiveVaults. - Push chain 2 again. The same deposit branch increments
fundsReceivedto 4. sendFundsToVaultfor chain 2 revertsNot all funds received. The controller still holds 12,000 USDC. The chain 2 vault balance is 0. The queued deposit is still 12,000 USDC.- Control: the second push reverts
already pushed.fundsReceivedstays 3. Step 5 transfers the 12,000 USDC to the chain 2 vault and the controller balance is 0.
Diagrams#
Impact#
12,000 USDC that step 5 should deliver to the chain 2 vault never moves. The controller balance stays 12000000000 and the vault balance stays 0. Rebalancing for this vault cannot enter step 5 until a guardian overwrites fundsReceived. On the fixed controller the same 12,000 USDC arrives at the chain 2 vault.
Remediation#
Record a sent mark per vault and chain at the start of pushVaultAmounts, and reject a second push. Check the mark before sendXChainAmount increments fundsReceived.
How to reproduce#
cd evm-hack-registry/12294-h-12-attacker-can-prevent-the-system-from-entering-step-5-du_exp
forge test -vv
Sources#
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 12294-h-12-attacker-can-prevent-the-system-from-entering-step-5-du_exp (evm-hack-registry mirror).
- AuditVault finding: 12294-h-12-attacker-can-prevent-the-system-from-entering-step-5-du.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.