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.
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
The challenge can be finished with the following steps:
aptos init --assume-yes --network custom --rest-url https://fullnode.devnet.aptoslabs.com --faucet-url https://faucet.devnet.aptoslabs.com
aptos init --assume-yes --network custom --rest-url https://fullnode.devnet.aptoslabs.com --faucet-url https://faucet.devnet.aptoslabs.com --account [AccountAddress] --amount [Amount]
Click the "Deploy" button to let the platform deploy the module. It will return the module and deployment transaction information.
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]
aptos move run --function-id 0x01315913bcdf7cc310c2ac1b394a9c4ce7e3b726de4cb634031198545ac34d2d::checkin::get_flag
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
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.
The first puzzle requires us to provide a
guess vector with length four and then append the
guess with the bytes
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.
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.
The function in question.
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:
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.
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
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
The main functionality is implemented inside the
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
Y tokens and will charge a fee and distribute a reward
swap_exact_y_to_x<X, Y>: swaps
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
Another question for us is how to get more than
simple_coin since there is exactly
simple_coin in the pool. After going through the code, we find two points of interest:
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:
X is the IN token and
Y is the OUT token. If
Y is SimpleCoin, the user will get
SimpleCoin as a reward.
Y is the IN token and
X is the OUT token. If
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
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:
As a result of the 13th swap, we have accumulated
10003862878 SimpleCoin, which is enough to trigger the flag event and obtain the flag.
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_21 swap Token1 to Token2 and vice versa.
get_amouts_out is used in
swap_21 to calculate the output.
get_flag requires the balance of Token1 or Token2 in the pool to be
0 to trigger the
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.
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.
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:
BASE string using the
Creates a resource address from the encrypted string and
Converts the resource address to a
cof using the
to_bytes function from the
Modify the cof vector with some operations.
Polynomial object using the degree and coefficients from the previous step.
Generates a random number and evaluates the polynomial at that point.
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:
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.
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.