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 ifbalanceOf(attacker) > balanceOf(opponent)
where the opponent in this case is always theflagHolder
.
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:
- Bob want to sell
Mon1
so it callsgame.putUpForSale(mon1);
- Alice intends to swap
Mon2
forMon1
because it's much more powerful. Alice callgame.swap(address(Bob), mon2, mon1)
and the swap happen - Paul, see the transaction and can swap again the
Mon1
that Alice just purchased because theMon
is still “sellable”. Paul callgame.swap(address(Alice), mon3, mon1)
and swap the new Alice'sMon
even if she did not intend to sell it - At this point Mark could call
game.swap(address(Paul), mon4, mon1)
and steal theMon
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:
- implement any kind of Reentrancy Guard
- 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
- Alice has
mon1
,mon2
,mon3
in the deck - Bob deploy an
attacker
contract that implementonERC721Received
andjoin
the game with his contract. The contract hasmon4
,mon5
,mon6
in the deck - Alice put for sale
mon1
- 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 butdeck[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 return0
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 thefight
and now it has insidemon7
,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 ofidx
return variable that is0
indexInDeck(_to, mon1);
will return 0 becausedeck[0] = mon1
decks[attacker][0] = mon1
(mon1
is still alive)decks[alice][0] = mon4
(mon4
has been burned becauseattacker
has lost thefight
)
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:
- we create an
Exploiter
smart contract that is able to interact with thegame
. The smart contract is inheriting fromIERC721Receiver
and implementing theonERC721Received
callback. This callback will be called automatically by thegame
during aswap
operation.
Inside the callback, we are just going to callgame.fight();
. If the callback has been called because ofexploiter.swap(alice, monOfAttackInPos0, monOfAliceInPos0)
it will make the attacker attack withmonOfAttackInPos0
in position 0 so thegame
will burn the Mon that after the callback will be transferred to Alice.
After the end ofswap
the Exploiter, contract will own 4 different Mons (3 new Mons afterfight
+ 1 Mon from Alice) - Use two different accounts to join the game and fill their decks. After that, put all the Mons for sale
- Make the
exploiter
join the game and fill its deck and put all the Mons for sale. - Execute
exploiter.swap(address(attacker2), 9, 3);
to leverage the reentrancy exploit. At the end of eachswap
theexploiter
contract will have the Mon balance increased by 1 (as explained before) - Keep executing the exploit until you have at least 7
mon
before starting the fight. This is important because after thefight
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 asattacker2
has no more swappableMons
(that are burned each time the swap happen because of thefight
) start usingattacker3
Mons to keep swapping and exploiting the reentrancy attack. - Profit and enjoy being the Flag Holder! (well not you but your exploit contract :D
GitHub repo & Links
- GitHub repo with tests made in foundry: https://github.com/StErMi/hats-finance-challenge1-solution
- My Twitter account if you want to follow my content: https://twitter.com/StErMi