All incidents

TIME ERC2771 Burn Exploit

Share
Dec 06, 2023 23:11 UTCAttackLoss: 89.51 WETH, 62,227,259,510 TIMEPending manual check1 exploit txWindow: Atomic
Estimated Impact
89.51 WETH, 62,227,259,510 TIME
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Dec 06, 2023 23:11 UTC → Dec 06, 2023 23:11 UTC

Exploit Transactions

TX 1Ethereum
0xecdd111a60debfadc6533de30fb7f55dc5ceed01dfadd30e4a7ebdb416d2f6b6
Dec 06, 2023 23:11 UTCExplorer

Victim Addresses

0x4b0e9a7da8bab813efae92a6651019b8bd6c0a29Ethereum
0x760dc1e043d99394a10605b2fa08f123d60faf84Ethereum

Loss Breakdown

89.51WETH
62,227,259,510TIME

Similar Incidents

Root Cause Analysis

TIME ERC2771 Burn Exploit

1. Incident Overview TL;DR

On Ethereum mainnet block 18730463, transaction 0xecdd111a60debfadc6533de30fb7f55dc5ceed01dfadd30e4a7ebdb416d2f6b6 exploited thirdweb TokenERC20 at 0x4b0e9a7da8bab813efae92a6651019b8bd6c0a29 and the TIME-WETH Uniswap V2 pair at 0x760dc1e043d99394a10605b2fa08f123d60faf84. The attacker used a public trusted forwarder at 0xc82bbe41f2cf04e3a8efa18f7032bdd7f6d98a81 together with delegatecall-based multicall to spoof _msgSender() as the pair address during burn(uint256), destroy most of the pair's TIME balance, call sync(), and then dump previously acquired TIME for WETH.

The root cause is a dangerous composition of modules that each behave as designed in isolation. TokenERC20 trusts ERC2771 meta-transactions and also exposes OpenZeppelin MulticallUpgradeable. Because multicall delegatecalls attacker-supplied calldata while msg.sender remains the trusted forwarder, ERC2771ContextUpgradeable._msgSender() reads the last 20 bytes of the delegated subcall payload instead of the real outer signer. In this incident, that let an unprivileged attacker make ERC20BurnableUpgradeable.burn(uint256) execute as if the Uniswap pair itself had called it.

2. Key Background

The victim token contract is thirdweb TokenERC20 at 0x4b0e9a7da8bab813efae92a6651019b8bd6c0a29. It inherits ERC2771ContextUpgradeable, MulticallUpgradeable, and ERC20BurnableUpgradeable. The relevant live liquidity venue is the TIME-WETH Uniswap V2 pair at 0x760dc1e043d99394a10605b2fa08f123d60faf84, with TIME as token0 and WETH as token1.

The exploit relies on four background facts:

  1. ERC2771ContextUpgradeable treats the last 20 bytes of calldata as the logical sender whenever msg.sender is a trusted forwarder.
  2. MulticallUpgradeable.multicall(bytes[]) re-enters the same contract with delegatecall, so the outer msg.sender is preserved during inner calls.
  3. ERC20BurnableUpgradeable.burn(uint256) destroys tokens from _msgSender() rather than from an explicit account argument.
  4. A Uniswap V2 pair can be repriced catastrophically if a token contract destroys the pair's token balance and the pair then calls sync() to ratify the new reserve.

The ACT pre-state is Ethereum mainnet after block 18730462 and before the exploit transaction executes. In that pre-state, TokenERC20 already trusts forwarder 0xc82bbe41f2cf04e3a8efa18f7032bdd7f6d98a81, and the TIME-WETH pair already holds 65692658856160314505008470321 TIME and 89787809617954054780 WETH. No privileged key, whitelist position, or private orderflow is required to access the public forwarder, the Uniswap router, or the live token contract.

3. Vulnerability Analysis & Root Cause Summary

The broken invariant is straightforward: any path that relies on _msgSender() for balance ownership or authorization must bind that logical sender to the real signer of the outer call. TokenERC20 violates that invariant once it combines ERC2771 sender suffixing with delegatecall-based multicall. The victim contract trusts the forwarder in ERC2771ContextUpgradeable, and _msgSender() extracts the last 20 calldata bytes whenever the call comes from that forwarder. MulticallUpgradeable then delegatecalls attacker-supplied subcall bytes back into TokenERC20, preserving the forwarder as msg.sender while giving the attacker full control over the new calldata suffix. ERC20BurnableUpgradeable.burn(uint256) consumes _msgSender() directly, so the attacker can burn tokens from any address that can be encoded into the delegated subcall tail. In this incident, the attacker encoded the TIME-WETH pair address, burned 62227259510000000000000000000 TIME from the pair, forced the pair to sync(), and converted the resulting mispricing into 89495661608671096816 wei of net ETH-denominated profit after gas.

The vulnerable code path is visible in the collected source and verified forwarder source:

// ERC2771ContextUpgradeable
function _msgSender() internal view virtual override returns (address sender) {
    if (isTrustedForwarder(msg.sender)) {
        assembly {
            sender := shr(96, calldataload(sub(calldatasize(), 20)))
        }
    } else {
        return super._msgSender();
    }
}

// MulticallUpgradeable
function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
    results = new bytes[](data.length);
    for (uint256 i = 0; i < data.length; i++) {
        results[i] = _functionDelegateCall(address(this), data[i]);
    }
}

// ERC20BurnableUpgradeable
function burn(uint256 amount) public virtual {
    _burn(_msgSender(), amount);
}

4. Detailed Root Cause Analysis

The exploit hinges on how the public forwarder packages meta-transactions. Its verified execute function calls the target with abi.encodePacked(req.data, req.from), so the outer forwarded call into TokenERC20 legitimately carries the attacker's chosen signer address in the last 20 bytes:

(bool success, bytes memory result) = req.to.call{ gas: req.gas, value: req.value }(
    abi.encodePacked(req.data, req.from)
);

In the collected trace, the attacker first buys TIME, then relays a forged-looking but fully valid ForwardRequest through forwarder 0xc82bbe41f2cf04e3a8efa18f7032bdd7f6d98a81. The forwarded target call is TokenERC20.multicall(bytes[]), and the single inner bytes payload is a burn(uint256) subcall whose calldata tail has been replaced with the pair address:

0xc82BbE41f2cF04e3a8efA18F7032BDD7f6d98a81::execute(...)
  TokenERC20::ac9650d8(...)                    // multicall(bytes[])
    TokenERC20::42966c68(...) [delegatecall]   // burn(uint256)
      emit Transfer(
        from: 0x760dc1E043D99394A10605B2FA08F123D60faF84,
        to:   0x0000000000000000000000000000000000000000,
        value: 62227259510000000000000000000
      )

That trace line is the decisive breakpoint. The burn event proves that the delegated subcall resolved _msgSender() to the pair address rather than to the attacker's signer. The collected balance diff corroborates the same state change at storage level: the pair's TIME balance drops from 65692658856160314505008470321 to 3465399346160314505008470321, a delta of -62227259510000000000000000000 TIME.

Once the pair's TIME balance is destroyed, the attacker calls pair.sync(). The trace shows the pair updating reserves to match the manipulated post-burn token balance:

0x760dc1E043D99394A10605B2FA08F123D60faF84::sync()
  emit Sync(9999999891268803341558999, 94787809617954054780)

After the reserve ratification, the attacker sells the TIME acquired in the opening swap back into the now-mispriced pool. The trace shows the profitable exit and WETH withdrawal:

0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D::swapExactTokensForTokensSupportingFeeOnTransferTokens(
  3455399346269045701666911322, 0, [TIME, WETH], 0x6980..., 1701904319
)
0x760dc1E043D99394A10605B2FA08F123D60faF84::swap(0, 94513462587046838316, 0x6980..., 0x)
WETH9::withdraw(94513462587046838316)

The exploit is ACT because the entire transaction sequence is permissionless. An unprivileged actor can fund the buy leg, sign a ForwardRequest for an address they control, relay it through the public forwarder, and use the public Uniswap V2 router and pair. The success predicate is objective and already realized in the incident: the attacker cluster's ETH-denominated value rises from 198257735908099592483 wei to 287753397516770689299 wei, with a net increase of 89495661608671096816 wei after 17800978375741500 wei of gas and fees.

5. Adversary Flow Analysis

The adversary strategy is a single-transaction, multi-stage exploit carried by transaction 0xecdd111a60debfadc6533de30fb7f55dc5ceed01dfadd30e4a7ebdb416d2f6b6.

  1. The top-level EOA 0xfde0d1575ed8e06fbf36256bcdfa1f359281455a funds and submits the transaction to attacker contract 0x6980a47bee930a4584b09ee79ebe46484fbdbdd0.
  2. The attacker contract wraps 5 ETH into WETH and uses router 0x7a250d5630b4cf539739df2c5dacb4c659f2488d to buy 3455399346269045701666911322 TIME from the TIME-WETH pair.
  3. The attacker relays a signed ForwardRequest whose recovered signer is 0xa16a5f37774309710711a8b4e83b068306b21724. That request targets TokenERC20.multicall(bytes[]) and contains a delegated burn(uint256) payload with the pair address appended as the forged sender suffix.
  4. burn(uint256) executes as if the pair itself were calling it and destroys 62227259510000000000000000000 TIME from pair 0x760dc1e043d99394a10605b2fa08f123d60faf84.
  5. The attacker contract calls pair.sync() so the Uniswap reserves now reflect the burned TIME balance.
  6. The attacker immediately sells the TIME bought in step 2 back into the drained pool, receives 94513462587046838316 WETH, unwraps it to ETH, and sends 4923240442287576107 wei to 0xdafea492d9c6733ae3d56b7ed1adb60692c98bc5.

The identified adversary-related accounts are fully supported by the trace and balance deltas:

  • 0xfde0d1575ed8e06fbf36256bcdfa1f359281455a: top-level sender and gas payer.
  • 0x6980a47bee930a4584b09ee79ebe46484fbdbdd0: attacker contract that orchestrates the exploit path.
  • 0xa16a5f37774309710711a8b4e83b068306b21724: recovered meta-transaction signer inside Forwarder.execute.
  • 0xdafea492d9c6733ae3d56b7ed1adb60692c98bc5: direct ETH payout recipient at the end of the exploit.

6. Impact & Losses

The measurable losses are both economic and state-destructive.

  • The TIME-WETH pair loses 89513462587046838316 units of WETH-equivalent value from its pre-state balance sheet.
  • The pair's TIME balance is reduced by 62227259510000000000000000000 TIME through the unauthorized burn.
  • The pair's TIME reserve collapses from 65692658856160314505008470321 to 3465399346160314505008470321.
  • The pair is left with only 274347030907216464 WETH after the exit swap.

From the attacker perspective, the success predicate is net profit in ETH terms. Across the attacker contract, the top-level sender, and the direct payout recipient, the cluster's value rises by 89495661608671096816 wei after fees. This is consistent with the native balance deltas in the collected balance_diff.json, which show WETH/ETH extraction from the pair and gas payment by the top-level sender.

7. References

  1. Exploit transaction metadata for 0xecdd111a60debfadc6533de30fb7f55dc5ceed01dfadd30e4a7ebdb416d2f6b6, including block 18730463, calldata, and gas data.
  2. Full execution trace for the exploit transaction, showing the opening TIME buy, Forwarder.execute, delegated burn(uint256), pair.sync(), the exit swap, and WETH.withdraw.
  3. Balance diff artifact for the exploit transaction, showing the pair TIME burn, WETH depletion, and the attacker's net ETH-denominated gain.
  4. thirdweb TokenERC20 verified source, especially its inheritance of ERC2771ContextUpgradeable, MulticallUpgradeable, and ERC20BurnableUpgradeable.
  5. ERC2771ContextUpgradeable source, specifically _msgSender() and _msgData() sender-suffix handling for trusted forwarders.
  6. OpenZeppelin MulticallUpgradeable source, specifically multicall(bytes[]) and _functionDelegateCall.
  7. OpenZeppelin ERC20BurnableUpgradeable source, specifically burn(uint256).
  8. Verified source for forwarder 0xc82bbe41f2cf04e3a8efa18f7032bdd7f6d98a81, especially execute(ForwardRequest,bytes) and its abi.encodePacked(req.data, req.from) call pattern.
  9. Verified source for Uniswap V2 pair 0x760dc1e043d99394a10605b2fa08f123d60faf84, especially sync(), swap(...), and reserve accounting.