SEETF 2023
Lately, I’ve been diving into learning smart contracts, and I stumbled upon the SEETF 2023 challenges this weekend. It turns out there are four cool challenges specifically focused on smart contracts. Despite having a hectic weekend, I decided to carve out some time to give them a shot. Fortunately, I managed to solve all of it. Here is my writeup for the challenges.
Smart Contracts
Murky
The SEE team has a list of special NFTs that are only allowed to be minted. Find out which one its allowed!
nc win.the.seetf.sg 8546
Initial Analysis
In this challenge, we were given three solidity files. Let’s check it first.
MerkleProof.sol
|
|
This file contains a library called MerkleProof
, which can be called to verify whether the given index
is the leaf of the merkle tree based on the given proof
.
SEEPass.sol
|
|
This contract is trying to implement their own NFT based on the ERC721
structure. There is a function called mintSeePass
that can be used to mint a new NFT. However, before we can mint a new NFT, we need to be able to provide unused tokenId
and the correct proof
so that we can pass the MerkleProof
verification.
Setup.sol
|
|
This is the setup contract. As you can see, to solve this challenge, the goal is to make the msg.sender
(which is us) has at least one NFT.
Solution
Based on the above initial analysis, we know that the goal is we need to be able to call mintSeePass
. However, we need to pass the MerkleProof
verification first.
After looking at the SEEPass
contract and MerkleProof
library, I noticed some interesting thing. Let’s re-visit the verify
code.
|
|
If we set an empty proof
and set the index
as the uint256
representation of the given root
, then we won’t reach the loop. Because of that, the code will compare computedHash == root
, which is true
in this case because the computedHash
is obviously the same as the root
.
Checking through the SEEPass
and Setup
contract, we can safely use the uint256
of root
as tokenId
because it isn’t used yet.
Now that we know how to solve this challenge, let’s try to interact with the given host and port to check how to interact with the challenge.
|
|
Okay, so with the given url and port, we can launch a new instance. Some informations that we can get:
- The private
rpc_endpoint
. - The private key of the player.
- The setup contract address.
However, up until now:
- We don’t know yet the address of the deployed
SEEPass
via theSetup
contract. - We don’t know yet what is the
root
value.
Let’s re-visit the Setup
contract.
|
|
To get the SEEPass
address, because it is a public
variable, by default, there will be a getter for it. We just need to call pass()
to get the address.
Now that we know how to get the SEEPass
address, we need to know how to fetch the _merkleRoot
stored in it. Let’s re-visit the SEEPass
contract.
|
|
The _merkleRoot
was set as private variable, so there won’t be a getter for it. However, nothing is private in a smart contract. Even though it’s private, we can still fetch the value by directly accessing the storage
of the contract. Trying to iterate the storage
slot one by one, I found that the _merkleRoot
was stored in slot 6.
After we know the _merkleRoot
value, we can simply call the mintSeePass()
function with empty proof
and _merkleRoot
as the tokenId
(convert it to uint256
). I used python web3 library to interact with the challenge.
Full Script
|
|
abi
, I manually compiled the contract with solc
in my local + init a foundry
project to handle the contract dependencies to open-zeppelin
.Flag: SEE{w3lc0me_t0_dA_NFT_w0rld_w1th_SE3pAs5_f3a794cf4f4dd14f9cc7f6a25f61e232}
Fiasco
In the dystopian digital landscape of the near future, a cunning mastermind has kickstarted his plan for ultimate dominance by creating an army of robotic pigeons. These pigeons, six in the beginning, are given a sinister mission: to spy on the public, their focus being on individuals amassing significant Ethereum (ETH) holdings.
Each pigeon has been tasked with documenting the ETH each person owns, planning for a future operation to swoop in and siphon off these digital assets. The robotic pigeons, however, are not just spies, but also consumers. They are provided with ETH by their creator to cover their operational expenses, making the network of spy birds self-sustaining and increasingly dangerous.
The army operates on a merit-based system, where the pigeon agents earn points for their successful missions. These points pave their path towards promotion, allowing them to ascend the ranks of the robotic army. But, the journey up isn’t free. They must return the earned ETH back to their master for their promotion.
Despite the regimented system, the robotic pigeons have a choice. They can choose to desert the army at any point, taking with them the ETH they’ve earned. Will they remain loyal, or will they break free?
Initial Analysis
In this challenge, we were given two solidity files. Let’s check it first.
Pigeon.sol
|
|
This is the main contract that we will interact with. Skimming through it, it seems that the contract is trying to simulate how job promotion works. Basically, a user can be set as a pigeon, and then it can do some basic tasks to get points, which can be used to get promotion. Later, we will check this contract again.
Setup.sol
|
|
This is the setup contract. It deploy the Pigeon
contract, and then assign some pigeons with different ranks, code, and name. Some of the pigeons were created with some ethers to initialize their treasury
value.
To solve the challenge, the user’s balance need to be >= 34 ether, and we need to drain the pigeon
balance.
Solution
First, in order to interact further with the Pigeon
contract, we need to be a pigeon first. To do that, we can call the becomeAPigeon
. Let’s check the code first to see whether there is a bug or not.
|
|
There is a bug with the codeName
generation. Notice that to generate the codeName
, we basically do hash(code+name)
. However, this means that we can easily create hash collision to overwrite the existing juniorPigeon
mapping. For example, suppose that we want to impersonate junior pigeon ("Numbuh", "5")
(which is one of the pigeon that was infused with ether
during the creation). We can simply set our code
to "Numbu"
and our name
to "h5"
. The hash result will be the same as ("Numbuh", "5")
. By doing this, we can overwrite the stored address of the juniorPigeon
easily.
Now, remember that the goal is to drain the balance of pigeon
and transfer all of it to us. Notice that there is an interesting function called flyAway
.
|
|
If we call flyAway
, the address of the pigeon will get treasury[codeName]
of ether. Remember that we can easily overwrite the address of any pigeon with our address, so to steal the ether, we can simply:
- Overwrite the pigeon address with our address via the hash collision in
codeName
generation. - Call
flyAway
to steal the ether.
Our user current rank is still juniorPigeon
, and in order to drain the whole pigeon
ether, we need to do promotion
as well. Luckily, the promotion
allowed us to set a new codeName
, and the codeName
generation is still prone to hash collision. So, on each promotion, we can simply set the codeName
to collide with the created pigeon
which has ether.
To get promotion
, we need to do task
. However, if we do task
, if our taskPoints
larger than the threshold set for the promotion, we won’t be able to flyAway
. Luckily, there is another bug here. Compare the checks those are used in the flyAway
and promotion
.
|
|
Notice that, if the taskPoints
are equals to the promotion’s threshold, we actually can:
- Call
flyAway
and steal the ether. - Call
promotion
. We can still dopromotion
because the check inpromotion
is allowing equalstaskPoints
.
So, the goal is very clear, we need to do tasks until the points are equal to the given threshold, call flyAway
(to steal the ether), and then call promotion
so that we can start to steal ether from the higher rank pigeon.
To make it easier on controlling the points that we get during calling task
, we can simply deploy a dummy contract and then set its balance to crafted value so that the taskPoints
can be easily set to be equivalent with the threshold.
My solution full flow is like below:
- Create a dummy contract, initialize it with 4 ether
- Call
becomeAPigeon
to make the player as ajuniorPigeon
.- Set the
code
toNumbu
and thename
toh5
, so that we will overwrite the stored address ofjuniorPigeon[Numbuh5]
with our address, and later steal all of its ether (which is 5 ether based on theSetup
contract).
- Set the
- Do
task
twice by setting theperson
parameter to our dummy contract.- Doing twice will make our
taskPoints
to 8 ether, which is equivalent to thejuniorPromotion
threshold.
- Doing twice will make our
- Call
flyaway
- We get 5 ether from this.
- Call
promotion
- Set the
code
toNumbu
and thename
toh3
, so that we will overwrite the stored address ofassociatePigeon[Numbuh3]
with our address, and later steal all of its ether (which is 10 ether based on theSetup
contract).
- Set the
- Now, transfer extra 2 ether to our dummy contract, to make the balance become 6 ether.
- Do
task
twice by setting theperson
parameter to our dummy contract.- Doing twice will make our
taskPoints
to 12 ether, which is equivalent to theassociatePromotion
threshold.
- Doing twice will make our
- Call
flyaway
- We get 10 ether from this.
- Call
promotion
- Set the
code
toNumbu
and thename
toh1
, so that we will overwrite the stored address ofseniorPigeon[Numbuh1]
with our address, and later steal all of its ether (which is 15 ether based on theSetup
contract).
- Set the
- Call
flyaway
- We get 15 ether from this.
- Take back the ether from our dummy contract back to us, so that we will have balance more than 34 ether.
Full Script
Dummy Contract
|
|
Solver
|
|
Flag: SEE{c00_c00_5py_squ4d_1n_act10n_9fbd82843dced19ebb7ee530b540bf93}
Pigeon Bank
Initial Analysis
In this challenge, we were given three solidity files. Let’s check it.
PETH.sol
|
|
This contract is trying to implement a coin. Skimming through it, one thing that is interesting is that it has flashLoan
feature. Another thing is that some functions can only be called by the owner of the contract.
PigeonBank.sol
|
|
This contract is the bank, which is actually the owner of the PETH
contract (because this contract is the one who deploy the PETH
contract in constructor). One thing that is interesting is that all of the functions are set to nonReentrant
, which used to prevent reentrancy attack.
Setup.sol
|
|
This is the setup contract, and to solve the challenge, we need to drain the PETH
contract and make our msg.sender
balance to be >= 2500 ether.
Solution
First, let’s check the flashLoan
feature.
|
|
I noticed that we can actually make the PETH
contract as the _userAddress
param, and then set the _wad
value to 0
. By doing this, we can actually make the PETH
contract to call one of their own function (via the Address.functionCallWithValue
). Looking for the functions that doesn’t have onlyOwner
modifier, there is one interesting function, which is approve
.
|
|
The idea is that if we call flashLoan
and set the _userAddress
to PETH
contract itself, and then set the calldata
to call approve()
, we can actually force the PETH
to set any allowance to our desired address, so that the desired address can spend the PETH
money.
However, notice that PETH
doesn’t have any money actually. So, we need to look for another bug.
The other bug is actually a reentrancy bug inside the withdrawAll
method.
|
|
There’s a problem with this method. It sends the ether to the user before burning the PETH
coin. This is vulnerable to reentrancy attack. The scenario is like below:
- Contract call
deposit
x ether. - Contract call
withdrawAll
. - The
PETH
contract send the ether first. - The contract has
receive()
method, which will be triggered during receiving value that was being sent byPETH
. - The contract call
transfer()
to send the just received value toPETH
. - Now, after this call, the state will be:
PETH
coin’s balance is increase by x ether.- Yet, the contract still received x ether.
Repeating the above scenario, at some point, the PETH
coin’s balance will become 2500 ether. And because of the flashLoan
that we call before already permit us to spend the PETH
coin’s balance, we can simply transfer all of PETH
coin’s balance to us and withdraw it to drain the bank.
Full Script
Attacker Contract
|
|
Solver
|
|
Flag: SEE{N0t_4n0th3r_r33ntr4ncY_4tt4ck_abb0acf50139ba1e468f363f96bc5a24}
Pigeon Vault
rainbowpigeon has just received a massive payout from his secret business, and he now wants to create a secure vault to store his cryptocurrency assets. To achieve this, he developed PigeonVault, and being a smart guy, he made provisions for upgrading the contract in case he detects any vulnerability in the system.
Find out a way to steal his funds before he discovers any flaws in his implementation.
Blockchain has a block time of 10: https://book.getfoundry.sh/reference/anvil/
Initial Analysis
In this challenge, we were given a lot of solidity files. After skimming through some of the code, I observed that the code is trying to follow the diamonds multi facets pattern. This link explained the concept pretty well. Let’s try to check some of the important file.
Setup.sol
|
|
This is the setup contract. In order to solve the challenge, we need to become the owner
of the pigeonDiamond
contract, and have ether more than 3000 ether. Some initial state:
- The
pigeonDiamond
has 1 millionIERC20
token (newly minted). - We can call
claim
to get some of the newly minted token from theSetup
contract.
Diving more through the setup contract, we observed that what the setup code trying to do is:
- Create a diamond called
pigeonDiamond
- Initialize it with some facets. List of the facets:
DiamondCutFacet
DiamondLoupeFacet
OwnershipFacet
FTCFacet
DAOFacet
PigeonVaultFacet
How the diamond pattern works in simplified version is that:
- A diamond store a mapping which will map the function selectors with the correct
facet
address. - Everytime we call a function in the diamond, the diamond will try to route the call to the correct
facet
address based on the called function selector. - With the help of
IDiamondCut
interface, we can easily add/replace/remove this selectors. For example, if we want to replace an existing selectors, than what we need to do:- Deploy a new facet with the same selector
- Call
diamondCut
to upgdate the selectors mapping.
Let’s try to check the available facets.
OwnershipFacet.sol
|
|
There is a function called transferOwnership
, however for now, we can only call it if we’re the owner
.
PigeonVault.sol
|
|
There is a function called emergencyWithdraw
, that will allow us to drain the diamond’s ether. However, once again, we can only call it if we’re the owner
.
DAOFacet.sol
|
|
This facet is very interesting. We can see that the function executeProposal
can be used to upgrade the diamond
if we collect enough votes for the upgrade proposal. We can use submitProposal
to submit the upgrade proposal. Another thing is that we can castVote
as well whether we agree with the proposal or not, and the value of our votes was based on the number of votes
that was calculated by the FTC
token.
Let’s check the FTCFacet
to understand more how the votes
works.
FTCFacet.sol
|
|
Skimming through the code, this is the token that is being used to calculate the votes
during processing proposal. Basically, we can call delegate
to set up the value of votes
that is used during processing the proposal, where the value is equivalent with the number of FTC
token that we have.
Now that we’ve skimmed through the code, let’s try to think on how to solve the challenge.
Solution
I noticed that there are some interesting observations that I found:
- In the
Setup
contract, we can actually callclaim
multiple times because they forgot to set theclaimed
totrue
during calling it.- That means, our user can have all the minted token by
Setup
.
- That means, our user can have all the minted token by
- Looking at the
isUserGovernance
method, the simplified logic is basically just trying to cheeck whethertotalSupply >= 100
, which is alwaystrue
.- This function is called to decide whether user can submit a proposal or not.
- Because it always return
true
, that means user can always submit a proposal
- Looking at the
castVoteBySig
method, we noticed that:- The call
getPriorVotes
was using themsg.sender
instead of thesigner
address. - This means that one user can cast multiple votes as long as they can provide any
signature
which is recovered to non-null address.
- The call
Checking through the executeProposal
code, the requirement to execute the proposal is:
- Proposal isn’t executed yet (A proposal can only be executed once).
block.number >= proposal.endBlock
- Looking at the challenge description, they use foundry, which will increased the
block.number
by 1 per10
seconds. - Because the
proposal.endBlock
isproposal.startBlock + 6
, that means we need to wait one minute before calling theexecuteProposal
.
- Looking at the challenge description, they use foundry, which will increased the
- The
proposal.forVotes
is required to be more thanproposal.againstVotes
and more than(s.totalSupply / 10)
, which is around 100k votes.- This can easily achieved by two bugs that we found. Either:
- Call
claim
multiple times so that our user can directly callcastVoteBySig
with votes larger than 100k. - Or, we can also call
castVoteBySig
multiple times with different signature, because thevotes
value that was fetched was themsg.sender
balance instead of thesigner
.
- Call
- This can easily achieved by two bugs that we found. Either:
I found that the multiple claim
calls are easier, so I decided to go with that.
Now that we know we can execute an upgrade proposal, let’s think on what we should do with it.
Because the goal is to be the owner
of the pigeonDiamond
, we can simply deploy our FakeOwnershipFacet
in the proposal. The FakeOwnershipFacet
will replace the transferOwnership
method so that it can be called wihout being the current owner
of the diamond.
After upgrading it, we can simply call the emergencyWithdraw
to satisfy the required ether balance to solve the challenge.
Full Script
Attacker.sol
|
|
Solver
|
|
Flag: SEE{D14m0nd5_st0rAg3_4nd_P1g30nS_d0n’t_g0_w311_t0G37h3r_B1lnG_bl1ng_bed2cbc16cbfca78f6e7d73ae2ac987f}
Social Media
Follow me on twitter