A challenge I authored for SekaiCTF 2024.
Setup
The challenge is only 10 loc:
# app.py
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import FileResponse
async def download(request):
return FileResponse(request.query_params.get("file"))
app = Starlette(routes=[Route("/", endpoint=download)])
The flag is put into an envvar:
FROM python:3.9-slim
RUN pip install --no-cache-dir starlette uvicorn
WORKDIR /app
COPY app.py .
ENV FLAG="SEKAI{test_flag}"
CMD ["uvicorn", "app:app", "--host", "0", "--port", "1337"]
Why /proc/self/environ fails directly
Since the app gives us a direct file read primitive, why not just request /?file=/proc/self/environ and read the flag from the environment variables?
The issue is that FileResponse.__call__ calls os.stat(path) first to set the Content-Length header, then opens and streams the file:
# starlette/responses.py
310 def set_stat_headers(self, stat_result: os.stat_result) -> None:
311 content_length = str(stat_result.st_size)
# ...
316 self.headers.setdefault("content-length", content_length)
320 async def __call__(self, scope, receive, send) -> None:
# ...
323 stat_result = await anyio.to_thread.run_sync(os.stat, self.path)
324 self.set_stat_headers(stat_result)
# ...
343 async with await anyio.open_file(self.path, mode="rb") as file:
stat() on any procfs file returns st_size = 0, so Content-Length: 0 goes in the response headers. When Starlette then reads and streams the actual file content, h11’s ContentLengthWriter raises LocalProtocolError("Too much data for declared Content-Length") and the request errors out before the body is sent.
The TOCTOU race
FileResponse uses the path string twice: once for stat(), once for open_file(). If the target of the path changes between the two calls, the Content-Length and the body come from different files.
/proc/self/fd/N is a symlink to whatever file descriptor N has open. stat(/proc/self/fd/N) follows the symlink, so it reports the size of the actual file pointed to by fd N, not 0.
Idea:
- req A: start serving a big existing file (e.g.
/etc/passwd). This opens it as fd N inside uvicorn’s worker. - req B: request
/?file=/proc/self/fd/N.stat(/proc/self/fd/N)follows the symlink to/etc/passwd, gets a real non-zero size.Content-Lengthis set and headers are sent. - req A finishes: fd N is closed and recycled.
- req C: request
/?file=/proc/self/environ. This opens environ as the new fd N. - req B resumes:
open_file(/proc/self/fd/N)now opens/proc/self/environ. The body bytes are sent to the client before h11 raises"Too little data for declared Content-Length"and the connection drops.
The client receives the environ bytes even though the server errors out, because uvicorn flushes body chunks before the Content-Length mismatch is detected at send_eom. A raw socket client reads until the connection closes regardless of Content-Length, so it captures the partial response.
Solve
Full solver: web/funny-lfr/solution/exp.py.