MoveCTF 2022 Writeup

Amber Group
Amber Group
Published in
9 min readNov 15, 2022

The MoveCTF is a major Move security competition for security professionals and developers who are interested in the Move language and the Move ecosystem. Sponsored by Sui Foundation and hosted by MoveBit on 5–7 November, the first MoveCTF consisted of four challenges and attracted a total of 345 participants around the world. Thanks to the organizers!

Two members from the blockchain security team at Amber Group participated and ranked in the top 10 (cwu.eth and Ouooo) by each solving all the four challenges.

In this blog post, we walk through the challenges they solved during the 48-hour competition.

Challenges Solved

  • Checkin
  • Simple Game
  • Flash Loan
  • Move Lock

Checkin

This is the entry-level challenge for everyone to get familiar with the environment. All we need to do is call the get_flag() function to get the flag!

$ sui client call --gas-budget 10000 --package $PACKAGE_ADDR --module checkin --function get_flagSimple Game

Simple Game

The simple game challenge is the first “real” challenge after “Checkin”. You need a treasury box to get the flag. If you are lucky, you will get a good random number. And, the only way to get a treasury box is to send a hero to slay the boar king.

The damage a hero can do to the boar king can be measured by the calculation (hero.strength — monster.defense). Initially, the hero.strength is way lower than monster.defense, which makes the mission of slaying the boar king impossible. Therefore, you need to train the hero by sending him to slay boars first. Each successful completion of the slay_boar() mission increases the hero.experience. At the end of the day, you can level_up() and drastically improve the strength of your hero. When the hero.experience reaches 100, you can eventually send him to slay_boar_king() with your fingers crossed. If the hero kills the boar king and gets a treasury box for you, you can use that box to get the flag with a 1% chance.

Get the box

As mentioned above, we can simply get a treasury box by three steps within the 200 stamina limit: slay_boar(), level_up(), and slay_boar_king(). Therefore, we started off by sending lots of transactions out in a script. However, since the RPC was not stable, we can do it in a function like this and get the box in one-shot.

           } public entry fun get_box(hero: &mut Hero, ctx: &mut TxContext) {
while (hero::experience(hero) < 100) {
adventure::slay_boar(hero, ctx);
};

hero::level_up(hero);

while (hero::stamina(hero) >= 2) {
adventure::slay_boar_king(hero, ctx);
};

Crack the random number

Now, you have a treasury box. Will you just try invoking inventory::get_flag()? I did and my lovely box was gone. If you read through the get_flag() implementation, you’ll see that you can only get one flag with more than 100 boxes. For sure, you can try it but I don’t think it’s the right way to go.

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

This is reminiscent of the pseudo-random problem that happened on Ethereum back in 2018. We can probably generate a d100 in our own smart contract and invoke get_flag() if and only if d100 == 0. This theory seems to work as the ctx is the only thing that affects the seed.

      /// Generate a random integer range in [low, high).
public fun rand_u64_range(low: u64, high: u64, ctx: &mut TxContext): u64 {
rand_u64_range_with_seed(seed(ctx), low, high)
}

However, it’s not that easy. We tested two consecutive random::rand_u64_range(0, 100, ctx) calls in the same tx, but ended up with two different random numbers. How come?

Random Seed

The random::seed() function actually generates a seed by hashing the content of ctx and the uid created by object::new(ctx) as shown in the code snippet below:

 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
}

It seems we can compute the same seed in our own function. But, the uid is an u64 number which increments in each object::new(ctx) call.

 public fun new(ctx: &mut TxContext): UID {
UID {
id: ID { bytes: tx_context::new_object(ctx) },
}
}

Specifically, if you check object.move and tx_context.move in the Sui framework, you’ll find out that the ctx.ids_created number would be used to generate the uid and that number would be incremented by one before the object::new(ctx) call is done.

    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
}

So, we need to do the new_object() thing locally without updating ids_created so that we can predict the next random number and let get_flag() generate the same number. However, we can’t simply copy and paste the above code into our own module due to the fact that all members defined in the TxContext structure can only be accessed in the tx_context module.

      struct TxContext has drop {
/// A `signer` wrapping the address of the user that signed the current transaction
signer: signer,
/// Hash of the current transaction
tx_hash: vector<u8>,
/// The current epoch number.
epoch: u64,
/// Counter recording the number of fresh id's created while executing
/// this transaction. Always 0 at the start of a transaction
ids_created: u64
}

BCS

While reading through the Sui framework codebase and the random module, we noticed that there’s a sui::bcs module which could be used to serialize and deserialize all formats of data. So, we gave it a try.

     fun my_new_object(ids_created: u64, ctx: &mut TxContext): vector<u8> {
let ctx_bytes = bcs::to_bytes(ctx);
let prepared: BCS = bcs::new(ctx_bytes);
let (_, tx_hash) = (
bcs::peel_address(&mut prepared),
bcs::peel_vec_u8(&mut prepared),
);
let id = derive_id(tx_hash, ids_created);
id
}

See our version of new_object() above. We simply serialized ctx into ctx_bytes and retrieved the tx_hash out with peel_vec_u8().

derive_id()

Now, we can do object::new_object() on our own except the derive_id() native function. At some point, we thought we could simply declare that native function in our own module and invoke it in the same way as the tx_context module does.

    /// Native function for deriving an ID via hash(tx_hash || ids_created)
native fun derive_id(tx_hash: vector<u8>, ids_created: u64): address;

native fun derive_id(tx_hash: vector<u8>, ids_created: u64): address;

However, that’s not the case. And, this is the hardest part of this simple challenge.

Hash Function

As we see the comments came with the declaration of the native function, we should do hash(tx_hash || ids_created) in our own module. But, what hash function should we use?

Again, we searched the Sui codebase and found the derive_id() implementation in crates/sui-types/src/base_types.rs:

   /// 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[0..ObjectID::LENGTH]).unwrap()
}

Eventually, sha3_256() in std::hash below seems the one we should use in our own module.

module std::hash {
native public fun sha2_256(data: vector<u8>): vector<u8>;
native public fun sha3_256(data: vector<u8>): vector<u8>;
}native public fun sha2_256(data: vector<u8>): vector<u8>;

to_le_bytes() and ObjectID::LENGTH

Now, we know that we should pack tx_hash and ids_created into a vector<u8> and invoke sha3_256() to get the hash we want. However, it’s not that straightforward. After a couple of hours of debugging, we noticed that the native rust function to_le_bytes(ids_created) should have been done this way:

vector[(ids_created as u8), 0, 0, 0, 0, 0, 0, 0]

And, we should cut the first 20 bytes of the hash out to get the ObjectID.

fun derive_id(tx_hash: vector<u8>, ids_created: u64): vector<u8> {
let info: vector<u8> = vector::empty<u8>();
vector::append<u8>(&mut info, tx_hash);
vector::append<u8>(&mut info, vector[(ids_created as u8), 0, 0, 0, 0, 0, 0, 0]);
let hash: vector<u8> = hash::sha3_256(info);
let length = vector::length(&mut hash);
while ( length > 20 ) {
vector::remove(&mut hash, length-1);
length = length - 1;
};
hash
}

Finally, we can derive the same hash and crack the random number.

Flash Loan

This is a challenge that teaches you how to perform a flash loan in Move and Sui.

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

Since the get_flag() function checks if the current balance is zero, we just need to flash::loan() all 1000 coins before invoking the get_flag() function. However, both loan and receipt returned by flash::loan() do not implement the drop crate such that the compiler won’t let you walk away with the loan and receipt in nondeterministic states.

After reading through the flash module codebase, repay() and check() seem to be the functions to consume the loan and receipt. Therefore, we solve the challenge as follows:

    public entry fun solve(flash_lender: &mut FlashLender, ctx: &mut TxContext) {
let (loan, receipt) = flash::loan(flash_lender, 1000, ctx);
flash::get_flag(flash_lender, ctx);
flash::repay(flash_lender, loan);
flash::check(flash_lender, receipt);
}

Move Lock

This challenge is similar to solving a matrix-vector multiplication problem. Specifically, we need to find out two valid vectors, data1 and data2, to solve the challenge. In particular, data1 is part of the complete_plaintext and data2 is the encryption key. The product of complete_plaintext and data2 should match the given encrypted_flag. We used the z3 solver to find the solution.

Since part of the complete_plaintext is given, we can use z3 to find a solution of data2. We limited all elements of data2 in the range 0 to 26 to reduce the search space of z3.

key = [ Int('key_'+str(i))   for i in range(3*3) ]
a11, a12, a13 = key[0:3]
a21, a22, a23 = key[3:6]
a31, a32, a33 = key[6:9]

s = Solver()
for k in key:
s.add(And(k >= 0, k < 26))
for i in range(0, 9, 3):
p11,p21,p31 = complete_plaintext[i:i+3]
c11 = ( (a11 * p11) + (a12 * p21) + (a13 * p31) ) % 26
c21 = ( (a21 * p11) + (a22 * p21) + (a23 * p31) ) % 26
c31 = ( (a31 * p11) + (a32 * p21) + (a33 * p31) ) % 26

s.add(And(c11 == ciphertext[i], c21 == ciphertext[i+1], c31 == ciphertext[i+2]))
s.check()

m = s.model()

Then we could solve data1 with data2 (i.e., key) and the given encrypted_flag.

key = [25, 11, 6, 10, 13, 25, 12, 19, 2]
a11, a12, a13 = key[0:3]
a21, a22, a23 = key[3:6]
a31, a32, a33 = key[6:9]

data1 = []
for i in range(9, len(complete_plaintext), 3):
s = Solver()

p11,p21,p31 = complete_plaintext[i:i+3]
s.add(And(p11 >= 0, p21 >= 0, p31 >= 0))
c11 = ( (a11 * p11) + (a12 * p21) + (a13 * p31) ) % 26
c21 = ( (a21 * p11) + (a22 * p21) + (a23 * p31) ) % 26
c31 = ( (a31 * p11) + (a32 * p21) + (a33 * p31) ) % 26

s.add(And(c11 == ciphertext[i], c21 == ciphertext[i+1], c31 == ciphertext[i+2]))
s.check()
m = s.model()
data1 += [m.eval(p11), m.eval(p21), m.eval(p31)]

print(data1)

Finally, we solved the challenge with those numbers.

     public entry fun solve(resource_object: &mut ResourceObject, ctx: &mut TxContext) {
let data1 : vector<u64> = vector[28, 14, 13, 32, 17, 0, 19, 46, 11,
0, 19, 8, 14, 13, 18, 24, 14, 20,
12, 0, 39, 0, 6, 30, 3, 19, 40,
1, 17, 4, 26, 10, 19, 7, 4, 7,
34, 11, 11, 2, 34, 15, 33, 4, 17,
7, 0, 28, 10, 19, 7, 4, 33, 0,
2, 62, 24, 15, 37, 0, 13, 30, 19];
let data2 : vector<u64> = vector[25, 11, 32, 10, 13, 25, 38, 19, 2];
move_lock::movectf_unlock(data1, data2, resource_object, ctx);
move_lock::get_flag(resource_object, ctx);
}

Disclaimer

The information contained in this post (the “Information”) has been prepared solely for informational purposes, is in summary form, and does not purport to be complete. The Information is not, and is not intended to be, an offer to sell, or a solicitation of an offer to purchase, any securities. The Information does not provide and should not be treated as giving investment advice. The Information does not take into account specific investment objectives, financial situation or the particular needs of any prospective investor. No representation or warranty is made, expressed or implied, with respect to the fairness, correctness, accuracy, reasonableness or completeness of the Information. We do not undertake to update the Information. It should not be regarded by prospective investors as a substitute for the exercise of their own judgment or research. Prospective investors should consult with their own legal, regulatory, tax, business, investment, financial and accounting advisers to the extent that they deem it necessary, and make any investment decisions based upon their own judgment and advice from such advisers as they deem necessary and not upon any view expressed herein.

--

--