Hats Finance Challenge #1 solution

StErMi
9 min readMay 12, 2022

--

Last week I received a DM from Hats Finance, a Decentralized smart bug bounty marketplace. The DM was referring to a challenge where I signed up during DevConnect Amsterdam. It was talking about some security challenge to solve. What a great way to pass my evenings and weekend :D

If you want to know more, you can jump to the landing Hats Finance Gamification landing page.

Hats Finance Challenge #1

The first challenges consist of exploiting a Capture the flag game.

Capture the Flag

The contract Game.sol encodes a card fighting game where the goal is to obtain the flag by pitching your deck of Mons against the deck of the flagHolder and win the fight

  • Anyone can join the game calling game.join()
  • On joining, a player receives a deck of 3 pseudo-random Mons
  • Each Mon is an NFT, and each Mon has powers: FIRE; WATER; AIR and SPEED, each with a value in a range from [0–9]
  • Users can try to improve their deck by swapping their Mons with other users, or by exchanging a Mon for a randomly generated new one
  • For a swap to succeed, another player must put one of their Mons for sale
  • At each moment, one single player holds the flag
  • Other players can try to capture the flag by challenging the flag holder
  • A fight between two Mons takes place with one of the 3 elements (FIRE, WATER or AIR). The Mon with the highest value in that element wins the fight. If the two Mons have the same strength, the Mon with the most SPEED wins. If the two Mons are exactly the same, the flag holder wins.
  • A fight between two decks consists of pairing the three Mons of the challenger with the 3 Mons of the flag holder, pseudo-randomly choosing 3 elements, and then having the 3 pairs fight on each of these elements

The Hats Challenge

The Game.sol is deployed with the flagHolder holding an apparently unbeatable deck with perfect Mons.

Your mission is to obtain the flag: i.e. game.flagHolder() should return an address that you control

How can we exploit the game?

The only way to capture the flag is to beat the flag holder user.

If you look at the fight() function, you understand from the start that there’s no way that playing fairly, you will be able to win the game. The flag holder will always win. ALWAYS.

After reviewing the contract, I understood that the only way to win the game would have been:

  • Steal the flag holder Mons from his deck by swapping them, replacing them or stealing them in some way
  • Being able to fulfill the fight() winning condition that was checking if balanceOf(attacker) > balanceOf(opponent) where the opponent in this case is always the flagHolder.

Let’s review the contract to find all the exploitable parts and create a strategy.

randomGen() Pseduo-randomness generator can be exploited

Well, as the name say, the function will generate a pseudo-random number.

Let’s take a look at the code of the function

function randomGen(uint256 i) private returns (uint8) {
uint8 x = uint8(uint256(keccak256(abi.encodePacked(block.number, msg.sender, nonce))) % i);
nonce++;
return x;
}

Because all of these parameters can be pre-computed at transaction call time (even the nonce that is private can be read at list using etherjs) you could always know which value randomGen would generate at a given time and revert (or not even do) the call until the resulting value satisfy your need.

It means that you could call swapForNewMon infinite time until you would be able to generate a new Mon that have one stat maxed up to 9.

forSale is not resetted to false after a swap

When you want to put your Mon for sale, you must call putUpForSale(uint256 _monId). The function will change the value of the mapping forSale[_monId] = true;.

This is a requirement when, at some point in time a buyer want to swap his Mon for yours. If your Mon is not for sale (forSale[_monId] == false;) the swap function called by the buyer will correctly revert.

The problem in this case is that after a swap the forSale value of the sold Mon is not resetted back to false. This mean that the Mon that now is owned by the buyer is always in a “sellable” state. Anyone in the world can swap his Mon for the Mon that the buyer has just bought without the buyer consent (and so on forever).

Scenario:

  1. Bob want to sell Mon1 so it calls game.putUpForSale(mon1);
  2. Alice intends to swap Mon2 for Mon1 because it's much more powerful. Alice call game.swap(address(Bob), mon2, mon1) and the swap happen
  3. Paul, see the transaction and can swap again the Mon1 that Alice just purchased because the Mon is still “sellable”. Paul call game.swap(address(Alice), mon3, mon1) and swap the new Alice's Mon even if she did not intend to sell it
  4. At this point Mark could call game.swap(address(Paul), mon4, mon1) and steal the Mon from Paul and the cycle can go on again and again

Swaps don’t allow the seller to accept the transaction, favoring the buyer

Let’s make an example:

Alice has a medium powerful Mon1 that want to sell, let’s say a Mon(5,5,5,5) Bob see that Alice has put Mon1 for sale, and he decides to swap it with his Mon2 that is the worst Mon available Mon(1,1,1,1).

Alice cannot decide to accept the transaction and Bob gain, without losing anything, a better Mon.

The swap function should follow a two-step pattern that allow Alice to accept the exchange.

indexInDeck function does not check if the _monId is present in the deck

Let see how the indexInDeck function works:

function indexInDeck(address _owner, uint256 _monId) internal view returns(uint256 idx) {
for (uint256 i; i < DECK_SIZE; i++) {
if (decks[_owner][i] == _monId) {
idx = i;
}
}
}

The function return the index inside the deck array of a user if the _monId is found. But what happens if the user does not own that specific _monId?

Because the function is not handling that case, the idx return variable will be returned not initialized and by default in Solidity when a variable is not initialized it takes the default value for the specific type of the variable.

In this case,indexInDeck will return 0. This is an important bug because if the _monId is not found in the user’s deck, the function will return a value indicating that the _monId is present in the deck in the position 0 even if it’s not true!

The function should return inside the if and revert if it arrives at the end of the for without finding a match.

swap function is vulnerable to reentrancy attack because of _safeTransfer. It does not follow Checks-Effects-Interactions Pattern best practice or use reentrancy guard

Let’s see the code of the swap function:

_safeTransfer(address from, address to, uint256 tokenId, bytes memory _data) is a function that internally call a "callback" onERC721Received if the to address is a contract. You can learn more on the OpenZeppelin ERC721 implementation code.

This is needed to be sure that the receiving address can handle ERC721 tokens (NFT).

The problem in this case is that the swap function does not:

  1. implement any kind of Reentrancy Guard
  2. update the contract’s state variables after the external call, not following the checks-effects-interactions pattern described in the Solidity documentation

Let’s make an example to better understand the problem. Follow it carefully because it’s pretty complex, and it will be the main door for our solution to the challenge.

In this scenario, the to (the address that will receive the NFT) is a Contract that implement the onERC721Received callback

  1. Alice has mon1, mon2, mon3 in the deck
  2. Bob deploy an attacker contract that implement onERC721Received and join the game with his contract. The contract has mon4, mon5, mon6 in the deck
  3. Alice put for sale mon1
  4. Bob call attacker.game.swap(alice, mon4, mon1)

when _safeTransfer(_to, swapper, _monId2, ""); is executed, the Game call attacker.onERC721Received callback. At this point, the decks are not yet updated (they are updated only after the second _safeTransfer).

This mean that inside onERC721Received

  • mon1 is already owned by Alice but
  • deck[alice][0] = mon1
  • deck[attacker][0] = mon4

At this point, the attacker is able for example to call game.fight() where the game would fight with deck[attacker][0] that still point to mon4 even if it has already been transferred to Alice.

So, when the attacker will be defeated by the unbeatable flagholder the game will burn mon4 that is currently owned (because it has been transferred) by Alice and not by the attacker. Note that _burn is a low-level instruction that is not checking who's the owner of the tokenId.

After the fight end all the attacker mons will be burned and new mon will be replaced to the burned one.

After returning from the onERC721Received callback, the state of the attacker deck is like this

  • deck[0] = mon7
  • deck[1] = mon8
  • deck[2] = mon9

and the following instructions will be executed

// update the decks
uint256 idx1 = indexInDeck(swapper, _monId1);
uint256 idx2 = indexInDeck(_to, _monId2);
decks[swapper][idx1] = _monId2;
decks[_to][idx2] = _monId1;
  • indexInDeck(swapper, mon4); will return 0 because it will try to find a token with ID 4 inside the deck, but the user deck has been replaced because the user has lost the fight and now it has inside mon7, mon8, mon9. Because of the problem we have discussed before, instead of reverting when it does not find a token that should be in the deck, it will return the default value of idx return variable that is 0
  • indexInDeck(_to, mon1); will return 0 because deck[0] = mon1
  • decks[attacker][0] = mon1 (mon1 is still alive)
  • decks[alice][0] = mon4 (mon4 has been burned because attacker has lost the fight)

So at the end of the cycle the attacker ends with 4 Mon instead of 3 because it has 3 new Mon (minted by fight to replenish the deck) + the one that has been swapped from Alice.

How to exploit Reentrancy to gain the flag holder title

It will be a mix of all the problem we have seen, in particular we are going to use both the reentrancy problem in swap and the indexInDeck not reverting when the Mon is not present in the deck.

We could also leverage the fact that forSell is not updated, but it's not relevant because we will use different addresses to exploit it. In a normal scenario, we could use the forSell exploit to gain access to other user's Mon that have been put for sale at least one time.

What we do is:

  1. we create an Exploiter smart contract that is able to interact with the game. The smart contract is inheriting from IERC721Receiver and implementing the onERC721Received callback. This callback will be called automatically by the game during a swap operation.
    Inside the callback, we are just going to call game.fight();. If the callback has been called because of exploiter.swap(alice, monOfAttackInPos0, monOfAliceInPos0) it will make the attacker attack with monOfAttackInPos0 in position 0 so the game will burn the Mon that after the callback will be transferred to Alice.
    After the end of swap the Exploiter, contract will own 4 different Mons (3 new Mons after fight + 1 Mon from Alice)
  2. Use two different accounts to join the game and fill their decks. After that, put all the Mons for sale
  3. Make the exploiter join the game and fill its deck and put all the Mons for sale.
  4. Execute exploiter.swap(address(attacker2), 9, 3); to leverage the reentrancy exploit. At the end of each swap the exploiter contract will have the Mon balance increased by 1 (as explained before)
  5. Keep executing the exploit until you have at least 7 mon before starting the fight. This is important because after the fight the game will check if the attacker (you) have more tokens compared to the flag holder. If so, you (attacker) will become the flag holder! As soon as attacker2 has no more swappable Mons (that are burned each time the swap happen because of the fight) start using attacker3 Mons to keep swapping and exploiting the reentrancy attack.
  6. Profit and enjoy being the Flag Holder! (well not you but your exploit contract :D

GitHub repo & Links

--

--

StErMi
StErMi

Written by StErMi

#web3 dev + auditor | @SpearbitDAO security researcher, @yAcademyDAO resident auditor, @developer_dao #459, @TheSecureum bootcamp-0, @code4rena warden

Responses (1)