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:

  1. req A: start serving a big existing file (e.g. /etc/passwd). This opens it as fd N inside uvicorn’s worker.
  2. 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-Length is set and headers are sent.
  3. req A finishes: fd N is closed and recycled.
  4. req C: request /?file=/proc/self/environ. This opens environ as the new fd N.
  5. 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.