This is a CTF pwn challenge that I wrote in C which requires the user to exploit the CVE-2021-4034 vulnerability. Players are given 2 binaries in the challenge directory in this repo. The chal binary implements the CTF challenge and the shelly.so is a helper binary.
At the time of writing this writeup, the Dockerfile is still not complete. The Dockerfile is required to deploy this challenge during a live CTF, but not locally: You can still emulate this challenge locally by setting up the user permissions and installing the vulnerable packages as follows:
- In order to successfully exploit this vulnerability, players who are not on a Linux machine should first install a Linux VM. Then, you will need to install a vulnerable kernel. Instructions on how to that: [https://askubuntu.com/a/700221]
- Install the packages
libpolkit-gobject-1-0=0.105-26ubuntu1 libpolkit-agent-1-0=0.105-26ubuntu1 policykit-1=0.105-26ubuntu1. - Create a
flag.txtfile owned byroot:rootin the current directory. - Create an unprivileged user. Switch to this user.
- Download the files in the
challengefolder to the current directory. - Run
chalas the unprivileged user.
Running the chal binary gives us a vague idea of what this binary does:
WELCOME TO THE HUB CTRL+ALT+DELICIOUS
We're not just a sandwich hub. We are the beacon of flavors, serving a symphony in every byte
1. ENTER THE HUB
2. QUIT
1
Order number: 0x7ffde93681f0
Enter your name: tin
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
1
Pick your bread: aaaa
Select your spread: bbbb
Choose your veg: cccc
Slam your meat & egg: dddc
Any side notes for the cook? 0000
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
1
Pick your bread: AAAA
Select your spread: BBBB
Choose your veg: CCCC
Slam your meat & egg: DDDD
Any side notes for the cook? 1111
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
3
Enter order index: 0
aaaa, bbbb, cccc, dddc, 0000
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
3
Enter order index: 1
AAAA, BBBB, CCCC, DDDD, 1111
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
4
Enter order index: 1
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
3
Enter order index: 1
Invalid index!
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
5
/notes ./tin/notes
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
6
1. ENTER THE HUB
2. QUIT
2
Come again :)
Playing around with the input size in the Add function and the indices of the Cancel functions don't give us anything special (no overflow or segmentation fault). Though, some interseting things:
- It looks like the orders are place in a list (linked list perhaps) and they are 0-indexed?
- There is this
Order number: 0x7ffde93681f0which seems to print some location on the stack? - Also, the program creates a directory with our input name along with 3 executables, one of which is the helper binary file we are given:
peasant@Tin-VM:~/Desktop$ ls
chal chal.c Dockerfile shelly.so solve.py tin
peasant@Tin-VM:~/Desktop$ ls -l tin/
total 28
-rwxrwx--- 1 peasant vboxsf 5 Feb 4 14:33 notes
-rwxrwx--- 1 peasant vboxsf 20 Feb 4 14:33 recipe
-rwxr-x--- 1 peasant vboxsf 16488 Feb 4 14:33 shelly.so
peasant@Tin-VM:~/Desktop$ cat tin/notes
0000
peasant@Tin-VM:~/Desktop$ cat tin/recipe
aaaa
bbbb
cccc
dddc
The executables contain our input.
You can play around with the other functions and hope that you might run into some bugs (which is probable), but I'm gonna cut to the chase and open the program in Ghidra.
Comparing the strings that appear while running the program and the strings that are present in Ghidra, we can rename some of the FUN_* functions into familiar names:
undefined8 main(void)
{
int iVar1;
size_t sVar2;
undefined2 *puVar3;
long in_FS_OFFSET;
int opt;
int local_1c;
char *local_18;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_18 = "/recipe";
print("WELCOME TO THE HUB CTRL+ALT+DELICIOUS\n");
print(
"We\'re not just a sandwich hub. We are the beacon of flavors, serving a symphony in every by te\n\n"
);
while( true ) {
print("1. ENTER THE HUB\n");
print("2. QUIT\n");
__isoc99_scanf(&DAT_001030c5,&opt);
getc(stdin);
if (opt != 1) break;
printf("Order number: %p\n",&local_18);
print("Enter your name: ");
__isoc99_scanf(&DAT_0010334f,&DAT_00105120);
sVar2 = strlen(&DAT_00105120);
puVar3 = (undefined2 *)malloc(sVar2 + 2);
DAT_00105100 = puVar3;
*puVar3 = 0x2f2e;
*(undefined *)(puVar3 + 1) = 0;
strcpy((char *)(DAT_00105100 + 1),&DAT_00105120);
iVar1 = FUN_001022f0(DAT_00105100,&DAT_00105060);
if (iVar1 == -1) {
mkdir((char *)DAT_00105100,0x1c0);
}
DAT_00105140 = 0;
order_cnt = 0;
for (local_1c = 0; local_1c < 10; local_1c = local_1c + 1) {
*(undefined8 *)(&ptr_array + (long)local_1c * 8) = 0;
}
main_menu();
}
print("Come again :)\n");The printf("Order number: %p\n",&local_18); prints out a location of a local variable on the stack.
A quick check in gdb shows us that the leaked address is the address of the pointer to the constant string /recipe:
...
Order number: 0x7fffffffdfc0
...
gef➤ x/gx 0x7fffffffdfc0
0x7fffffffdfc0: 0x0000555555559020
gef➤ x/s 0x0000555555559020
0x555555559020: "/recipe"We see a mkdir call, which creates a directory with our input name in the current directory.
These are consistent with our observation when we ran the program. Then it initializes some variable before calling the main_menu function, which looks something like:
while( true ) {
while( true ) {
while( true ) {
while( true ) {
while( true ) {
while( true ) {
print("1. ADD NEW ORDER\n");
print("2. EDIT ORDER\n");
print("3. SHOW ORDER\n");
print("4. CANCEL ORDER\n");
print("5. CHECKOUT\n");
print("6. DONE\n");
__isoc99_scanf(&DAT_001030c5,&local_40);
getc(stdin);
if (local_40 != 1) break;
add_order();
}
if (local_40 != 2) break;
edit_order();
}
if (local_40 != 3) break;
show_order();
}
if (local_40 != 4) break;
cancel_order();
}
if (local_40 != 5) break;
checkout();
}
if (local_40 == 6) break;
if (local_40 == 0x539) {
print(
"\nGORDON RAMSAY: Finally, a worthy opponent, our battle will be legendary! I BET YOU CAN \'T GUESS THE SECRET RECIPE.\n"
);
fgets(inp,0x20,stdin);
getrandom(random-bytes,0x10,0);
for (local_3c = 0; local_3c < 0x10; local_3c = local_3c + 1) {
if (inp[local_3c] != random-bytes[local_3c]) {
print("...*Nuh Uh!*...\n");
/* WARNING: Subroutine does not return */
exit(0);
}
print("...*Ooh Yes.. sCruMpTioUs*...");
}
print("Fine... I\'ll give you a taste.\n");
FUN_00101504();
}If you're not familiar with REV, this is the decompilation for the switch statement in C. There's one interesting option a.k.a 0x539. It allows the players to guess 0x10 random bytes. If all the bytes are equal, then it calls FUN_00101504(); which calls system("cat flag.txt");. Otherwise, the program exits.
However, bruteforcing 16 random bytes is equivalent to trying all 256**16 = 340282366920938463463374607431768211456 possibilities. Good luck with this one lol.
Even if you pass all the possibilities, the program is still unprivileged, so it can't read the flag. This cat flag.txt statement is intentional, not only to throw trick unexperienced players into picking this 0x539 menu option, but also to prevent the players to just simply call this function to read the flag, which we will dive into later.
int iVar1;
undefined8 *puVar2;
void *pvVar3;
undefined8 *ptr2;
if (order_cnt < 10) {
puVar2 = (undefined8 *)malloc(0x30);
print("Pick your bread: ");
readline(puVar2 + 1,8);
print("Select your spread: ");
readline(puVar2 + 2,8);
print("Choose your veg: ");
readline(puVar2 + 3,8);
print("Slam your meat & egg: ");
readline(puVar2 + 4,9);
iVar1 = order_cnt;
pvVar3 = malloc(0x30);
*(void **)(&ptr_array + (long)iVar1 * 8) = pvVar3;
*puVar2 = *(undefined8 *)(&ptr_array + (long)order_cnt * 8);
print("Any side notes for the cook? ");
readline(*puVar2,0x30);
puVar2[5] = 0;
if (DAT_00105140 != (undefined8 *)0x0) {
for (ptr2 = DAT_00105140; ptr2[5] != 0; ptr2 = (undefined8 *)ptr2[5]) {
}
ptr2[5] = puVar2;
puVar2 = DAT_00105140;
}
DAT_00105140 = puVar2;
order_cnt = order_cnt + 1;
}
...If some of the variables don't look easily readable like this for you, it's because I took some time to rename them into these, which you should do while reverse engineering to keep track of things. Alright, let's get to the main points:
puVar2is a malloc chunk that is0x30large.- 0-th field is
puVar3pointer, which is 8-byte long and points to thenotesof the current chunk. - The next 4 fields are each 8-byte long, though we can read 9 bytes into the 4-th field, so we can overflow 1 byte into the 5-th field.
- However, the line
puVar2[5] = 0;overwrites our 1-byte overflow to NULL anyway. - Then if the global variable
DAT_00105140 == 0, then we just set it to the new chunk, so this might be aheadpointer for the list? - Otherwise, we loop until the last chunk in the current list, and set its 5-th field to the new chunk. => 5-th field is the pointer to the next chunk in the linked list.
...
print("Enter order index: ");
__isoc99_scanf(&DAT_001030c5,&local_20);
getc(stdin);
if ((local_20 < 0) || (order_cnt <= local_20)) {
print("Invalid index!\n");
}
else {
local_18 = head;
for (local_1c = 0; local_1c != local_20; local_1c = local_1c + 1) {
local_18 = (undefined8 *)local_18[5];
}
print("Pick your bread: ");
readline(local_18 + 1,8);
print("Select your spread: ");
readline(local_18 + 2,8);
print("Choose your veg: ");
readline(local_18 + 3,8);WE
print("Slam your meat & egg: ");
readline(local_18 + 4,9);
print("Any side notes for the cook? ");
readline(*local_18,0x30);
}
...There's a check for our input index, so we can't edit arbitrary locations.
However, the 9-byte read in the 4-th field which overflows 1-byte into the 5-th field is still there!!! We can overwrite 1 byte into the nxt pointer
...
if (local_18 != (undefined8 *)0x0) {
printf("%s, %s, %s, %s, %s\n",local_18 + 1,local_18 + 2,local_18 + 3,local_18 + 4,*local_18);
}
...%s will print until the NULL character, so if our 4-th field is 8-byte long, then the 4th %s will print the 4-th field of our chunk + the nxt pointer value in the 5-th field.
=> We get a heap leak!!!
Nothing interseting here.
strcpy(local_d8,dir_name);
sVar2 = strlen(dir_name);
strcpy(local_d8 + sVar2,"/recipe");
creat(local_d8,0x1c0);
iVar1 = open(local_d8,2);
if (iVar1 == -1) {
print("Error opening file f.\n");
/* WARNING: Subroutine does not return */
exit(0);
}
chmod(local_d8,0x1f8);
strcpy(local_98,dir_name);
sVar2 = strlen(dir_name);
strcpy(local_98 + sVar2,"/notes");
printf("%s %s\n","/notes",local_98);
creat(local_98,0x1c0);
__fd = open(local_98,2);
if (__fd == -1) {
print("Error opening file f_notes.\n");
/* WARNING: Subroutine does not return */
exit(0);
}
chmod(local_98,0x1f8);So it creates the files recipe and notes in the directory with our input name. The chmod statements sets these files to executable mode: 0x1f8 and 0x1c0 are 0700 and 0770 in octal base respectively.
...
for (local_ec = 0; (local_e0 != (char **)0x0 && (local_ec < order_cnt)); local_ec = local_ec + 1)
{
sVar2 = strlen((char *)(local_e0 + 1));
write(iVar1,local_e0 + 1,sVar2);
write(iVar1,&DAT_00103127,1);
sVar2 = strlen((char *)(local_e0 + 2));
write(iVar1,local_e0 + 2,sVar2);
write(iVar1,&DAT_00103127,1);
sVar2 = strlen((char *)(local_e0 + 3));
write(iVar1,local_e0 + 3,sVar2);
write(iVar1,&DAT_00103127,1);
sVar2 = strlen((char *)(local_e0 + 1));
write(iVar1,local_e0 + 4,sVar2);
write(iVar1,&DAT_00103127,1);
sVar2 = strlen(*local_e0);
write(__fd,*local_e0,sVar2);
write(__fd,&DAT_00103127,1);
local_e0 = (char **)local_e0[5];
}
iVar1 = close(iVar1);
if (-1 < iVar1) {
iVar1 = close(__fd);
if (-1 < iVar1) {
local_58 = 0x2f706d742f207063;
local_50 = 0x732e796c6c656873;
local_48 = 0x206f;
local_40 = 0;
local_38 = 0;
local_30 = 0;
local_28 = 0;
local_20 = 0;
strcpy((char *)((long)&local_48 + 2),dir_name);
system((char *)&local_58);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
}
...This huge messy block of code basically writes the content in our chunks to these files, then executes the commnad cp /tmp/shelly.so dir_name, where dir_name is the name we give the program at the beginning.
void FUN_00101e84(void)
{
long in_FS_OFFSET;
char *local_18;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
puts("HOLD UP, LET HIM COOK.");
local_18 = (char *)0x0;
execve("/usr/bin/pkexec",&local_18,(char **)&ptr_array);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}This is an interesting function. It calls pkexec with a NULL argv and the notes array pointer as the environment variables, which is the vulnerable command in our CVE-2021-4034. Does this suggest that we have to craft our notes and redirect execution to this function somehow?
Opening this up in Ghidra will tell us that it's obviously a set-UID-root library, which is similar to the ones used in the CVE exploit.
Some major points:
- We have a stack leak at the beginning of the program (Order number) which holds the address of the constant string
/recipeused in creating the filerecipe. - 0th field of a chunk is its
notespointer. - 5th field of a chunk is the
nxtpointer to the next chunk in the linked list. Showcan leak a heap address which is the 5-th field in a chunk.Editcan overwrite 1 byte into the 5-th field. THIS IS THE ONLY ARBITRARY WRITE BUG!!FUN_00101e84callspkexecwith NULL argv and the environment variables that we control (notesarray).
A quick inspection in gdb can tell us the offset between different chunks in our program:
...
Pick your bread: aaaa
Select your spread: a
Choose your veg: a
Slam your meat & egg: a
Any side notes for the cook? 0000
1. ADD NEW ORDER
2. EDIT ORDER
3. SHOW ORDER
4. CANCEL ORDER
5. CHECKOUT
6. DONE
1
Pick your bread: bbbb
Select your spread: b
Choose your veg: b
Slam your meat & egg: b
Any side notes for the cook? 1111
...
gef➤ search-pattern aaaa
[+] Searching 'aaaa' in memory
[+] In '[heap]'(0x55555555a000-0x55555557b000), permission=rw-
0x55555555aae8 - 0x55555555aaec → "aaaa"
gef➤ x/30gx 0x55555555aae8-0x18
0x55555555aad0: 0x0000000000000000 0x0000000000000041
0x55555555aae0: 0x000055555555ab20 0x0000000061616161
0x55555555aaf0: 0x0000000000000061 0x0000000000000061
0x55555555ab00: 0x0000000000000061 0x000055555555ab60
0x55555555ab10: 0x0000000000000000 0x0000000000000041
0x55555555ab20: 0x0000000030303030 0x0000000000000000
0x55555555ab30: 0x0000000000000000 0x0000000000000000
0x55555555ab40: 0x0000000000000000 0x0000000000000000
0x55555555ab50: 0x0000000000000000 0x0000000000000041
0x55555555ab60: 0x000055555555aba0 0x0000000062626262
0x55555555ab70: 0x0000000000000062 0x0000000000000062
0x55555555ab80: 0x0000000000000062 0x0000000000000000
0x55555555ab90: 0x0000000000000000 0x0000000000000041
0x55555555aba0: 0x0000000031313131 0x0000000000000000
0x55555555abb0: 0x0000000000000000 0x0000000000000000So the chunks are of size 0x40 (including 0x10 bytes of metadata), and they are separated (from start of current chunk to start of next chunk) by an offset of 0x40+0x40 = 0x80. This is because the notes are allocated whenever we allocate a chunk so they always fall in between consecutive chunks.
Before we jump into the exploit, how do we abuse the 1-byte overflow bugs to write to an arbitrary address? We can use the following strategy:
- Allocate 3 chunks A , B , C.
- Leak the
nxtpointer of the 2nd chunk (2nd point in the Summary session).- The leaked address (which is the next chunk) is
C. - Then, the current chunk's address will be
B = C-0x80. - Let the LEAST significant byte of
Bbex.
- The leaked address (which is the next chunk) is
- Overflow the 1 byte
x + 8into the 5th field of the current chunk. The resultingnxtpointer will beB + 8.- We gotta be careful here: using the snippet of memory above, if we let the current chunk be the first one a.k.a
A = 0x55555555aae0, then the leakd address would beB = 0x55555555ab60andx = 0xe8. - If we overwrite the last byte into the 5th field of
Aat0x55555555ab08withx, the resultingnxtpointer will be0x55555555**ab**e8 != 0x55555555**aa**e8, which is not what we wanted. - Therefore, we have to pick a current chunk and the next chunk such that their 2ND LEAST significant byte are the same.
- We gotta be careful here: using the snippet of memory above, if we let the current chunk be the first one a.k.a
- So now we have the 3rd chunk is at
B + 8instead ofC. Edit chunk B so its 1st (bread) field contains thetargetaddress we want to write to. - Edit the 3rd chunk, which will overwrite the content starting from
B + 8. Thenotespointer of this 3rd chunk is thetarget! So whatever we write tonoteswill be written intotarget!
There is a way to jump to this function, which I'm not gonna discuss. But before you try this, take a look at the Dockerfile. Notice anything about flag.txt?
...
chown root:root /home/ctf/flag.txt
...
USER peasant
CMD ["/home/ctf/start.sh"]
IT IS OWNED BY ROOT, WHILE THE PROGRAM IS RAN AND OWNED BY AN UNPRIVILEGED USER. So even if you execute "cat flag.txt", it's not gonna print out the flag because the program doesn't have permisison to access the file. This implies that we need to escalate into root in order to read the flag.
To exploit CVE-2021-4034, we need the following setup:
- A directory with the name
GCONV_PATH=. - In this directory, we need an executable file which is the name for the directory where our
gconv-modulesconfiguration file will be in. Let this berecipein our challenge. - In the
recipedirectory, we need a file namedgconv-moduleswith the content we control, and a dynamic library which will spawn us a shell (maybe this is the given binaryshelly.so)? - Then, in our
gconv-modules, we need to have the same CHARSET as step 5 below, and the name of our dynamic libraryshelly.solike this:module UTF-8// SHELLY// shelly 2. - After all of this, we call
pkexecwith NULL argv and the crafted environment variable array ={"recipe", "PATH=GCONV_PATH=.", "CHARSET=SHELLY", "SHELL=shelly", NULL}.
Now, we exploit this challenge to get the above setup.
Steps 1-2 are easy: we only have to log in with a username GCONV_PATH=. to create this directory. Then we do a checkout to create the executable recipe in this directory. Then we return to the first menu.
We log in with the username recipe to create this directroy. Now, we need to create a file named gconv-modules, but checkout only creates the files recipe and notes.
What if we overwrote the memory location for the string /notes to become /gconv-modules? This could work, but it requires that memory to be writable. Let's check it out in gdb:
gef➤ search-pattern /notes
[+] Searching '/notes' in memory
[+] In '/home/peasant/Desktop/chal'(0x555555559000-0x55555555a000), permission=rw-
0x555555559010 - 0x555555559016 → "/notes" And it is writable!
How do we perform the write?
- We use the arbitrary write strategy described above to arbitrarily READ the location of the leaked stack address. This gives us where
/recipeis. - A quick inspection in gdb can tell us the offset from
/recipeto/notesis0x10. Subtract this from the/recipeaddress in step 1 to get/notesaddress. - Use the arbitrary write strategy above to overwrite
/notesto/gconv-modules.
As to writing the correct content to the gconv-modules file, we know that at checkout, the notes content of the chunks will be written to /notes, which is now /gconv-modules. To be sure, we can just provide the string module UTF-8// SHELLY// shelly 2 to every notes.
So now, whenever we call checkout, it will create 2 files recipe and gconv-modules and write some module UTF-8// SHELLY// shelly 2 (we use shelly because that's the name of the .so set-uid-root library given) lines to the gconv-modules file. It will also copy a shelly.so into the same directory.
- We allocate 4 chunks whose
notesare the corrsponding environment variables. Thenotespointer array will have 4 pointers to these 4 notes and an ending NULL pointer, which is exactly what we wanted. - Allocate additional chunks and use the same strategy we used for goal 3-4 to overwrite the
RIPfor themainfunction to our secret functionFUN_00101e84.- How do we know where the
RIPis? Remember our leaked stack address at the beginning of the program? Go into gdb to find out its offset to theRIP, then add that offset to the actual leaked address to get theRIP. - How do we know where
mainis? Remember our leaked address of/recipefrom step 2 of Goal 3-4? Do the same thing here!
- How do we know where the
- Enjoy your shell :)
Solve script with comments is provided on this repo. Feel free to contact me if you have any questions :)