Paradigm CTF 2022 Writeup

สำหรับเวอร์ชั่นภาษาไทย สามารถอ่านได้ที่: https://inspexco.medium.com/th-paradigm-ctf-2022-writeup-ae7b6a86fcec

Inspex team members participated in Paradigm CTF 2022 on the _C0FFEE team and ranked 15th from a total of 445 teams with a total score of 2,465.1.

There were different types of smart contract challenges, including EVM, Solana, and Cairo, and we were having a great time solving these fun and challenging challenges.

We have solved a total of 11 challenges as follows:

EVM

RANDOM
Rescue
SOURCECODE
Trapdooor
Merkledrop

Solana

OTTER-WORLD
SOLHANA-1
SOLHANA-2 (Solved Locally)

Cairo

RIDDLE-OF-THE-SPHINX
CAIRO-PROXY
CAIRO-AUCTION

On this CTF, Paradigm’s foundry was used extensively. We used it to test our exploits, trace transactions, deploy exploit contracts, call them, and send transactions.

Here’s our write-up for the challenges we have solved.

EVM

RANDOM

CHALLENGE: RANDOM
AUTHOR: Riley Holterhus
TAGS: SANITY CHECK
DESCRIPTION: I’m thinking of a number between 4 and 4
ACCESS: nc 34.66.135.107 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/random.zip

In this challenge, two Solidity contracts are given in the zip file as shown below:

This challenge is a simple sanity-check for EVM. What you need to do is to call the solve() function with 4 as the parameter to match the return value from the _getRandomNumber() function.

The instance can be started by accessing the server provided in the challenge.

The challenge server provided us with the RPC URL, private key, and the address of the Setup contract.

What we need to solve this challenge is the address of the Random contract.

We got the address from the Setup contract by calling random() function using cast as follows:

From the output, the Random contract is at the address 0x31a91af1e135324f344f96757703aa11ff214e51. Therefore, we called the solve() function as follows:

The transaction was successfully executed, so we accessed the server again to get the flag.

Flag: PCTF{IT5_C7F_71M3}

RESCUE

CHALLENGE: RESCUE
AUTHOR: Riley Holterhus
TAGS: PWN
DESCRIPTION: I accidentally sent some WETH to a contract, can you help me?
ACCESS: nc 34.123.187.206 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/rescue.zip

Three Solidity files are provided for this challenge.

From the Setup contract, we can see that 10 $WETH is transferred to the MasterChefHelper contract in the constructor. From the isSolved() function, our goal is to make the $WETH balance of the MasterChefHelper contract to be zero to solve this challenge.

The MasterChefHelper contract has one external function, swapTokenForPoolToken(), which takes a token, divides it in half, swap it into two tokens of the pool specified, and add liquidity using those tokens.

We can see that when the _addLiquidity() function is called, the whole balance of both tokens are used to calculate the LP amount to get. Therefore, if we can add liquidity using a pool with $WETH as one of the tokens, we can use the $WETH stuck in the contract to get the LP token.

However, as our initial token is swapped to two tokens in the pool, there are some conditions that we need to fulfill:

  1. One of the tokens in the pool must be $WETH in order for the token stuck to be added to the liquidity pool.
  2. The tokenIn must not be one of the tokens in the pool selected, because a token cannot be swapped to the same token via the router.
  3. There must be liquidity available for the swap from tokenIn to tokenOut0 and tokenIn to tokenOut1.

To find the tokenIn and poolId that we can use, we started a challenge instance and wrote a simple web3 script to get the data of each pools.

By running the script above, we got the following output:

We found that the token 0x5dbcF33D8c2E976c6b560249878e6F1491Bca25c can be swapped to $WETH (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2) through pool id 25, and can also be swapped to 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 through pool id 26.

We also found that pool id 21 contains both 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 and 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.

To fulfill all of the conditions mentioned, we can use 0x5dbcF33D8c2E976c6b560249878e6F1491Bca25c as the tokenIn, and 21 as the poolId.

To get all of $WETH out from the MasterChefHelper contract, we need the final before the calling of _addLiquidity() to have the same ratio with the liquidity pool, or have more of 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 to send all WETH to the liquidity pool.

We wrote a contract to help us perform the following actions:

  1. Deposit 11 $ETH to get 11 $WETH
  2. Buy 0x5dbcF33D8c2E976c6b560249878e6F1491Bca25c using 0.1 $WETH
  3. Buy 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 using 10.9 $WETH
  4. Send the bought 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 to MasterChefHelper contract to inflate the balance of the token
  5. Call MasterChefHelper.swapTokenForPoolToken()

The contract was deployed using forge with the following command:

We then sent 11 $ETH to the contract we deployed and execute the solve() function with the following commands:

After that, we connected to the server to get the flag.

Flag: PCTF{MuCH_4PPr3C1473_53r}

SOURCECODE

CHALLENGE: SOURCECODE
DESCRIPTION: Fixed point EVM bytecode
ACCESS: nc 34.136.156.228 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/sourcecode.zip

To solve this challenge, we need to craft a smart contract that return its own bytecode as an output without having any of the blacklisted opcodes as checked in the safe() function.

Our first thought was to craft an EVM bytecode to return the contract code.

However, the bytecode above does not work since the codesize (0x38) and codecopy (0x39) opcodes were filtered by safe() function.

We know that a program that returns itself as an output is called “quine”. We did a quick search and found quine.etk, a smart contract that returns its own source code.

Ref: https://gist.github.com/karmacoma-eth/220b58b7cd32d649fa1a15e70b6d8bff

However, the quine.etk is using the CALLVALUE opcode, which is restricted by the safe() function.

We applied the same idea and changed the callvalue instruction to other instruction, and pad the bytecode with the STOP instructions, which is 0x00 bytecodes.

Then we changed the PUSH16 instruction to PUSH32 followed by the bytecode of dupper.bc.

We then compiled the opcodes to get the bytecodes in hex format.

The challenge instance was started by connecting to the server.

We called the challenge() function to get the address of the challenge smart contract.

We then called the solve() function and used the bytecode from above as the parameter.

The transaction was executed successfully, this means that our bytecode works. So we connected to the server to get the flag:

Flag: PCTF{QUiNE_QuiNe_qU1n3}

TRAPDOOOR

CHALLENGE: TRAPDOOOR
AUTHOR: samczsun
TAGS: PWN
DESCRIPTION: In theoretical computer science and cryptography, a trapdoor function is a function that is easy to compute in one direction, yet difficult to compute in the opposite direction (finding its inverse) without special information, called the “trapdoor”.
ACCESS: nc 34.68.217.8 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/trapdooor.zip

For this challenge, you need to send your contract bytecode to the server to factorize a number into two primes correctly. Your bytecode will be replaced in the Script.sol contract, and the execution will be done using forge.

We know that forge contains cheatcodes (https://book.getfoundry.sh/forge/cheatcodes); therefore, we used the envString() cheatcode function to get the value of the FLAG environment variable. As we only see the number that results from our factorization as the output, we split the flag into two parts, and encode each part as bytes,

We then compiled our contract, and execute it to get the runtime bytecode.

We then connected to the server and sent the runtime bytecode we got.

Even when the result shows that we did not factor the number correctly, the two numbers from the output are actually parts of the flags we have retrieved and encoded. Python can then be used to decode the numbers we got.

Flag: PCTF{d0n7_y0u_10v3_f1nd1n9_0d4y5_1n_4_c7f}

MERKLEDROP

CHALLENGE: MERKLEDROP
AUTHOR: Riley Holterhus
TAGS: PWN
DESCRIPTION: Were you whitelisted?
ACCESS: nc 35.188.148.32 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/merkledrop.zip

This challenge is, basically, an airdrop distributor contract using Merkle tree to verify the claimer.

File structure
- contracts/Setup.sol This file has two contracts, Token and Setup. The Setup contract’s constructor deploys the MerkleDistributor contract and has the isSolved() function to check the problem’s solve status.
- contracts/MerkleDistributor.sol This is the contract that holds the airdrop reward. It has the claim() function, which the only non-view/pure function in the contract.
- contracts/MerkleProof.sol This is the MerkleProof library. it has only verify() function for verifying whether the node is in the Merkle tree.
- tree.json A list of addresses and their index, Merkle proofs, and claim amount.

To solve this challenge, you have to drain all of the airdrop reward from the contract without claiming every airdrop. What a contradiction.

In the MerkleProof contract, there is only one function for verifying the root and the node of Merkle tree. Everything seems normal.

In the MerkleDistributor contract, our star of this problem, the claim() function takes index, address, amount, and an array of merkleProof to claim the airdrop and transfers the reward to the supplied address parameter with the amount of amount parameter. We can pass the first condition of the problem by using every valid data from tree.json but we will be stuck on the second condition.

If we look closely on the the keccak256() functions on the MerkleDistributor contract and the MerkleProof contract, we can smell something fishy from here.

The challenge uses the abi.encodePacked() function on both MerkleDistributor and MerkleProof contracts. On the MerkleDistributor contract, it takes uint256(index), address(account), and uint96(amount) as arguments. The result from the function will be 32 bytes(uint256) + 20 bytes (address) + 12 bytes (uint96) = 64 bytes.

Likewise, in the MerkleProof contract, the abi.encodePacked() function takes 32 bytes (byte32) + 32 bytes (byte32) and results in 64 bytes output, which the size of the output is “coincidentally” the same as the MerkleDistributor contract.

So, we can split two concatenated proofs into the size of uint256(index), address(account), uint96(amount) as arguments and pass them into the claim() function. The verifying function cannot distinguish between the leaf node and the branch node of the tree and the verifying function should be success.

If we can find a combination of index, address, and amount that is included in the Merkle tree and the amount can be sum with other claims to be exactly the same as the total reward, we can solve this challenge.

We wrote a solver in Go to find the node that matches our conditions above.

After running the solver, we got the answer that we are looking for. We have to claim two times. In the first time, we have to claim with the following values:

  • index: 0xd43194becc149ad7bf6db88a0ae8a6622e369b3367ba2cc97ba1ea28c407c442
  • account: 0x000000000000000000000000d48451c19959e2d9bd4e620fbe88aa5f6f7ea72a
  • amount: 0xf40f0c122ae08d2207b
  • proofs: [0x8920c10a5317ecff2d0de2150d5d18f01cb53a377f4c29a9656785a22a680d1d, 0xc999b0a9763c737361256ccc81801b6f759e725e115e4a10aa07e63d27033fde, 0x842f0da95edb7b8dca299f71c33d4e4ecbb37c2301220f6e17eef76c5f386813, 0x0e3089bffdef8d325761bd4711d7c59b18553f14d84116aecb9098bba3c0a20c, 0x5271d2d8f9a3cc8d6fd02bfb11720e1c518a3bb08e7110d6bf7558764a8da1c5]

The index parameter will be the first proof (the first 32 bytes) and the account will be the first 20 bytes of the second proof and amount will be the last 12 bytes of the second proof.

This diagram is part of the Merkle tree verification when we are claiming the claim at index 37.

This diagram is part of the Merkle tree verification when we are using 0xd43194becc149ad7bf6db88a0ae8a6622e369b3367ba2cc97ba1ea28c407c442 as the index value, 0xd48451c19959e2d9bd4e620fbe88aa5f6f7ea72a as the account value and 0xf40f0c122ae08d2207b as the amount value. The path of the calculation will eventually get 0x225de26d438b50d8... like we had done with the valid claim data on the previous diagram.

We wrote an exploit contract to call the claim function using the data we got.

We then started the challenge instance by connecting to the server.

After that, the Exploit contract was deployed.

We called the merkleDistributor() function of the Setup contract to get the address of the MerkleDistributor contract.

The exploit() function was then called using the address of the distributor contract.

Lastly, we connected to the server again to get the flag.

Flag: PCTF{N1C3_Pr00F_8r0}

Solana

OTTER-WORLD

CHALLENGE: OTTER-WORLD
AUTHOR: NotDeGhost
TAGS: SANITY-CHECK
DESCRIPTION: Otter World!
ACCESS: nc 34.72.24.70 8080
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/otter-world.tar.gz

This challenge is a sanity check Solana challenge.

What you need to do is to call the get_flag() function with the correct value for the magic parameter, which is specified in the contract as 0x1337 * 0x7331.

The challenge has already provided a template for the solution program as follows:

As annotated with /* TODO */ at line 22, we can add * 0x7331 to match the value in the challenge as follows:

The IP address of the server should be modified to match the challenge provided server in the following file:

The script can then be started using the script provided as follows:

Flag: PCTF{0tt3r_w0r1d_8c01j3}

SOLHANA-1

CHALLENGE: SOLHANA-1
AUTHOR: hana (@dumbcontract2)
TAGS: PWN
DESCRIPTION: theres a brand new ponzi scheme in town that lets people deposit and withdraw their bitcoin. why would they want to do this? no one knows
ACCESS: nc 35.188.83.0 3000
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/solhana-ctf.tar.gz

From the zip file, it provides the source code of the server that will verify whether the provided account state is correct or not in order to retrieve the flag.

File Structure

  • chain/: A source code of the challenges’ programs. This is where we observe how the program works and analyze the vulnerabilities
  • client/: A skeleton code for you to craft the magic in the attack() function
  • elf/: built bpf binaries of all challenges’ programs. This can be used to test locally via solana-test-validator node
  • server/: A built program to communicate with the server. There is no need to configuration here unless you want to test it in local.

SOLHANA-1 challenge is a platform that allows the user to deposit and withdraw token through the platform.

The condition to retrieve the flag of this problem is to make the deposit_account account has 0 balance.

During the challenge initialization, 10 Bitcoin is minted to Satoshi, which is then deposited into the deposit_account account through the deposit() function.

Therefore, the goal is to remove these 10 Bitcoin from this funded deposit_account account.

Let’s check what the program’s functionalities offer to the users.

  • setup_for_player() — create a state account which is a root of trusted account of the platform
  • deposit() — deposit token from the depositor account to the deposit account and mint the voucher token to the depositor_voucher account.
  • withdraw() — withdraw the deposited token from the deposit account to the depositor account and burn the voucher token from the depositor_voucher account.

This means if we want to make the balance of that account to be 0, we can either make it through the deposit() and the withdraw() function.

The requirement is that the user who withdraws must have the voucher to be burned during the withdrawal. Unfortunately, in the setup, there is no voucher minted to the users.

What if we create a fake token mint and fake token account and use it as the voucher to fulfill the withdraw() function’s condition?

Let’s check the account verification mechanism whether the attack scenario is possible or not.

The program only verifies whether the voucher_mint account has the mint::authority = state and token::decimals = deposit_mint.decimals.

Therefore, what we need to do to attack is as follows:
1. Create a fake mint program with the same token decimal as Bitcoin (6 decimals)
2. Mint at least 10 vouchers to the player’s fake voucher account to match the balance we need to burn on the withdrawal
3. Transfer the account authority to the `state` account
4. Withdraw from the `deposit_account` account using the fake voucher created

The attack script is as follows:

We run the script and got the flag.

Flag: PCTF{unsafe_voucher_vouched_vouchsafely}

SOLHANA-2 (Solved Locally)

CHALLENGE: SOLHANA-2
AUTHOR: hana (@dumbcontract2)
TAGS: PWN
DESCRIPTION: theres a brand new ponzi scheme in town that lets people deposit and withdraw their ethereum. why would they want to do this? no one knows
ACCESS: nc 35.188.83.0 3000
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/solhana-ctf.tar.gz

SOLHANA-2 is like SOLHANA-1, but with the liquidity pool and swap features (like AMM) added.

This issue is solved locally, but failed when submitted. Therefore, the detail might be incorrect (which is the reason while it failed), but might give you the idea.

The condition to retrieve the flag of this problem is to make the token amount of those 3 pools to be at maximum 150_000_000

During setup, the challenge comes with the 3 liquidity token pools for swapping, which are wo_eth (8 decimals), so_eth (6 decimals), and st_eth (8 decimals) pools with the balance of 100_000_000 for each account. And for the player, there is also an initial balance for swapping to which are 1_000 for each account.

So, let’s take a look at the added functionalities — add_pool() and swap().

It assign the new added pool to the pool array of the state account without verifying whether it overwrites the existing one or not. So, this could be used to do something fancy.

This function validates the token amount in case the token input and token output apply different decimals. The passed pool account must exist on pool array of the state account. Then, it transfer the token input to the pool token account and transfer the token output from the pool token account to the user in return.

We think you see the attack scenario now. What if we call add_pool() with the fake token mint (8 decimals) to make it exist in the state account. And then just simply swap that fake token with the balance that we just mint it to the player with the wo_eth ( 8 decimals) and st_eth (8 decimals) tokens, draining the balance of the required accounts. So the sum of the balances of all required accounts will be 100_000_000 which is less than 150_000_000, allowing us to pass the challenge condition.

Below is the attack script that pass the local test.

Unfortunately, we could not find out why our solution was not working remotely on the challenge server. 🙁

Cairo

RIDDLE-OF-THE-SPHINX

CHALLENGE: RIDDLE-OF-THE-SPHINX
DESCRIPTION: What walks on four legs in the morning, two legs in the afternoon, three legs in the evening, and no legs at night?
ACCESS: nc 35.193.19.12 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/riddle-of-the-sphinx.zip

This is a sanity check Cairo (Starknet) challenge.

To solve this challenge, we need to set the _solution state to “man” (0x6d616e) as checked in chal.py.

We started an instance and wrote a script to invoke the solve() function by with 0x6d616e as a parameter.

We then run our script and connected to the server to get the flag.

Flag: PCTF{600D_1UCK_H4V3_FUN}

CAIRO-PROXY

CHALLENGE: CAIRO-PROXY
DESCRIPTION: Just a simple proxy contract
ACCESS: nc 35.226.167.223 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/cairo-proxy.zip

In this challenge, three Cairo files were given:

  • almost_erc20.cairo: a simple implementation of ERC-20 token
  • proxy.cairo: a proxy contract that calls the function from the implementation through the fallback function
  • utils.cairo: utility contract for reading and writing state

However, when we compile the contract, we can see that the auth_write_storage() function is also imported, allowing arbitrary write to any storage address.

From chal.py, our goal is to set the balance of the player account to 50000e18.

Therefore, we started the challenge instance and wrote a script to call auth_write_storage() to set the player balance as 50000e18.

The script was run and we connected to the server to get the flag.

Flag: PCTF{d3f4u17_pu811c_5721k35_4941n}

CAIRO-AUCTION

CHALLENGE: CAIRO-AUCTION
AUTHOR: BitBaseBit, and Mauricio Perdomo
TAGS: PWN
DESCRIPTION: Just a simple auction contract
ACCESS: nc 34.132.254.35 31337
RESOURCES:
https://github.com/paradigmxyz/paradigm-ctf-infrastructure
https://ctf.paradigm.xyz/resources/cairo-auction.zip

In this challenge, the goal is to win the auction by outbidding the winner. From chal.py, there were two other bidders that have bid for 100000e6, while the player has only 50000e6 tokens.

The raise_bid() function requires that the latest bid amount is over the winning bid to change the winner.

While the unlock_funds() function can be used to unlock the funds by deducting the _auctionBalances.

However, as Uint256 in Cairo is as struct composed of two felts (https://github.com/starkware-libs/cairo-lang/blob/master/src/starkware/cairo/common/uint256.cairo#L9-L14), it is possible to contains a negative value if not checked properly.

We started the instance and wrote our script to unlock our funds with -100000000001 in order to increase _auctionBalances to be 1 higher than the winning bid. Then raise the bid with 0, since our balance will already be higher than the winning bid. To get that value, we subtract 100000000001 from the max value of felt (2**251+17*2**192+1) and set it to the low bits.

We ran our script, and was able to get the flag from the server successfully.

Flag: PCTF{y0u_7h0u9h7_17_w45_4_p21m171v3_7yp3_8u7_17_w45_m3_d10}

About Inspex

Inspex is formed by a team of cybersecurity experts highly experienced in various fields of cybersecurity. We provide blockchain and smart contract professional services at the highest quality to enhance the security of our clients and the overall blockchain ecosystem.

For any business inquiries, please contact us via Twitter, Telegram, contact@inspex.co

--

--

Cybersecurity professional service, specialized in blockchain and smart contract auditing https://twitter.com/InspexCo

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Inspex

Cybersecurity professional service, specialized in blockchain and smart contract auditing https://twitter.com/InspexCo