Reproduced Exploit

Across SpokePool: claimRelayerRefund clears the wrong refund slot and can be repeated

The function loads relayerRefund[token][msg.sender] and then stores 0 at relayerRefund[token][refundAddress]. Those keys are different whenever the caller asks to be paid somewhere else. One call with refundAddress = victim deletes the victim's 900 RUSD credit. Nine calls with refundAddress = attac…

Dec 2024•Other•logic•2 min read

Chain

Other

Category

logic

Date

Dec 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

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.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 56784-anyone-can-lock-relayer-refunds-and-contract-can-be-drained. Standalone Foundry PoC and full write-up: 56784-anyone-can-lock-relayer-refunds-and-contract-can-be-drained_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/state-update · vuln/dos/frozen-funds

Reproduction: cheatcode-free local synthetic. SpokePool.claimRelayerRefund is the audited body. Full trace: output.txt. PoC: test/56784-anyone-can-lock-relayer-refunds-and-contract-can-be-drained_exp.sol.

AuditVault taxonomy: lang/solidity · sector/options · severity/high · OpenZeppelin Across Protocol SVM Solidity, December 2024

Key info#

ProtocolAcross SpokePool
FindingOpenZeppelin, AuditVault 56784, high (published as critical in the report)
Vulnerable codeSpokePool.claimRelayerRefund
HarmAttacker extracts 900 RUSD. Victim's 900 credit is set to 0. Pool balance ends at 0
Chainlocal synthetic

TL;DR#

The function loads relayerRefund[token][msg.sender] and then stores 0 at relayerRefund[token][refundAddress]. Those keys are different whenever the caller asks to be paid somewhere else. One call with refundAddress = victim deletes the victim's 900 RUSD credit. Nine calls with refundAddress = attacker pay 100 RUSD each, because the caller's own credit is never cleared. The pool started with 1000 RUSD and ends at 0.

The vulnerable code#

SOLIDITY
function claimRelayerRefund(address l2TokenAddress, address refundAddress) external {
    uint256 refundAmount = relayerRefund[l2TokenAddress][msg.sender];
    require(refundAmount > 0, "no refund");
    // @> VULN: zeroes refundAddress, not msg.sender, so the caller's credit survives
    relayerRefund[l2TokenAddress][refundAddress] = 0;
    IMini(l2TokenAddress).transfer(refundAddress, refundAmount);
}

The fixed body stores 0 at msg.sender. The same caller can take 100 RUSD once. A second claim reverts with no refund. The victim's 900 credit is still there and pays out in full.

Diagrams#

flowchart TD A["Pool holds 1000 RUSD"] --> B["Victim credit 900, attacker credit 100"] B --> C["Claim with refundAddress = victim"] C --> D["Victim slot is written to 0"] D --> E["Attacker slot is still 100"] E --> F["Nine claims pay the attacker 900 RUSD"] F --> G["Pool balance is 0 and the victim cannot claim"]

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

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.