All incidents

IdolMain Reward Double-Claim

Share
Jan 14, 2025 17:31 UTCAttackLoss: 12.95 stETHPending manual check2 exploit txWindow: 36s
Estimated Impact
12.95 stETH
Label
Attack
Exploit Tx
2
Addresses
2
Attack Window
36s
Jan 14, 2025 17:31 UTC → Jan 14, 2025 17:31 UTC

Exploit Transactions

TX 1Ethereum
0xeb95b7a106c3379b38881a8d99b65a0319a28da2275eacb53f47aa1e7f63ab66
Jan 14, 2025 17:31 UTCExplorer
TX 2Ethereum
0x5e989304b1fb61ea0652db4d0f9476b8882f27191c1f1d2841f8977cb8c5284c
Jan 14, 2025 17:31 UTCExplorer

Victim Addresses

0x439cac149b935ae1d726569800972e1669d17094Ethereum
0xae7ab96520de3a18e5e111b5eaab095312d7fe84Ethereum

Loss Breakdown

12.95stETH

Similar Incidents

Root Cause Analysis

IdolMain Reward Double-Claim

1. Incident Overview TL;DR

An ACT attacker exploited IdolMain on Ethereum by moving a single Idol NFT into a future CREATE address, deploying a helper contract at that address, and repeatedly self-transferring the NFT to claim the same stETH reward snapshot multiple times. In seed transaction 0x5e989304b1fb61ea0652db4d0f9476b8882f27191c1f1d2841f8977cb8c5284c, the helper contract ultimately transferred 12950523927926154327 wei of stETH to adversary-controlled recipient 0x8152970a81f558d171a22390e298b34be8d40cf4.

The root cause is a logic bug in IdolMain::_beforeTokenTransfer. When _from == _to and the holder owns exactly one NFT, the function first claims rewards for _from, then deletes claimedSnapshots[_from], and then claims again for _to, which is the same address. That reopens the same rewardPerGod unit and makes the reward claim non-idempotent under self-transfer.

2. Key Background

IdolMain is an ERC721-like contract that also distributes stETH rewards to NFT holders. Reward accounting is driven by the global variable rewardPerGod, while each holder's claimed position is stored in claimedSnapshots[address]. A holder's pending reward is derived from its NFT balance multiplied by the difference between rewardPerGod and its claimed snapshot.

The transfer hook is security-critical because IdolMain performs reward settlement inside _beforeTokenTransfer, before the ownership transition is finalized. That means the hook must be correct for both ordinary transfers and edge cases such as self-transfer.

This incident also depends on a standard EVM property: an address that will later receive code via CREATE can already receive ERC721 tokens before deployment. The adversary used that property to preload the NFT into the future exploit contract address and then deployed code there in a later transaction.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability class is an application logic bug in transfer-time reward accounting. IdolMain assumes the sender and receiver of a transfer are distinct holders, but that assumption is false when a holder self-transfers an NFT. For a holder with exactly one Idol, _claimEthRewards(_from) updates claimedSnapshots[_from] to the current rewardPerGod, after which the hook immediately deletes the same snapshot because balanceOf(_from) == 1. If the transfer is a self-transfer, the receiver branch then treats the same address as an eligible recipient and calls _claimEthRewards(_to) again. Because the snapshot was just deleted, the second claim re-computes the same reward chunk as unclaimed and transfers stETH again.

The relevant victim-side code path is:

function _beforeTokenTransfer(address _from, address _to, uint256 _tokenId)
    internal
    virtual
    override
    onlyAllowedContracts(_to)
{
    super._beforeTokenTransfer(_from, _to, _tokenId);
    if (_from != address(0x0)) {
        _claimEthRewards(_from);
        if (balanceOf(_from) == 1) {
            delete claimedSnapshots[_from];
        }
    }

    if (balanceOf(_to) > 0) {
        _claimEthRewards(_to);
    } else {
        claimedSnapshots[_to] = rewardPerGod;
    }
}

function _claimEthRewards(address _user) internal nonReentrant {
    uint256 currentRewards = getPendingStethReward(_user);
    if (currentRewards > 0) {
        allocatedStethRewards = allocatedStethRewards - currentRewards;
        claimedSnapshots[_user] = rewardPerGod;
        require(steth.transfer(_user, currentRewards));
    }
}

The violated invariant is: a holder with one Idol must not be able to realize the same rewardPerGod increment more than once per owned NFT. The concrete breakpoint is the deletion of claimedSnapshots[_from] in the same self-transfer execution that later calls _claimEthRewards(_to) for the same address.

4. Detailed Root Cause Analysis

The ACT opportunity existed before block 21624237. At that point Idol #940 was owned by 0xe546480138d50bb841b204691c39cc514858d101, IdolMain.rewardPerGod() was already non-zero, and the future CREATE address 0x22d22134612c0741ebdb3b74a58842d6e74e3b16 had not yet been deployed.

The prerequisite transaction was 0xeb95b7a106c3379b38881a8d99b65a0319a28da2275eacb53f47aa1e7f63ab66 in block 21624237. Its calldata invokes IdolMain.transferFrom(0xe546..., 0x22d221..., 940), and the receipt contains the matching ERC721 Transfer event proving that Idol #940 moved into the future CREATE address before any code existed there.

input: 0x23b872dd...e546480138d50bb841b204691c39cc514858d101...22d22134612c0741ebdb3b74a58842d6e74e3b16...03ac
log Transfer(from: 0xe546480138d50bb841b204691c39cc514858d101,
             to:   0x22d22134612c0741ebdb3b74a58842d6e74e3b16,
             tokenId: 940)

Three blocks later, transaction 0x5e989304b1fb61ea0652db4d0f9476b8882f27191c1f1d2841f8977cb8c5284c deployed code at that prepared address. Because the NFT had already been transferred there, the constructor immediately controlled a single Idol and could execute the vulnerable self-transfer loop.

The seed exploit trace shows repeated calls of:

IdolMain::safeTransferFrom(
  0x22d22134612C0741EBDb3B74a58842D6E74E3b16,
  0x22d22134612C0741EBDb3B74a58842D6E74E3b16,
  940
)

Each iteration emits a stETH transfer from IdolMain to the same exploit contract for one rewardPerGod chunk:

emit Transfer(
  from: IdolMain: [0x439cac149B935AE1D726569800972E1669d17094],
  to:   0x22d22134612C0741EBDb3B74a58842D6E74E3b16,
  value: 31056412297185023
)

That trace behavior is exactly what the source-level bug predicts: the same address reclaims the same reward window because the snapshot is deleted and then recomputed during a no-op ownership transition.

Near the end of the same trace, the exploit realizes profit by forwarding the accumulated stETH to the adversary-controlled recipient:

Lido::balanceOf(0x22d22134612C0741EBDb3B74a58842D6E74E3b16) => 12950523927926154327
Lido::transfer(0x8152970a81f558d171a22390E298B34Be8d40CF4, 12950523927926154327)
emit Transfer(from: 0x22d22134612C0741EBDb3B74a58842D6E74E3b16,
              to:   0x8152970a81f558d171a22390E298B34Be8d40CF4,
              value: 12950523927926154327)

The exploit contract then returns Idol #940 to 0xe546480138d50bb841b204691c39cc514858d101, showing that the value extraction came from repeated reward claims rather than permanent seizure of the NFT.

5. Adversary Flow Analysis

The adversary flow has two transactions and no privileged prerequisites beyond owning one unlocked Idol NFT.

  1. In tx 0xeb95b7a106c3379b38881a8d99b65a0319a28da2275eacb53f47aa1e7f63ab66, EOA 0xe546480138d50bb841b204691c39cc514858d101 transfers Idol #940 into future CREATE address 0x22d22134612c0741ebdb3b74a58842d6e74e3b16.
  2. In tx 0x5e989304b1fb61ea0652db4d0f9476b8882f27191c1f1d2841f8977cb8c5284c, the same EOA deploys a helper contract at that address.
  3. The freshly deployed contract verifies it owns exactly one Idol and repeatedly executes safeTransferFrom(address(this), address(this), 940) while allocatedStethRewards remains at least one rewardPerGod unit.
  4. Each self-transfer reopens the same reward snapshot and causes another stETH transfer from IdolMain into the helper contract.
  5. After the loop, the helper transfers the accumulated stETH to recipient 0x8152970a81f558d171a22390e298b34be8d40cf4.
  6. The helper returns Idol #940 to the original EOA, leaving the exploit contract with no meaningful retained stETH balance.

The identified adversary-related accounts are:

  • 0xe546480138d50bb841b204691c39cc514858d101: deployer EOA and prerequisite NFT sender.
  • 0x22d22134612c0741ebdb3b74a58842d6e74e3b16: future CREATE address and exploit helper contract.
  • 0x8152970a81f558d171a22390e298b34be8d40cf4: stETH profit recipient hard-coded by the exploit.

The public victim components are:

  • IdolMain at 0x439cac149b935ae1d726569800972e1669d17094
  • stETH at 0xae7ab96520de3a18e5e111b5eaab095312d7fe84

6. Impact & Losses

The seed exploit transaction transferred 12950523927926154327 wei of stETH to the adversary-controlled profit recipient. Expressed with 18 decimals, that is approximately 12.950523927926154327 stETH.

This is more than a one-off accounting mistake for a single holder. Because the defect is permissionless and depends only on controlling one unlocked Idol while rewards are funded, any qualifying holder could repeatedly consume shared reward inventory intended for all Idol holders. The vulnerability therefore undermines the correctness of the protocol's reward distribution model and can drain allocatedStethRewards in reward-sized chunks until the remaining inventory falls below one rewardPerGod unit.

7. References

  1. Verified IdolMain source at 0x439cac149b935ae1d726569800972e1669d17094, especially _beforeTokenTransfer and _claimEthRewards.
  2. Prerequisite transaction 0xeb95b7a106c3379b38881a8d99b65a0319a28da2275eacb53f47aa1e7f63ab66 and its receipt, proving transfer of Idol #940 into the future CREATE address.
  3. Seed exploit transaction 0x5e989304b1fb61ea0652db4d0f9476b8882f27191c1f1d2841f8977cb8c5284c metadata and full execution trace, proving repeated self-transfers, repeated stETH payouts, profit forwarding, and NFT return.
  4. Root cause validation result in artifacts/validator/root_cause_challenge_result.json, which independently confirms the exploit mechanism and the corrected trace-backed profit amount.
IdolMain Reward Double-Claim | Clara