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…

Jul 2021•Other•logic•3 min read

Chain

Other

Category

logic

Date

Jul 2021

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: 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-registry mirror.


Vulnerability classes: vuln/logic/missing-check · vuln/logic/state-update · vuln/dependency/unsafe-external-call

Key info#

ProtocolConnext TransactionManager
FindingCode4rena 2021-07 H-05, AuditVault 25506
Vulnerable codeTransactionManager.fulfill catch path
HarmThe helper pulls 1000 NEXT from the next deposit with the approval the catch left behind
Chainlocal 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#

SOLIDITY
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#

  1. The bridge is funded with 1000e18 and fulfill approves the helper.
  2. addFunds reverts. The catch pays the user 1000e18. The allowance is still 1000e18.
  3. Another 1000e18 is minted to the bridge.
  4. The helper transferFroms that 1000e18 to the sink. The bridge is empty.

Diagrams#

flowchart TD A["Approve helper for 1000"] --> B["addFunds reverts"] B --> C["VULN: catch pays the user and leaves the approval"] C --> D["Another 1000 arrives at the bridge"] D --> E["Helper transferFrom pulls that 1000"]

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#


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.