Reproduced Exploit

IOTA Heroes — contractAdventure mints the reward twice

contractAdventure takes the rent and sends it to the hero owner before _initiateAdventure, and before started is set. The owner is a contract. On the ETH call it uses goAdventure, which is still allowed, and that path mints 1000 gold. The outer call then mints another 1000 for the same hero.

Apr 2023•Other•reentrancy•3 min read

Chain

Other

Category

reentrancy

Date

Apr 2023

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: 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no. Standalone Foundry PoC and full write-up: 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/reentrancy/cross-function · vuln/logic/incorrect-order-of-operations

Key info#

ProtocolIOTA Heroes
FindingAuditOne, AuditVault 21017
Vulnerable codeAdventure.contractAdventure then goAdventure
HarmOne 1 ETH rental mints 2000 gold. Recording the adventure before the payment mints 1000 gold and the owner still receives the 1 ETH.
Chainlocal synthetic

TL;DR#

contractAdventure takes the rent and sends it to the hero owner before _initiateAdventure, and before started is set. The owner is a contract. On the ETH call it uses goAdventure, which is still allowed, and that path mints 1000 gold. The outer call then mints another 1000 for the same hero.

The vulnerable code#

SOLIDITY
function contractAdventure(uint256 heroId) external payable {
    require(msg.value == RENT, "rent");
    require(!started[heroId], "started");
    address heroOwner = ownerOf[heroId];
    // @> VULN: rent is paid before _initiateAdventure, so the owner can reenter
    (bool ok,) = heroOwner.call{value: msg.value}("");
    require(ok, "pay");
    _initiateAdventure(heroId, msg.sender);
    started[heroId] = true;
}

The control calls _initiateAdventure and sets started before the ETH transfer. goAdventure then sees started and does not mint.

Root cause#

The external payment is the first interaction, and the "already adventuring" bit is the last write. goAdventure shares _initiateAdventure and only checks started. During the payment that bit is still false.

Preconditions#

The hero owner is a contract the attacker controls, and that contract is also the renter. Rent is 1 ETH. The reward is 1000 gold. The report's unpublished adventure source is reduced to this order.

Attack walkthrough#

  1. The owner calls contractAdventure for hero 1 with 1 ETH.
  2. The rent check passes. started is false.
  3. The 1 ETH is sent to the owner. The owner calls goAdventure.
  4. goAdventure mints 1000 gold.
  5. The outer call mints another 1000 and only then sets started.
  6. The owner holds 2000 gold. The adventure contract holds 0 ETH.

The control mints 1000, sets started, then pays the 1 ETH. The callback does not call goAdventure. The owner holds 1000 gold and the 1 ETH rent.

Diagrams#

flowchart TD A["Owner calls contractAdventure with 1 ETH"] --> B["started is still false"] B --> C["VULN: rent is sent to the owner"] C --> D["Owner calls goAdventure and mints 1000 gold"] D --> E["Outer call mints another 1000 gold"] E --> F["Owner holds 2000 gold for one rental"] F --> G["Record-first control mints 1000 and still pays the rent"]

Impact#

Each rental of a hero whose owner is a contract mints the adventure reward twice. Here that is 2000 gold instead of 1000. The extra 1000 is unbacked mint.

Remediation#

Call _initiateAdventure and set started before transferring the rent, and guard both entry points with nonReentrant.

How to reproduce#

BASH
cd 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no_exp
forge test -vv

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.