Web3 Hacking: Paradigm CTF 2022 Writeup
Paradigm CTF is one of the most challenging Web3-focused security competitions — amazing for blockchain security folks, this competition consists of challenges created by some of the brightest minds in the field. The blockchain security team at Amber Group participated as team Amber_Labs and ranked #6 from a total of 445 teams.
In this blog post, we walk through the challenges we solved during and after the competition.
Challenges
Solved Challenges:
EVM
- Random
- Rescue
- Hint-finance
- Sourcecode
- Lockbox2
- Just-in-time
- Vanity
- Merkledrop
- TRAPDO{3,4}R
Cairo
- Riddle-of-the-sphinx
- Cairo-proxy
- Cairo-auction
Solana
- Otter-world
- Otterswap
Random
Random is the first challenge to getting used to the CTF environment. Just call the solve() with 4 as the argument. The private key, RPC URL, and setup contract address are given from the server when we create a new instance.
cast send <contract-addr> “solve(uint256)()” 4 — private-key <private-key> — rpc-url <rpc-url>
Rescue
This is a series of challenges on DeFi, this challenge examines DeFi ecology a little more and requires interaction with Uniswap.
Setup
The challenge is considered solved if the WETH balance of mcHelper contract is 0.
Vulnerability
The _addLiquidity() function uses the current balance to add liquidity.
Exploit
We prepare 100 WETH to exploit the vulnerability.
First, we swap one of the WETH to DAI while the rest to USDT, and transfer all the USDT to the mcHelper contract.
Second, we call the function swapTokenForPoolToken() with the first parameter poolid set as 0. The pool is composed of WETH and USDT, which matches exactly what we need. The second parameter is DAI which will be taken by the function, then swap half of DAI to WETH while the rest to USDT.
Finally, add the liquidity. Since we transferred a large amount of USDT to the contract before calling the function, the pool will run out of WETH when liquidity is added.
Hint finance
This challenge implements a vault. Users can deposit their tokens into the vault to earn rewards.
Setup
The setup contract deploys three different vaults: PNT vault, SAND vault, and AMP vault. Each vault is initialized with 10 ether worth of the underlying token. The win condition is to reduce the underlying balance to 1% of what it started with.
Vulnerability — ERC777 Reentrancy
Looking at the deposit() and withdraw() functions, we notice a problem: these two functions do not comply with the checks-effects-interactions pattern. There is a possible way to reenter the deposit() function in the middle of the withdraw() function.
The AMP and PNT tokens are ERC777 tokens. And we found: CREAM Finance Post Mortem: AMP Exploit
The AMP token contract implements ERC777, which has the _callPostTransferHooks hook that triggers tokensReceived() function that was implemented by the recipient.
ERC777 tokens allow arbitrary callbacks via hooks that are called during token transfers. Malicious contract addresses may cause reentrancy on such callbacks if reentrancy guards are not used. Therefore, an exploit came up with the following steps:
- We call the withdraw() function to withdraw all the balance in the vault, as there is no check before the transfer.
- During the token transfer, the tokensReceived() hook in our exploit contract can call the deposit() function using the token the vault just transferred to us. Because of a small bal, we can get large shares. Our balance is increasing and so is the totalSupply.
- When the exploit returns to the hijacked withdraw() call, the arithmetic operation will not cause underflow due to the increased balance. Now, we successfully drain the vault.
The SAND Token, HOW!
The remaining vault has SAND as its underlying. The flashloan() function seems strange; it allows anyone to make an arbitrary call from the vault contract. Recall that the function selector is the first 4-bytes of the function signature, and an external function call is sent with a function selector.
So we calculated the selector of onHintFinanceFlashloan, 0xcae9ca51, and put it in the Ethereum Signature Database.
The result is the same as the approveAndCall(). The approveAndCall() approves the target to spend the amount and calls it with data. Let the vault call the approveAndCall() and set the target to our exploit contract by calling the vault’s flashloan() function with elaborately crafted calldata.
As the two colliding functions have different parameters, we analyze the calldata when the onHintFinanceFlashloan() is called.
In flashloan(), since it calls the balanceOf and transfer of our fake token, we construct the balanceOf and transfer functions in our fake token contract.
Note that the approveAndCall() checks whether the first parameter is msg.sender or not.
In doFirstParamEqualsAddress(), it will check the data.length is not smaller than (36+32).
To sum up, we can construct the payload like the following:
Calling the approveAndCall(), we use the transferFrom() to transfer the token to our exploit contract:
Sourcecode
This challenge requires writing a contract that returns its own bytecodes when called.
There are restrictions to the contract.
- The contract is called via staticcall, so it cannot modify state.
- Specific opcodes are filtered. It cannot call another contract.
3. We tried getting bytecodes with codecopy, but found out that it’s also filtered.
Solution
We constructed a contract that contains two parts. The second part moves stack contents into memory and returns that memory. The first part contains a push instruction whose operand is the second part’s bytecode.
And now we need to add two extra operations to the second part. The first is a dup1 opcode, and the second is a mstore8 that stores the pushX instruction in the first part.
We used huff to implement our contract.
Lockbox2
This challenge requires to construct input data that bypasses a 5-stage lockbox.
stage1
stage1 limits input data length to 500 bytes.
stage2
stage2 checks if the first 4 uint256 elements of our parameter are prime numbers.
So we can only use small numbers in the first 4 uint256 in our input. Or we would run out of gas.
stage3
stage3 calls into a contract address that we control, and checks if c is equal to the return value. But remember stage2 restricts our a, b, c to small numbers. So we can only make it calls into “Precompiled Contracts” or EOA. And we later found that it conflicts with stage4 if we call into “Precompiled Contracts”.
If we call into EOA, staticcall returns nothing and the memory at return data position is not filled. But there is a mysterious mstore operation that enables us to control the return data. By debugging in remix we found that the return data is at memory address 0x60. So we may set a to 0x60 and b to 1 to fake a return value. But remember a and b should be prime, we adjusted a to 0x61 and b to 0x101. Then set c to 1, we can bypass this stage.
stage4
This stage calls create with data we passed in input. So we can deploy a contract on chain. The deployed contract is called with staticcall.
The deployed contract’s bytecode hash should equal to tx.origin. We know that ethereum address is the last 20 bytes of the keccack256 hash of the public key and the bytecode hash is the keccack256 hash of the bytecode. So this stage requires us to deploy our public key. And the public key should be executable.
We generated a wallet whose public key starts with 00, the STOP opcode, and sent the transaction with that wallet.
This stage’s function arguments’ types are bytes. If you check the ABI encoding rules of solidity you will find that the first two uint256 are offsets to a and b. Since we set a, b to 0x61 and 0x101 in the last stage, we need to put our contract creation code with its length at the offset 0x61.
stage5
This stage will call into the lockbox again. But requires this call to fail.
We leveraged the create in stage4. The create runs our opcode and we can modify the state. We wrote another contract as a counter and revert in the second call. So stage4 will revert in the second call in stage5 to meet the condition.
Added the solve function selector to the input data we constructed in the previous steps and sent the transaction with the wallet we generated in stage4. This challenge is solved.
Just-in-time
This challenge implements a brainfuck compiler with solidity. The compiler contract compiles the brainfuck input and delegatecalls it.
To solve this challenge, we need to drain all of the compiler contract’s ether.
Vulnerability
[ and ] pair denotes loops in brainfuck. The compiler first iterates the input to find [ and corresponding ], and stores the position index relations inside storage variable loops. loops is a solidity mapping type variable.
mapping (uint => uint) private loops;
The vulnerability is that if the input has an unclosed [, the loops mapping is not initialized at the corresponding position. So [ will be transformed into a conditional jump with one branch target to zero address.
The loops mapping elements are not cleared during calls. So we can first call the compiler with a valid input that has a closing [ ] pair, and then call the compiler with an input that only has a [ at the same position but does not have a matching ]. The second call will reuse the target address of the first call. And we can put our “shellcode” at the jump target position.
“shellcode”
Now we have an arbitrary address jump, we can write a “shellcode” that drains the balance of the compiler contract. The most simple shellcode we can think of is JUMPDEST SELFDESTRUCT. The first byte has to be JUMPDEST because in EVM JUMP/JUMPI instruction must jump to a JUMPDEST instruction. SELFDESTRUCT will send out all the ethereum in the compiler contract. SELFDESTRUCT takes one parameter in the stack as the ethereum receiver. We can just use the value as in because the challenge only requires the balance of the compiler contract to be zero.
So how to put our two-byte shellcode in the compiled contract?
This compiler extends the brainfuck language with 4 additional instructions R L A S. And these instructions take two bytes as its operand. We can insert our shellcode into A or S’s operand.
jump to shellcode
Now our shellcode is the operand of the PUSH2, and is not executable according to the yellow paper. Directly jumping to it will cause an execution revert.
The compiler also translates invalid operand op into de ad op be af in the final EVM bytecode. So we can leverage an EVM PUSH instruction to mark its following bytes including the PUSH2 in front of our shellcode to be its data operand. So our shellcode will be marked executable. Then jumping into it will solve the challenge.
Vanity
The goal of this challenge is to find an address that has at least 16 0s in its hex representation and we either have its private key or manage to pass the check in isValidSignatureNow.
The problem
Finding a qualified address by brute force search is obviously impractical, so we can focus on the isValidSignatureNow function.
The ECDSA library used here contains a signature malleability issue, but it’s not something we can take advantage of in this scenario.
Note that this verification helper supports ERC1271 signatures from smart contract wallets, if the first 4 bytes of the return value of the staticcall is equal to IERC1271.isValidSignature.selector, then the signature is considered valid.
The solution
The signer and signature parameter in the staticcall are the things we can control, so one possible solution is let signer be address(0x02), which is a precompiled contract for SHA-256 operation, and then find a byte string as signature that can lead to the first 4 bytes of the SHA-256 hash of the calldata to be IERC1271.isValidSignature.selector.
Merkledrop
The goal of this challenge is to transfer all the tokens out from the MerkleDistributor contract, but at the same time, there needs to be at least one address that’s in the list that has not claimed the tokens.
The problem
Before transferring out the tokens, the MerkleDistributor contract verifies the proof supplied by the claim function caller. However, there exists a second preimage attack on the Merkle tree. The attack works by creating another Merkle tree using the intermediate nodes as the leaf nodes of the new tree. The new tree will have the same Merkle root as the old one, allowing us to pass in a valid Merkle proof.
The solution
If we look closely at how the claim function constructs a leaf node, we can see that it concatenates uint256 index, address account, and uint96 amount and applies keccak-256. Note that this process is precisely how we generate an intermediate node from its two child nodes, as uint256, address, and uint96 adds up to a size of 512 bit. With these in mind, we can use the value of two child nodes of any node in the tree as the parameters, where the value of the left node is index, the value of the higher 160 bits of the right node is account, and the value of the lower 96 bits of the right node is amount. So that we can potentially transfer the tokens out even though the destination address and the transfer amount are not on the list.
The last thing to do before we beat the challenge is to simply find a combination of the right child nodes so that we can transfer exactly 75000 tokens.
TRAPDO{3,4}R
The goal of this challenge is to get the last line of stdout in the subprocess running the contract to start with “you factored the number!”. By default it logs “you didn’t factor the number. %d * %d != %d” if we fail to do the correct factorization of a uint256 number.
There are two versions of this challenge, because an unintended solution was found during the contest, and so later the challenge was patched and became a new challenge. The difference between the two versions is that the 3o one puts the flag in the env directly.
The unintended solution of trapdooor
By looking at chal.py, we can see the contract is executed via forge script:
It allows us to insert foundry cheatcodes in our contract, and one intuitive cheatcode to use is envString, envString can read arbitrary environment variable and return them as a string, which is exactly what we need to get the flag.
Since we can only see the last line of the output, we can convert the flag we get into two uint256 numbers as the parameters of console.log in the challenge, and then decode these two numbers to get the flag of the challenge.
The universal solution for both trapdooor and trapdooor
console.log in Hardhat and Foundry works by detecting calls sent to CONSOLE_ADDRESS, so we don’t necessarily have to do the factorization if we can find a way to inject a log after the last staticcall to CONSOLE_ADDRESS.
We can achieve that by setting the contract code on CONSOLE_ADDRESS through a foundry cheatcode etch and make it call itself again with crafted payload, see the code snippet below for example:
Riddle of the Sphinx
This is an environment setup challenge for Cairo.
Setup
The challenge is to let solution() return man.
Vulnerability
The solve() function allows users to write any string into the solution.
Exploit
As an environment setup challenge, we learned how to create AccountClient object from a private key and a gateway URL as follows:
As for the Contract object we’re going to interact with, except the GatewayClient object created above and the given contract address, we also need the abi of the contract. It could be generated by starknet-compile as follows:
Then, the Contract object could be created this way:
Finally, we can invoke solve() with “man” as follows:
Cairo Proxy
This is a proxy of a buggy ERC20 contract.
Setup
The challenge is to get int(50000e18) tokens in player’s balance.
Vulnerability
The burn() function fails to validate the amount to be burned properly such that we could burn an arbitrary amount of tokens and set the player’s balance to any value.
Specifically, the uint256_le() check in line 106 should be uint256_le(amount, account_balance). Since the player’s balance is 0 in the initial state, any given amount could pass the check. Later on, the underflowed account_balance would be written into balances in line 111.
Exploit
Burn 0x10000000000000000000000000000000000000000000000000000000000000000 — int(50000e18) of player’s tokens as follows:
Cairo Auction
There are four external functions in the auction.cairo contract.
increase_credit
Transfer in tokens and top-up _balances[user] (_balances[user] would be incremented).
raise_bid
Cast _balances[user] tokens to an auction (_auctionBalances[user] and _lockedBalancesOf[user] would be incremented).
unlock_funds
Unlock tokens in the auction (_auctionBalances[user] and _lockedBalancesOf[user] would be decremented).
withdraw_credit
Withdraw tokens which are not locked (_balances[user] would be decremented).
Setup
The challenge is setup with two bidders who increase_credit and raise_bid for 100k tokens respectively. We need to somehow make the player win the bid with only 50k tokens.
Vulnerability
If you carefully compare the codebase of cairo-proxy and cairo-auction, you should notice that the latter missed the uint256_check() calls, which is the key to identify the vulnerability in this challenge. Since uint256 in Cairo is a structure containing two felt and one felt is larger than 128 bits, the missed uint256_check() allows bad actors to use the high bits of the felt to bypass the uint256_le() check in unlock_funds(), which create a huge _lockedBalanceOf[user] due to integer underflow.
As shown in line 236 above, an extremely large amount could pass the check as uint256_le interprets it as 0.
Later on, (0 — amount), the underflowed value, would be written into _auctionBalances[player] and _lockedBalancesOf[player] in line 243 and line 248.
Now, the player has the unlimited _lockedBalancesOf. The player could issue a raise_bid() call to update the current_winner.
Exploit
Whenever we know the low felt could be used to trick unlock_funds(), we tried setting all higher 128 bits. My first test got the following error message:
So, the max value of the low felt should be 3618502788666131213697322783095070105623107215331596699973092056135872020481–1 which is equivalent to 0x800000000000011000000000000000000000000000000000000000000000000, having 0s in all lower 128 bits. This helps us to construct the following malicious unlock_funds() call:
Then, we could raise_bid and win the auction as follows:
Otter World
This is an environment setup challenge for Solana.
Setup
In client/framework/src/main.rs, you would see the deployment of chall.so. Specifically, the chall binds 0.0.0.0:8080, waits the solve program, and runs it.
In client/framwork-solve/solve/programs/solve/src/lib.rs, the get_flag() function is called with a magic number and a TODO in comments:
It means you should fill in a number that makes chall::cpi::get_flag() happy.
Vulnerability
Let’s take a look on the chall side. In client/framework/chall/programs/chall/src/lib.rs, the magic is 0x1337 * 0x7331 as follows:
So, all you need to do is slightly tweaking the solve and giving it a shot.
Exploit
Thanks for OtterSec who packed the challenge with docker. You only need to run client/setup.sh and have a cup of tea. The environment setup would be done automatically. Later, you can run client/run.sh to test you solve program after you set the correct IP in client/framework-solve/src/main.rs:
OtterSwap
OtterSwap is a uniswap-like DEX. The swap() function allows you to swap a token to b token (and vice versa).
Setup
The swap() function is implemented as follows:
The (x,y) are the balances of token a and token b in the pool. Based on the input amount, the out_amount is computed in a way similar to Uniswap, i.e., xy = k.
As shown in client/framework/src/main.rs, 10 of token a and 10 of token b are minted to the OtterSwap pool. On the other hand, 10 token a are minted to the you.
After the `solve` is executed, you need to get 29 tokens to catch the flag.
Vulnerability
The loophole is related to the rounding error while calculating out_amount. For example, when x=18, y=5 and you swap 2 b tokens to a tokens. The out_amount would be 18 — (18*5)/(5+2) = 18–90/7 = 18–12 = 6. After the swap, x=12, y=7 and k = 12*7 = 84, which demonstrates that the k could be reduced. If we do it properly, we can achieve x=0, y=1.
Exploit
Here comes the 11 swaps to drain the OtterSwap pool. The idea is to keep reducing k as much as possible.
After simulating the above operations in our brains, we tried to invoke chall::cpi::swap() multiple times in solve as follows:
However, the compiler shows cpi_ctx does not implement the Copy trait. So, we need to new the cpi_ctx instance whenever we issue a swap in a loop:
Then, the execution fails again. The problem is in cpi_accounts. As shown in the given client/framework-solve/solve/programs/solve/src/lib.rs, the cpi_accounts is created like this:
After digging into the Solana programming examples/documents, we realized that the user_in_account is considered as the account to transfer in the source token. But, ctx.accounts.user_in_account is linked to token a as shown in client/framework/src/main.rs:
What if we’re doing B -> A swap? The user_in_account of chall::cpi::accounts::Swap should be set as ctx.accounts.user_out_account, which links to the account to hold B. And, user_out_account should be set as ctx.accounts.user_in_account. Eventually, the exploit should create cpi_accounts based on the swap direction as follows:
Disclaimer
The information contained in this post (the “Information”) has been prepared solely for informational purposes, is in summary form, and does not purport to be complete. The Information is not, and is not intended to be, an offer to sell, or a solicitation of an offer to purchase, any securities. The Information does not provide and should not be treated as giving investment advice. The Information does not take into account specific investment objectives, financial situation or the particular needs of any prospective investor. No representation or warranty is made, expressed or implied, with respect to the fairness, correctness, accuracy, reasonableness or completeness of the Information. We do not undertake to update the Information. It should not be regarded by prospective investors as a substitute for the exercise of their own judgment or research. Prospective investors should consult with their own legal, regulatory, tax, business, investment, financial and accounting advisers to the extent that they deem it necessary, and make any investment decisions based upon their own judgment and advice from such advisers as they deem necessary and not upon any view expressed herein.