Table of Contents
Challenge
I developed a new video streaming service just for hackers. Learn all about viruses, IP addresses, and more on LeetTube! Here's the source code and the Dockerfile.
Note: the server is also running behind NGINX.
The application is simple enough--It serves videos and has both public and unlisted videos. The Dockerfile uses FROM kmh11/python3.1
, which is weird because Python 3.1 was released nearly 11 years ago. To ensure the name wasn't misleading, I ended up verifying the Python binary in the docker image, but I didn't find any related vulnerabilities. However, there is a noticeable vulnerability in how paths are handled.
#!/usr/bin/env python
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import os
videos = []
for file in os.listdir('videos'):
os.chmod('videos/'+file, 0o600)
videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()})
published = []
for video in videos:
if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename
else: published.append(video)
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
self.path = urllib.parse.unquote(self.path)
if self.path.startswith('/videos/'):
file = os.path.abspath('.'+self.path)
try: video = open(file, 'rb', 0)
except OSError:
self.send_response(404)
self.end_headers()
return
reqrange = self.headers.get('Range', 'bytes 0-')
ranges = list(int(i) for i in reqrange[6:].split('-') if i)
if len(ranges) == 1: ranges.append(ranges[0]+65536)
try:
video.seek(ranges[0])
content = video.read(ranges[1]-ranges[0]+1)
except:
self.send_response(404)
self.end_headers()
return
self.send_response(206)
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Content-Type', 'video/mp4')
self.send_header('Content-Range', 'bytes '+str(ranges[0])+'-'+
str(ranges[0]+len(content)-1)+'/'+str(os.path.getsize(file)))
self.end_headers()
self.wfile.write(content)
elif self.path == '/':
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(("""
<style>
body {
background-color: black;
color: #00e33d;
font-family: monospace;
max-width: 30em;
font-size: 1.5em;
margin: 2em auto;
}
</style>
<h1>LeetTube</h1>
<p>There are <strong>"""+str(len(published))+"</strong> published video"+('s' if len(published) > 1 else '')+" and
<strong>"+str(len(videos)-len(published))+"</strong> unpublished video"+('s' if len(videos)-len(published) > 1 else
'')+".</p>"+''.join("<h2>"+video["title"]+"</h2><video controls src=\""+video["path"]+"\"></video>" for video in published))
.encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
except:
self.send_response(500)
self.end_headers()
httpd = HTTPServer(('', 8000), RequestHandler)
httpd.serve_forever()
Local File Inclusion?
It adds the user-supplied path to the base path:
self.path = urllib.parse.unquote(self.path)
if self.path.startswith('/videos/'):
file = os.path.abspath('.'+self.path)
This means if we send /videos/../../../etc/passwd
, we should receive the passwd file.
Bypassing Nginx WAF
Unfortunately, Nginx blocks the request:
My team was stuck at this point for nearly an entire day. After reading the documentation for BaseHTTPRequestHandler
, I realized that it didn't have a query
variable. This meant that self.path
included the query parameters. However, this application did not process the query string.
This means we can do the following: https://leettube.2020.chall.actf.co/videos/../?/../../
. Nginx will treat the last two ../
s as part of the query parameter, while LeetTube will happily include it into the path and resolve the path to ./../
.
LeetTube? More like LeakTube
We don't know the name of the flag, and even if we did, we can't read it directly due to the chmod
at the beginning of the script. An interesting target is /proc/self/
. We initially tried to read files from /proc/self/fd
, but we didn't receive any output.
The psuedofile /proc/self/mem
contains the memory of the calling program. Luckily, we can seek into the file at an arbitrary location using the Range
header. By reading /proc/self/maps
, we can find the address of the Python heap:
In the Python heap, only UTF-8 strings containing the name of the loaded files were present. From here, we decided to dump other areas of the Python process's memory. We found the contents of the video files in an anonymous memory page:
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?/../../proc/self/mem' -H "Range: bytes $(python3 -c 'print(f"{0x7f4d4c225000}-{0x7f4d4cae6000}")')" --output memory.dump
Finding the Flag
We're looking for the .mp4
file containing the flag. The MP4 header is 66 74 79 70
at offset 4
. From this we can search for these characters and extract the file. We get a video with the flag: