Kiln’s solutions to Paradigm CTF
Loïc Titren
Loïc Titren
Engineering
August 23, 2022

Kiln’s solutions to Paradigm CTF

This weekend 3 Kilners participated in the 2022 edition of the Paradigm Capture The Flag (CTF), one of the best Web3 security competition out there. We managed to come in at the 33rd place out of more than 400 registered teams!

This post is a writeup of the solutions to some of the Ethereum and Starknet challenges.

Challenges are available at ctf.paradigm.xyz and scoreboard at https://ctf.paradigm.xyz/score.

RANDOM

This challenge was an introductory challenge to kick off the CTF. Two contracts were deployed, Setup.sol and Random.sol. The challenge gave us the address of the Setup contract.

Random.sol is quite simple, the _getRandomNumber() always returns 4. The solve() function takes a number as a parameter, if it’s equal to 4, the public solved variable is set to true.

Setup.sol simply creates an instance of the Random contract (which is deployed at another address to the Setup’s) and provides an isSolved() which returns true when the solved variable of the Random contract comes back as true.

Solution using cast (foundry):

  1. Send a Transaction to get the random public variable value (address of the Random contract)

       2. Get the value returned in the transaction

       3. Call the function solve with parameter 4

Setup.isSolved() will now return true!

Flag: PCTF{IT5_C7F_71M3}

RIDDLE-OF-THE-SPHINX (Starknet)

This Starknet challenge comes with a deployed Cairo contract which has an unauthenticated setter (solve()) and getter (solution()) on a _solution storage variable.

The given Python script responsible for checking if the challenge is validated or not calls the getter of the contract. It then checks if the value returned is equal to man encoded on 4 bytes (cf riddle of the Sphinx).

Solution using starknet.py:

Flag: PCTF{600D_1UCK_H4V3_FUN}

CAIRO-PROXY (starknet)

This challenge comes with a deployed Cairo contract which acts as a proxy for an ERC20 contract.

The contract exposes an implementation storage variable which contains a class_hash (basically the id of a Cairo contract), which is set to a default value of the ERC20 contract deployed before this contract.

Once the proxy contract is called, it calls the matching function in the contract referenced by the implementation storage variable.

Some utilities are also provided, but they have an authentication issue: they take an auth_account as a/the parameter and check if the caller is the same as this parameter to read or write any storage variable of the contract. This function checks the owner storage variable instead.

The checker function that validates the challenge then calls the balanceOf function on the proxy contract and checks if the competitor’s user balance matches 50000e18. Note that as it is a proxy, the balance will be checked in the proxy’s memory and not the ERC20 contract memory, using the initialize ERC20 method won’t work.

Solution:

  1. Deploy a modified version of ERC20 with a balanceOf() that always returns 50000e18
  1. Modify the implementation storage value to point to our ERC20 contract

Flag: PCTF{d3f4u17_pu811c_5721k35_4941n}

TRAPDOOR

In this challenge, a server asks for some EVM bytecode that would then get deployed. A call would then be made to the contract with one uint256 value, and is expected to factorize this value from the code.

Simple, right?

Well, not that easy when we you consider that the provided number is a factor of two 128-bit prime numbers and we were tasked with factoring a semiprime!

Now let’s have a look at what the verifying contract looked like:

The server would then replace the strings NUMBER with the semiprime and the CODE with the provided bytecode. It would then run the following script using foundry. If you don’t know foundry yet, maybe it’s time for you to jump in and start using it. It has so many interesting features that will help you navigate the EVM.


Features like reading values from environment variables. Useful, right?

Yes, the flag is held in an environment variable on the server side! (Fun fact: it wasn’t meant to be accessible in this way so it was kind of cheating. The real solution was to inject code that would run when the console is called to print a lot of your factored numbers! lines until out of gas)

So, this is the contract we compiled and gave to the server.

And this is the line that the server logged back to us:

Now, we simply need to decode these decimal values back to utf-8 and we get: PCTF{d0n7_y0u_10v3_f1nd1n9_0d4y5_1n_4_c7f}!

RESCUE

In this challenge, a user sent 10 WETH to a smart contract, we had to get them out of the contract in order to retrieve the flag. Our user had 5,000 ETH available.

Setup.sol contract instantiated a MasterChefHelper contract and sent it 10 WETH by default. The isSolved() function checked if we had managed to get the MasterChefHelper contract to 0.

MasterChefHelper.sol contract exposed a swapTokenForPoolToken() function which took in a Pool Id and an ERC20 token amount. It then transferred the ERC20 from the caller to itself and swapped half of it to the Token A portion of the pool, and the other to the Token B portion of the pool.

Finally, it sent all the balances of Token A and B to the pool. This is super interesting for us, it means that if one of token A or B is WETH then the 10 WETH lost on the contract would be added into the pool and leave the MasterChefHelper balance, which is exactly what we wanted!

Solution:

The goal here is to make sure the MasterChefHelper contract sends all its WETH balance to the pool. The problem is that UniswapV2 tries to conserve a certain equilibrium between the amounts of token A and token B.

For example, let’s assume the masterchef pool with id 0 is WETH/USDT. If we try to select this pool and send 40 USDC as an ERC20 amount for the swapTokenForPoolToken() function, the contract wouldn’t be able to deposit 20 USDC equivalent in USDT and 20 USDC in WETH with the 10 WETH already on the balance. Otherwise it would break the equilibrium of the added liquidity.

Our solution is to create the $KILN ERC20 token and two Uniswap pools for this token (which match the WETH/USDT masterchef pool):

  1. KILN <> WETH with 2000 ERC20 unit tokens each
  2. KILN <> USDT with 500 ether (<>5001e18*) tokens
  3. Deposit 10 ether (<>101e18*) worth of KILN tokens with the swapTokenForPoolToken() function

This way, Uniswap will accept a larger amount of WETH than USDT during deposits as there is a huge gap between WETH and USDT availability for the KILN token.

We made a contract to perform those 3 steps and called the function solve() with 600 Ether.

Flag: PCTF{MuCH_4PPr3C1473_53r}

MERKLEDROP

This challenge deployed a MerkleDrop contract that held 75,000 units of an ERC20 token. With it came a file with all the proofs from the Merkle tree. The proofs were legit and allowed users to redeem their share of tokens.

Now, when we looked at the isSolved() method, we clearly saw 2 conditions required to fulfill this challenge. First we needed to empty the contract of the ERC20 tokens. Simple! We had all the proofs, the second condition was the tough part: we weren’t allowed to use all the proofs. Using all of them but one would work.


Our first idea was to compute the sum of all the 64 proofs to make sure there weren't some combinations of 63 proofs or less that could get all the tokens. Unfortunately, the sum was 75,000.

So, let’s dive into this MerkleDistributor contract!

Nothing fancy here. The contract was using some bitwise operators to lower its gas cost when tracking proof submissions. We could also have a look at the verifier.

Now, let’s focus back on our goal. We needed some way to lay claim to an invalid amount or index in order not to use all the proofs available. When submitting a proof, this was the flow from input to transfer (each temporary var is kept so we could explain the resolution process).

Then it hit us: why is the amount using an uint96? This is pretty odd for Solidity where we’re used to moving around tokens with high decimal counts. But this was it. The input for the claim method was 64 bytes long (uint256 index is 32, address account is 20 and uint96 amount is 12), and it’s exactly the same length as two leafs.

It became clear: the goal was to short-circuit the verification chain by providing two leafs encoded as the 3 parameters and provide the subsequent proofs to get to the merkle root. But it still wasn’t completely solved. We could input any value greater than 64 to the index and we would solve that part but the amount had to be lower or equal to 75,000 or else the transfer would fail.

We took all the proofs and examined them carefully (with a script of course). And then we found a suspicious one:

This proof was the only one passing the following assertion.

And with a quick lookup, we found this other proof that was pretty convenient for our situation, because as you could see, it was exactly the remaining amount we would have had we used our exploit with the proof above.

We now needed to execute the exploit: 

  • Short-circuit proof 37 and submit proof 8 
  • Drain the whole contract by depositing 2 proofs (only one in a legit manner)

 

This is the flow of execution compared to a “legit” run. 

 

Note: Leaf1 is actually computed where the node was previously computed.

 

We basically did one step of the verification ahead of the contract!


Putting everything together.

Last step, we used forge and its amazing scripting capabilities.

VANITY

This challenge was a puzzle. We needed to solve the puzzle by calling solve(address,bytes). This was the Challenge contract:

As you can see, to solve the challenge we needed to receive a score of at least 16. When we looked at how the score was computed, we could see that it registered 0 bytes in the address provided to solve, and an address only had 20 bytes. So how could we get to an address with 16 null bytes?

Generating one wasn’t an option as we didn’t have thousands of years to spend on it.

Our last hope was that the verification of the signature would be flawed allowing us to input an address that wasn’t owned by us.

As expected, something odd was happening when the ECDSA signature verification failed. It called the signer address, expecting a result with a length equal to 32 and starting with the same 4 bytes (selector) as the method called (isValidSignature).

Ok! So now we needed a contract that had an isValidSignature (bytes32 hash, bytes calldata signature) method. This would have meant facing the same issue as above, we could predict the address of a contract but we didn’t have the power to generate such an exotic one.

Now there’s something you need to understand about the EVM. Some addresses are reserved for pre-compiles. Basically, they hold the ability to perform computation outside of the EVM and return the result, you don’t need to implement complex algorithms like cryptographic primitives in pure Solidity. And there’s a very well known pre-compile that can be found at address(2), keccak256.

We needed to find the proper data to provide a signature to the solve method in order for keccak256(IERC1271.isValidSignature.selector, hash, signature) to start with IERC1271.isValidSignature.selector!

With a simple golang script we could brute-force that signature value.

And we got as an output (after 7.5 seconds)

And we now had our signature value!


0x00000000000000000000000000000000000000000000000000000000cc412f04

One final transaction and we can retrieved the flag

We now received a best score of 19 and another flag PCTF{D0N7_F0r637_480U7_Pr3C0MP1135}!


Thanks to @m_rtimr and @pwnh4 for these detailed solutions.

We enjoyed a really fun weekend with @m_rtimr and @0xpanoramix, thanks @paradigm_ctf!

About Kiln

Kiln is the leading enterprise-grade staking platform, enabling institutional customers to stake assets, and to whitelabel staking functionality into their offering. Our platform is API-first and enables fully automated validator, rewards, and commission management.

We are proud to be long-term operators on the Ethereum Beacon chain, running over 8,500 validators on Mainnet and actively participating in #TestingTheMerge.

Want to work with us? We're hiring.

Subscribe to our Newsletter
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.