blockchain - tree
tl;dr: Merkle Tree second preimage attack using the existing node hash.
This challenge implements an airdrop contract on EVM where targeted users can claim their crewToken airdrop by providing valid MerkleTree proofs.
The airdrop contract is initialized with 300 crewTokens. These tokens can only be claimed by legitimate users. As soon as the balance of the airdrop contract came down below 50 crewTokens, anyone could claim all the remaining tokens by calling removeDust()
.
To solve this challenge, we are required to empty all the balance of the airdrop contract and make sure that the PLAYER address has more than 0.0 CrewToken.
|
|
The only way we can withdraw some crewTokens from the contract is by calling the claim
function.
However, it’s secured by the Merkle Tree proof. Only legitimate users who know the preimage of the Merkle Tree node hash can successfully call.
Dive deeper into how the Merkle Tree proof is implemented. At first, the leaf node hash is calculated by doubly ripemd160 hashing bytes11 epoch + uint72 amount + address to
.
Subsequently, two adjacent leaf node hashes are combined and again doubly ripemd160 hashed to calculate the inner node hash, repeating this procedure several times and finally a root hash is acquired.
To claim some crewToken from the contract, the preimage of one leaf/inner/root node hash must be acquired.
It first occurred to me that there might be one leaf node hash that could be used by the PLAYER address to receive the airdrop. So, I need to brute-force the preimage bytes11 epoch + uint72 amount + address to
and check whether the resulting hash is in the list. The address to
is sure to be the PLAYER address, and the epoch
(maybe starts from 0?) and amount
(perhaps between 0 and 300?) should be guessed. However, an hour of brute-forcing gave me nothing. Thus, this direction couldn’t be the intended solution.
Later, I noticed that the parameter types of the claim
function were quite weird.
|
|
Why should the epoch
parameter type be bytes11
and not bytes20
or bytes32
?
Why should the amount
parameter type be uint72
and not uint32
or uint256
?
It soon hit me that bytes11 + uint72
is 20 bytes and the type address
is also 20 bytes. This is the same as how two child node hashes, each 20 bytes, are combined to calculate the parent node hash.
It turns out that we don’t need to brute-force the preimage of the leaf node hash. We do have the preimage of several inner/root node hashes, which are its two child node hashes combined.
So, if one child node hash can be fit into bytes11 epoch + uint72 amount
and another child node hash is set to be address to
, we can utilize this parent node hash to form a valid Merkle Tree proof! The only limitation is that uint72 amount
, the last 9 bytes of the smaller child node hash converted to uint72
, should be less than 300 * 100^18.
A quick search gives us 4 candidates.
|
|
We need to reduce the balance to less than 50 crewToken and make sure the smaller hash comes first. So only the node hash 5c1562f32e1d9c715d41b10dde0eb3088353a980
fits the requirement.
Steps to attack:
- Call the
claim
function of the merkleAirdrop contract to claim 255.80 crewTokens to address0xEfabb566b9a30D074684005482d0ef08F4A76b4B
with the following parameters:bytes11 epoch
: hex"5c1562f32e1d9c715d41b1", which is the first 11 bytes of the smaller child node hashuint72 amount
: 255808596333462530432, which is the last 9 bytes of the smaller child node hashaddress to
: 0xEfabb566b9a30D074684005482d0ef08F4A76b4B, which is the sibling node hashbytes20[] proof
:- bytes20(hex"81ef08937a2c85f8ef6bc79ce82d0c9c7d10a119")
- bytes20(hex"1852fe43fe3f29f9880af8c4adda245f3959cd5d")
- bytes20(hex"a672fa083f7d075135f8de011d2597f3e5177371")
- Now the balance of the contract is below 50 crewTokens, so we call the
removeDust
function to claim the remaining crewToken. - Transfer some crewTokens to the PLAYER address.
- Now calling
Challenge.isSolved()
will return True.
|
|
blockchain - lightbook
tl;dr: The clob_v2::get_level2_book_status_bid_side
function does not return the orders whose price is within the exact range [price_low, price_high]
. Diving further into how this function is implemented, I found out that it found the closest key, which could be a problem. So I tried to place an ask order with the price set to 2.01, which is not in the range [1.95, 2.00], and the function returned orders including this one. Thus we can profit by selling at price 2.01 and buying at price 1.99. Repeat this plenty of times, and we have sufficient CUSDs to solve this challenge.
This challenge implements a trading pool on the SUI blockchain using the Move language where users can trade between two tokens - HCOIN and CUSD.
The trading pool is created using the DeepBookV2
packages provided by the official SUI team. The trading mechanism here is not an AMM like Uniswap
, the DeepBookV2
utilizes an order book for trading just like people buy/sell stocks in the traditional exchange. Users can place limit orders or market orders into this trading pool to buy/bid specified HCOINs with some CUSDs, or sell/ask specified HCOINs for some CUSDs.
A limit order is used to buy or sell a token at a pre-determined price and will not execute unless the price meets those qualifications.
A market order is an instruction to buy or sell a token immediately at the current market price.
During setup, the trading pool with no trading fee is created by admin
, 300 airdrop HCOINs and 600 airdrop CUSDs are minted and deposited to two objects stored inside storage
, and 6 bid orders and 6 ask orders are placed into this pool by admin
. The prices of bid orders are a little bit less than 2.00 and the prices of ask orders are slightly above 2.00. The middle price of CUSD/HCOIN seems to be 2.00. This means that admin
will profit from these 12 orders if someone in the market is willing to buy/sell at the prices of these orders.
The goal we need to achieve is to acquire more than 2500 CUSDs.
|
|
Initially, we can claim all the 300 HCOINs and 600 CUSDs from the airdrop. However, if we try to use these tokens to trade in the pool, taking all the orders placed by admin
, we are only left with 300 HCOINs and 558 CUSDs. We are losing our tokens because we are trading with admin
at an unfavorable price!
Thankfully, there’s an otter strategy, initialized with 20,000 HCOINs and 20,000 CUSDs, which can be executed by us. During execution, the strategy will take some orders at the market price if specified conditions are met. Maybe we can place some limit orders at a favorable price and let the strategy take our orders so that we can earn some tokens?
Unfortunately, the strategy is not that simple. The strategy is only willing to take bid orders whose price is in the range [2.00, 2.05], and consume ask orders whose price is in the range [1.95, 2.00]. If we want to profit by buying HCOINs, the price of our orders should be less than 2.00, and if we want to profit from selling HCOINs, the price must be more than 2.00, otherwise, we’ll lose our tokens. In other words, the strategy will never take orders that make us profit.
So, how is it possible for us to earn that many (>=2500) CUSDs???
This stuck with me.
No way to earn tokens from taking the orders placed by admin
. So the only way that we can profit is from strategy. What I need to do is thoroughly audit the code to understand how the strategy is executed and try harder to figure out whether somewhere exists a bug that I can exploit for profit.
|
|
During execution, the strategy will:
- Query the pool for the current market price by calling
clob_v2::get_market_price
.bid_price
is the highest order price at the bid side, andask_price
is the lowest price at the ask side. - Determine whether there’s any, if this is a
is_bid
execution, ask order existed, i.e.ask_price
is not empty. If not, it will do nothing but return. - Determine whether
ask_price
is more thanBUY_THRESHOLD
(1.95). Only ifask_price
<= 1.95 will the strategy take orders in the market. Otherwise, it will return. - Acquire all the orders whose price is within the range
[BUY_THRESHOLD, LIMIT]
, which is [1.95, 2.00], at the ask side by callingclob_v2::get_level2_book_status_ask_side
. The prices and depths (quantities) of each order are stored in the two vectorsprices
anddepths
separately. - Calculate how many base tokens (HCOINs) the strategy needs to sell if it wants to take all these orders between [1.95, 2.00]. The calculated result is stored in
total_required_base_amount
. Meanwhile, the amount of CUSDs the strategy will get is also calculated and stored intotal_required_quote_amount
. - Do some checks before trading. This ensures the strategy vault has enough tokens to complete the transactions.
- Place a market order to buy
total_required_base_amount
HCOINs with CUSDs. This will take all the orders whose price is within [1.95, 2.00] at the ask side. - Return all the remaining tokens to the strategy vault.
After deep analysis of the execution procedure, it seems that there’s also no way for us to earn tokens from trading with the strategy.
But this is a CTF challenge, which means a solution exists.
So I try to think whether the strategy will take more orders than expected.
If the strategy can take orders from the ask side whose price is more than 2.00, then we can profit and moreover solve this challenge.
Is it possible the calculation of total_required_base_amount
can be wrong so that the strategy will take more orders?
Is there any chance that we can cancel some orders when the strategy is about to take orders?
Or, perhaps, the returned orders from clob_v2::get_level2_book_status_ask_side
may go wrong?
So, I click into the implementation of the get_level2_book_status_ask_side
function in the clob_v2
module written by the official SUI team.
|
|
As documented in the code, this function will return the order depth of all valid prices within the entered price range at the ask side. But is what it really does consistent with what it says?
- At first, some checks are done to handle special cases.
- Next, it will search in the order book
&pool.asks
at the ask side, which is underlying implemented using a data structure called cirt-bit tree . The code will find the closest key to the enteredprice_low
in the tree and set that value to be the lower bound. Also, the closest key to the enteredprice_high
is found and set to be the upper bound. - Later, in the loop, all tree node between the lower bound and the upper bound is traversed. For each node, the corresponding
price
anddepth
is extracted and put in the two vectorsprice_vec
anddepth_vec
. - Return the two vectors
price_vec
anddepth_vec
.
Note the word closest!!!
This means that in case the key to be searched doesn’t exist in the tree, the code will try to find a node value that is closest to the key. The value may be smaller than the key or bigger than the key. Thus, there is a chance that the bound exceeds the expected range [price_low, price_high].
Realizing that this could be a problem, I immediately wrote some codes to test the idea.
And it worked!
I first placed a limit order at price 1.95 to sell 1 HCOIN and placed another limit order at price 2.01 to sell 100 HCOINs. Then, I executed the strategy to take my orders. And the strategy took all the two orders and I got 101 HCOINs back! This implied that the orders returned by the function clob_v2::get_level2_book_status_ask_side
included the order at price of 2.01, which was not as expected [1.95, 2.00].
Note that this bug also exists in the
clob_v2::get_level2_book_status_bid_side
function at the bid side.
Therefore, we can profit by selling HCOINs at price 2.01 and buying HCOINs at price 1.99. Repeat this plenty of times, and we’ll acquire enough CUSDs (>= 2500) to solve this challenge.
Steps to attack:
- Claim the airdrop and we will have 300 HCOINs and 600 CUSDs at hand.
- Create a custodian account so that we can trade in the pool.
- Consume all the 12 orders placed by
admin
so that there’s no order in the market and we can manipulate the market price. Now we only have 300 HCOINs and 558 CUSDs left. - Deposit all the remaining tokens into the custodian account so that we can place limit orders in the pool.
- Repeat the following procedure plenty of times to earn tokens from the strategy. For each time:
- Place one limit order to buy 0.01 HCOIN at price 2.05.
- Place another limit order to buy HCOIN at price 1.99 as much as possible with all of our CUSDs.
- Execute the strategy to consume these 2 limit orders.
- Place one limit order to sell 0.01 HCOIN at price 1.95.
- Place another limit order to sell all of our HCOINs at price 2.01.
- Execute the strategy to consume these 2 limit orders.
- After 100 iterations, we will have 0 HCOIN and 3139.4184 CUSDs at hand. This enables us to solve the challenge.
|
|
Paste the code snippet into the solve.py
file, change the host and port, and run python3 solve.py
award us the FLAG.
I haven’t read any SUI contract code before and knew little about how the SUI blockchain worked. I only knew that the SUI blockchain was similar to that of Aptos and learned a little about the Move contracts in the Aptos blockchain a few days ago when I tried to solve some challenges in the Aptos Code Collision CTF 2024 .
To understand how the challenge worked, I read through the DeepBookV2 documentation
and some code implementation in the clob_v2
and custodian_v2
modules, which helped me a lot.
Thanks a lot to the author @gss1 who found such a wonderful bug and made such a nice challenge.
It’s very enjoyable to solve this hard challenge which was only solved by me during the CTF, and it gave me much courage to solve harder challenges in the future!