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…
Chain
Other
Category
logic
Date
Dec 2024
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: 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-registrymirror.
Vulnerability classes: vuln/logic/state-update · vuln/dos/frozen-funds
Reproduction: cheatcode-free local synthetic.
SpokePool.claimRelayerRefundis 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#
| Protocol | Across SpokePool |
| Finding | OpenZeppelin, AuditVault 56784, high (published as critical in the report) |
| Vulnerable code | SpokePool.claimRelayerRefund |
| Harm | Attacker extracts 900 RUSD. Victim's 900 credit is set to 0. Pool balance ends at 0 |
| Chain | local 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#
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#
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 56784-anyone-can-lock-relayer-refunds-and-contract-can-be-drained_exp (evm-hack-registry mirror).
- AuditVault finding: 56784-anyone-can-lock-relayer-refunds-and-contract-can-be-drained.
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.