Reproduced Exploit
Connext fulfill — a failed helper keeps a 1000 approval and spends the next deposit
fulfill approves callTo for toSend, then calls addFunds. The helper reverts. The catch transfers the 1000 to the user and does not set the allowance back to 0. A later 1000 deposit is pulled by that leftover approval. The control approves 0 in the catch, and the same pull reverts, leaving the later…
Chain
Other
Category
logic
Date
Jul 2021
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: 25506-h-05-approval-is-not-reset-if-the-call-to-ifulfillhelper-fai. Standalone Foundry PoC and full write-up: 25506-h-05-approval-is-not-reset-if-the-call-to-ifulfillhelper-fai_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/missing-check · vuln/logic/state-update · vuln/dependency/unsafe-external-call
Key info#
| Protocol | Connext TransactionManager |
| Finding | Code4rena 2021-07 H-05, AuditVault 25506 |
| Vulnerable code | TransactionManager.fulfill catch path |
| Harm | The helper pulls 1000 NEXT from the next deposit with the approval the catch left behind |
| Chain | local synthetic |
TL;DR#
fulfill approves callTo for toSend, then calls addFunds. The helper reverts. The catch transfers the 1000 to the user and does not set the allowance back to 0. A later 1000 deposit is pulled by that leftover approval. The control approves 0 in the catch, and the same pull reverts, leaving the later deposit in the bridge.
The vulnerable code#
require(token.approve(callTo, toSend), "fulfill: APPROVAL_FAILED");
try IFulfillHelper(callTo).addFunds(user, transactionId, address(token), toSend) {}
catch {
// @> VULN: the fallback transfer runs and the approval of callTo is left in place
require(token.transfer(receivingAddress, toSend), "fulfill: TRANSFER_FAILED");
}
The control adds token.approve(callTo, 0) after the fallback transfer.
Root cause#
The approval is granted before the external call and is not inside the successful path only. A revert in the helper skips every line that would have consumed the allowance, and the catch does not clear it. The allowance stays equal to toSend.
Preconditions#
toSend is 1000e18 of an ERC-20. callTo is a contract whose addFunds reverts. The bridge later receives another 1000e18. Anyone who can call the helper can then transferFrom the bridge.
Attack walkthrough#
- The bridge is funded with 1000e18 and
fulfillapproves the helper. addFundsreverts. The catch pays the user 1000e18. The allowance is still 1000e18.- Another 1000e18 is minted to the bridge.
- The helper
transferFroms that 1000e18 to the sink. The bridge is empty.
Diagrams#
Impact#
The helper takes 1000e18 that arrived after the failed fulfill. The user already received the original 1000e18, so the second 1000e18 is an extra loss from the bridge. The control's reset makes the pull revert and the second 1000e18 stays.
Remediation#
Approve the helper only inside the successful path, or set the allowance to 0 in the catch. Connext patched this in nxtp pull 39.
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/25506-h-05-approval-is-not-reset-if-the-call-to-ifulfillhelper-fai.md
- Code4rena report: https://code4rena.com/reports/2021-07-connext
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 25506-h-05-approval-is-not-reset-if-the-call-to-ifulfillhelper-fai_exp (evm-hack-registry mirror).
- AuditVault finding: 25506-h-05-approval-is-not-reset-if-the-call-to-ifulfillhelper-fai.
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.