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 Flag
event.
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]
For example:
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 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.
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.
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:
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
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
Analysis
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:
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
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.
Analysis
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.
Challenge and solution source code
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:
Encrypts a BASE
string using the encrypt_string
function.
Creates a resource address from the encrypted string and ctfmovement
address.
Converts the resource address to a vector<u8>
cof
using the to_bytes
function from the bcs
module.
Modify the cof vector with some operations.
Constructs a 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.
Analysis
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.