Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
MiscMedium

Only Builtins

CTF writeup for Only Builtins from AmeteurCTF

//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_execve on /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 flag amateursCTF{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:

python

r"^(?:__auto_type v\d+=__builtin_[a-z_]+\((?:v\d+(?:,v\d+)*)?\);)+$"

Implications:

  • Every statement must declare a new __auto_type vN and immediately assign it to a builtin call.

  • All call arguments can only be previously created vX variables (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

  1. Extracted the archive and inspected chal.py, flag.txt, and Dockerfile to confirm the environment (Ubuntu 22.04, stock GCC).

  2. Enumerated which builtins actually link without extra libraries by compiling tiny probes (e.g., __builtin_memcpy, __builtin_execve, etc.). Math-only builtins like __builtin_sin failed due to the missing math library.

  3. Verified runtime behavior using ad-hoc C files and strace to ensure that invoking __builtin_execve works when passed pointers created inside the payload.

>Payload Strategy

Main hurdles:

  • No integer literals: had to synthesize 0/1 and larger integers via builtin side effects.

  • No string literals: needed to write /bin/sh\0 purely via memory ops.

  • Regex bans commas except inside builtin argument lists, so pointer arithmetic had to be expressed through successive mem* operations.

Key ideas:

  1. __builtin_huge_val, __builtin_isgreater, and __builtin_isinf_sign together produce deterministic zero/one constants.

  2. __builtin_clz, __builtin_clzll, and __builtin_popcount* derive fixed integers like 63, 31, 6, 5 which become our building blocks.

  3. __builtin_calloc gives us writable zeroed memory; repeated __builtin_memset/__builtin_mempcpy fill buffers with crafted bytes whose lengths equal previously synthesized integers.

  4. __builtin_strlen returns the length of the fabricated buffer, letting us “name” new integers for future steps.

  5. Once /bin/sh\0 exists 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:

c

__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:

python

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:

  1. Seed base constants via init().

  2. Call ensure_int for every integer needed (2,4,8,16,32,47,98,104,105,110,115).

  3. Write /bin/sh byte-by-byte using the cached ASCII values.

  4. 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:

python

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

  1. Generated a fresh payload: python3 gen_payload.py > payload.txt.

  2. Verified locally with python3 solve.py (optional, but ensured no regressions).

  3. Hit the remote challenge:

bash

$ 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_strlen plus 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

  1. Ensure /tmp/work exists or adjust permissions (the challenge expects it for TemporaryDirectory).

  2. Run python3 gen_payload.py > payload.txt to emit the exploit.

  3. For local testing: python3 solve.py.

  4. 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}.