force the transfer funds to arena with selfdestruct
guess the correct number and become win the fight
empty the arena balance with a reentrancy
claim the golden egg
claim the flag
Video writeup
Introduction
So we got a blockchain challenge with solidity smart contracts. One, DinoPark is used as the contract to check for the solve with isSolve() function and also deploy another useful contract : Arena where the real things happen.
Here the infra will call isSolve() to know if goldenEggHolder is the same address of msg.sender, the address calling isSolved, but we don’t know with which address the infra call it, spoiler: it’s our player address.
goldenEggHolder
To solve, goldenEggHolder is to be set to the address of msg.sender which his either directly our wallet (or EOA, externally owned address) or a contract.
spoiler (msg.sender need to be our EOA otherwise it won’t solve)
So we need to redefine goldenEggHolder at some point. Let’s see where we can find this in the contract.
By looking for the string in the contract we see that the default value of goldenEggHolder is the address of the contract.
claimGoldenEgg
And the way to set goldenEggHolder is to call claimGoldenEgg:
1
2
3
4
5
functionclaimGoldenEgg() external {
require(arena.isChampion(msg.sender), "DinoPark: Not a champion!");
require(address(arena).balance<=1wei, "DinoPark: Arena still has funds!");
goldenEggHolder =msg.sender;
}
which :
call the isChampion() that checks if the value of isChampion is msg.sender (our address).
check that the balance of the arena is under 1 wei, so basically empty
and check that we claimed the egg
So we need to see on the arena how to become champion and find a way to empty the arena contract balance.
If we chose the mode ranked, raptor got 100 more of strength.
Then a value roll is chosen based on the timestamp and prevrandao, value readable on the blockchain and take the value modulo 100.
Then the roll is added to player strength and a last check is made.
The function return True if :
We correctly guess the roll value.
And our total strength is above the raptor strength.
One should know that :
1
2
PLAYER_STRENGTH = 470;
parkRaptorStrength = 500;
We need at least a roll of 31 to get over the raptor strength. And if we chose the mode ranked
, the raptor got to 600 which we can’t beat because the max roll value is 99.
The contract exit if we the arena balance is under 0.6 ether.
Whis is at 0.5 ether at the start, here from the DinoPark contract which sends 0.5 ether at the creation :
Every other functions are just rabbit holes and baits that did not work on me because I stand on bait proof.
How to solve in a nutshell
Here’s is a drawing of the path to solve :
Let’s solve then.
Solve
SO the step are :
fund arena with some ether so we can fight
call fight by trying to get the correct guess and be champion with our address
find a way to empty the arena balance
claim the goldern egg
Fund arena
There are multiple to fund an address on th ethereum, for example :
Deploying a contract with some ether so the deployed gets it in its balance
Send it to a function with the modifier payable state
If the address is a wallet (or EOA), just make a transaction with ether in msg.value variable
In our case none of this works because the arena, is already deployed, doesn’t have any payable function, and is not a wallet.
So the author wrote the contract so the arena “refuse” funds.
BUT, there is an old trick to force a contract to receive fund which selfdestructing, which send all the remaining ethers of a contract to the address we want.
Then we write a contract, give it the ethers we want and send call selfdestruct to make it sends the values :
In the fight function we got to submit a guess and a fight mode.
First the fight mode is either normal or ranked and we saw earlier that ranked is impossible to win.
Here are the fight mode :
1
enum FightMode { Normal, Ranked }
An enum in solidity is just a list of uint8 that are called through the variable name.
So Normal is accessed like FIghtMode.Normal and as it’s the first item, it’s value is 0.
Next we need to guess correctly so that the validation function returns true :
1
return (guess == roll) && (totalPlayer > raptor);
And at the same time be above of raptor strength which is 500 and ours 470. So we need 31 <= guess 100.
Roll is picked on value that are guessable by writing the contract because there are read from the block of transaction; timestamp and prevrandao
1
uint8 roll =uint8(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) %100);
If we call fight(), we make a transaction, and this transaction will be listed on the block. If in fight(), block value are called, it will take value from this block so it’s totally readable and not random.
But this tricks works when using a contract. To solve we need to directly use our EOA, so we can’t guess anything, we have to do STRAIGHT gambling with a odds of 1% for having the correct number at each call.
Someting I omitted is that the blockchain for challenge have blocks being mined every 10 seconds, so at every time I make a single transaction, I have to wait 10 seconds.
Ain’t no way I’m waiting this long every time.
So I made a first bash script that call fight with the same number and wait 10 seconds and call again :
first_script.sh
1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
source .env
for i in {1..100}doecho"Fight #$i" cast send --rpc-url $RPC $ARENA --private-key $PKEY "fight(uint8,uint8)"520echo"champion : $(cast call --rpc-url $RPC $ARENA 'isChampion(address)(bool)' $ADDRESS)" sleep 10done
But from time to time a request a request was crashing so I thought I just messed up my script (I’m naive).
Then my teammate got a great idea which was, making many call of fight() at the same block so there should be one that will be working.
It didn’t work either.
After CTF, by debugging we saw that the only transaction crashing was the one with the winning number, so that’s how work casinos …
Finally, I remembered that we can script with foundry and I never managed to make it work.
And it is actually goated with all the things called cheatcodes which allow us to read environment variables to make it easier, use console.log() to print values and many other utilities.
Solidity scripting work by appending all the transactions it will send to a list, and then push this list on the blockchain.
So the for loop is gonna generate 68 transactions.
If the number is above 30, I will be winner.
But I’ve got another issue, AGAIN, one transaction failed everytime. Just one.
By debuggin in local reading this github issue, I found that it was a gas error, but weird because I actually set a gas-limit to almost 1 ETH, more than enough.
Actually the problem is not that, it’s the gas allowed per transaction, the estimation was around 30000 wei for the fight() so those transactions can not use more than that. But when I succeed, the isChampion() mapping is altered, raising the cost of the gas to around 50000 which will make the transaction fails.
So the fix is just to increase the allowed ammount of gas for this call like that :
1
2
3
for ( uint8 i =31; i<100; i++){
arena.fight{gas:100000}(i, Arena.FightMode.Normal);
}
We run the forge script command and become a champion :
The strategy of this one is to look at the ways to get ethers.
There is the drainToChampion() function it require us to be the parkContract which is not possible here, so it’s a bait.
It takes the address of the msg.sender, in this context it is the address of the fighter which is an EOA or smart contract.
Then, the function "" is called with PRIZE_MONEY (0.1 ether) in value.
To wrap up, it calls a function with no name and send 0.1 ether to us if we win.
We can think to just call fight() once more to get more ethers, but there is a kind of security that prevent us to t do that :
At the start of fight(), if isFighting[msg.sender] is set to false, prepareArena will be called that just check if the arean has 0.6 ether or more.
Then isFighting[msg.sender] is set to true and at the end of the fight, it is sets back to false.
But arena calls an empty function when sending us ethers after winning, and in solidity, calling a function with no name is like calling a non-existent function.
So, in this case, the fallback() function is called on the destination contract.
If a fallback() function is defined, the instruction in the latter are executed, else the whole transaction is reverted. See this.
Here is the process of sending ethers :
send Ether
|
msg.data is empty?
/ \
yes no
| |
receive() exists? fallback()
/ \
yes no
| |
receive() fallback()
So when we win the fight, arena will send us ethers and call the fallback() function. We can define in the fallback function to call fight() again until there are no more ethers in the arena, and if we win again, the arena will triger fallback once more which will call fight() one more time and so on.