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
SEEPassvia theSetupcontract. - We don’t know yet what is the
rootvalue.
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
codeNamegeneration. - Call
flyAwayto 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
flyAwayand steal the ether. - Call
promotion. We can still dopromotionbecause the check inpromotionis 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
becomeAPigeonto make the player as ajuniorPigeon.- Set the
codetoNumbuand thenametoh5, 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 theSetupcontract).
- Set the
- Do
tasktwice by setting thepersonparameter to our dummy contract.- Doing twice will make our
taskPointsto 8 ether, which is equivalent to thejuniorPromotionthreshold.
- Doing twice will make our
- Call
flyaway- We get 5 ether from this.
- Call
promotion- Set the
codetoNumbuand thenametoh3, 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 theSetupcontract).
- Set the
- Now, transfer extra 2 ether to our dummy contract, to make the balance become 6 ether.
- Do
tasktwice by setting thepersonparameter to our dummy contract.- Doing twice will make our
taskPointsto 12 ether, which is equivalent to theassociatePromotionthreshold.
- Doing twice will make our
- Call
flyaway- We get 10 ether from this.
- Call
promotion- Set the
codetoNumbuand thenametoh1, 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 theSetupcontract).
- 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
depositx ether. - Contract call
withdrawAll. - The
PETHcontract 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:
PETHcoin’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
pigeonDiamondhas 1 millionIERC20token (newly minted). - We can call
claimto get some of the newly minted token from theSetupcontract.
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:
DiamondCutFacetDiamondLoupeFacetOwnershipFacetFTCFacetDAOFacetPigeonVaultFacet
How the diamond pattern works in simplified version is that:
- A diamond store a mapping which will map the function selectors with the correct
facetaddress. - Everytime we call a function in the diamond, the diamond will try to route the call to the correct
facetaddress based on the called function selector. - With the help of
IDiamondCutinterface, 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
diamondCutto 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
Setupcontract, we can actually callclaimmultiple times because they forgot to set theclaimedtotrueduring 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
isUserGovernancemethod, 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
castVoteBySigmethod, we noticed that:- The call
getPriorVoteswas using themsg.senderinstead of thesigneraddress. - This means that one user can cast multiple votes as long as they can provide any
signaturewhich 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.numberby 1 per10seconds. - Because the
proposal.endBlockisproposal.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.forVotesis required to be more thanproposal.againstVotesand more than(s.totalSupply / 10), which is around 100k votes.- This can easily achieved by two bugs that we found. Either:
- Call
claimmultiple times so that our user can directly callcastVoteBySigwith votes larger than 100k. - Or, we can also call
castVoteBySigmultiple times with different signature, because thevotesvalue that was fetched was themsg.senderbalance 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