Back to all stories
Aptos Capture The Flag - MOVEment 2022

CTF MOVEment is a contest hosted on Aptos that offers participants the chance to learn about Move – a programming language used to build secure smart contracts, and the differences between Move-compatible blockchains.

Readers can also refer to a writeup of a previous Sui-based CTF writeup: Move Capture the Flag Competition Recap to get more insight into how to Move programming language operates on different chains. We are happy to share our learning process again.

Move has gained popularity recently due to the rise of multiple Move-compatible blockchain ecosystems such as Aptos and Sui. For more information about Move, see an Introduction to Move and Moving the Immovables: Lessons Learned From Our Aptos Smart Contract Audit.

Aptos Capture The Flag - MOVEment 2022

Challenge 1 - CheckIn

The first challenge was a warm-up exercise to help get us familiar with how a CTF competition works. The source code for this challenge can be found here. The goal for this challenge is to invoke the get_flag() function which will generate a Flag event.

The challenge can be finished with the following steps:

  1. Create an account and request gas:

aptos init --assume-yes --network custom --rest-url --faucet-url

  1. Request gas for an account:
aptos init --assume-yes --network custom --rest-url --faucet-url --account [AccountAddress] --amount [Amount]
  1. Click the "Deploy" button to let the platform deploy the module. It will return the module and deployment transaction information.

  2. Interact with the module based on the given information in the previous step and get the transaction hash:

aptos move run --function-id [ModuleAddress]::[Module]::[Function]

For example:

aptos move run --function-id 0x01315913bcdf7cc310c2ac1b394a9c4ce7e3b726de4cb634031198545ac34d2d::checkin::get_flag

  1. Click the "Next" button to let the platform redirect to the transaction validation step. Input the transaction hash and click "Get Flag" to verify the result.

Here is some helpful documentation for working with the Aptos client tool and SDK, which helps accomplish the following challenges:

Aptos Client documents: Use CLI for Configuration | Aptos Docs

Aptos SDK: Use Aptos SDKs | Aptos Docs

Aptos API Spec: Aptos API Spec | Aptos Docs

Challenge 2 - HelloMove

The second challenge is a combination of three puzzles. Once all three puzzles are solved, the restrictions res.q1 && res.q2 && res.q3 in the get_flag function will be unlocked, and we can trigger the Flag event.


Puzzle 1

The first puzzle requires us to provide a guess vector with length four and then append the guess with the bytes 109, 111, 118, and 101 (which is "move" in ASCII). Then the code calculates the Keccak-256 hash of the modified guess vector and checks if it matches the expected hash value. If the hashes match, the code updates the q1 flag to be true.

We decided to brute force this puzzle since we only needed to guess four numbers in u8. There are 1/2^32 chances of getting the correct answer, which is not bad.

Puzzle 2

The second puzzle is pow(10549609011087404693, guess, 18446744073709551616) == 18164541542389285005. The pow function looks complicated, so we decided to try a novel approach: asking ChatGPT for help.

movectf2 The function in question.

movectf3 ChatGPT's summary.

Let's ask ChatGPT if we can solve this with a discrete logarithm function.


Now, we can use the index calculus algorithm(discrete_log) to solve this puzzle which can be found in the Sympy library:


Puzzle 3

The third puzzle provides three operations on balance with the input number: add, multiply and left-shift. The goal is to make the balance less than the initial balance.

All three operations seem to increase the balance. The first idea that jumps to mind is the integer overflow, and the Move language has built-in protection against overflow. However, the bitwise shift operator does not have this protection, so it is essential to consider whether overflow is acceptable when using the shift operator. In this particular puzzle, we can use the left-shift operator repeatedly until an overflow occurs.


The rest work is simple. We gathered all three solutions in one module (alternatively, prepare a script) and invoked a transaction to capture the flag.

Challenge and solution source code

Challenge 3 - SimpleSwap

This challenge creates an AMM implementation including the following modules:

  • swap: Handle the core functionality of the AMM, such as creating new token pairs, adding and removing liquidity from the pairs, and swapping tokens.

  • simple_coin: Creating coins used in the AMM includes the most important function: get_flag(). The flag event can be triggered when the user's simple coin balance is greater than 10^10.


  • router: provides additional functionality on top of the swap module, which serves as an entry point for adding and removing liquidity and swapping tokens.

  • swap_utils: provides utility functions for the swap module.

The main functionality is implemented inside the swap.move.

  • init_module: initializes the test environment with <simple_coin, test_usdc> pair and add liquidity with 10^10 in each token.

  • create_pair: creates a new token pair/pool

  • add_liquidity: adds liquidity to the pool

  • remove_liquidity: removes liquidity

  • swap_exact_x_to_y<X, Y>: swaps X to Y tokens and will charge a fee and distribute a reward

  • swap_exact_y_to_x<X, Y>: swaps Y to X tokens and will charge a fee and distribute a reward


As we do not have the mint capability to mint more than 10^10 simple_coin, we need to crack the swap module to drain simple_coin inside the pool. The good news is we can mint an unlimited amount of test_usdc via function claim_faucet, which can be used to swap simple_coin out.


Another question for us is how to get more than 10^10 simple_coin since there is exactly 10^10 simple_coin in the pool. After going through the code, we find two points of interest:

The functions swap_exact_x_to_y_direct<X, Y> and swap_exact_y_to_x_direct<X, Y> are public functions without taking fees.

simple_coin can be minted as a reward during the swap:

For swap_exact_x_to_y_direct<X, Y>: X is the IN token and Y is the OUT token. If Y is SimpleCoin, the user will get SimpleCoin as a reward.

For swap_exact_y_to_x_direct<X, Y>: Y is the IN token and X is the OUT token. If X is SimpleCoin, the user will get SimpleCoin as a reward.


It turns out that by swapping for a certain amount each time, we need around 300 swaps to get more than 10^10 SimpleCoin, which exceeds the gas limit on Aptos. To quickly resolve the issue, we split the 300 times swap into six separate calls and got the flag. Well, it's complicated.

Therefore, we started thinking about how we should optimize our strategy. The main goal is to get as many rewards as possible in the first few rounds. Thus, we tried increasing the input amount five times to counteract the impact of the previous swapping and gain more SimpleCoin from the output. Finally, by using the following strategy, we drained more than 10^10 SimpleCoin in 13 swaps:

Screenshot 2022-12-20 at 4.45.59 PM

As a result of the 13th swap, we have accumulated 10003862878 SimpleCoin, which is enough to trigger the flag event and obtain the flag.

Challenge and solution source code

Challenge 4 - SwapEmpty

The fourth challenge is another swap module that contains the following major functions:

init_module is invoked during the first time deployment. It creates a pool with 50 Token1 and 50 Token2.

get_coin issues 5 Token1 and 5 Token2 to a user one time.

swap_12 and swap_21 swap Token1 to Token2 and vice versa.

get_amouts_out is used in swap_12 or swap_21 to calculate the output.

get_flag requires the balance of Token1 or Token2 in the pool to be 0 to trigger the Flag event.



Before beginning the implementation, we are aware of the following:

Users can require 5 Token1 and 5 Token2. At the same time, the pool only has 50 Token1 and 50 Token2. We should be able to create slippage easily.

The implemented formula for swap is (out token amount) = (in token amount) * (out token balance) / (in token balance). In this design, if we lower the balance of either token in the pool, the token's price would increase, so we can always swap a token for the other and swap back to get more tokens.

There is no limitation on the amount out of Token1 or Token2 in get_amouts_out. We are thus able to drain one side.

We can simulate the challenge as follows. Each time we use all Token1 or Token2 to invoke swap functions, and as a result of price slippage, we can get more tokens (Token1 or Token2) during each swap.

Screenshot 2022-12-20 at 4.51.45 PM

As a result, we can get 55 Token1 and 7 Token2 while only 48 Token2 are left in the pool. We successfully drain all Token1 from the pool.

Challenge and solution source code

Challenge 5 - MoveLockV2

The fifth challenge is another puzzle. We need to invoke the unlock function with a valid p to get the flag. The unlock function performs the following process:

  1. Encrypts a BASE string using the encrypt_string function.

  2. Creates a resource address from the encrypted string and ctfmovement address.

  3. Converts the resource address to a vector<u8> cof using the to_bytes function from the bcs module.

  4. Modify the cof vector with some operations.

  5. Constructs a Polynomial object using the degree and coefficients from the previous step.

  6. Generates a random number and evaluates the polynomial at that point.

  7. If the polynomial evaluates to the same value as the input p, it calls the get_flag function and returns true, indicating that the lock was successfully unlocked. Otherwise, it returns false without emitting the flagged event.



Upon initial examination of the project, we observe that:

encrypt_string uses transaction_context::get_script_hash(), which should be a fixed value for the whole transaction. We can find the implementation in the Aptos Core Github repository.

Note: for this test, we can only use the script instead of the entry function, otherwise there could be an error since the get_script_hash will return 0. More detail about script hash generation can be found here.

An increment() function will affect the gen_number() function and further influence the cof vector output and the encryption result.

To avoid any inconsistency during the process, we create a mock module to compute the result and feed the result to ctf::unlock() function to solve the challenge.



With this process, we use the mock module to generate a value in the current transaction context and use this value as the input for the original unlock function in the move_lock module. Since the evaluation processes in the mock module and the original move_lock module are the same, the results would match and thus trigger the flagged event.

Challenge and solution source code