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.
Chain
Other
Category
reentrancy
Date
Apr 2023
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: 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-registrymirror.
Vulnerability classes: vuln/reentrancy/cross-function · vuln/logic/incorrect-order-of-operations
Key info#
| Protocol | IOTA Heroes |
| Finding | AuditOne, AuditVault 21017 |
| Vulnerable code | Adventure.contractAdventure then goAdventure |
| Harm | One 1 ETH rental mints 2000 gold. Recording the adventure before the payment mints 1000 gold and the owner still receives the 1 ETH. |
| Chain | local 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#
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#
- The owner calls
contractAdventurefor hero 1 with 1 ETH. - The rent check passes.
startedis false. - The 1 ETH is sent to the owner. The owner calls
goAdventure. goAdventuremints 1000 gold.- The outer call mints another 1000 and only then sets
started. - 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#
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#
cd 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no_exp
forge test -vv
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/21017-reentrancy-exploit-in-initiateadventure-function-auditone-no.md
- AuditOne report: https://github.com/solodit/solodit_content/blob/main/reports/AuditOne/2023-04-13-Lotaheros.md
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no_exp (evm-hack-registry mirror).
- AuditVault finding: 21017-reentrancy-exploit-in-initiateadventure-function-auditone-no.
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.