Analyzing a Modern Linux Kernel Vulnerability (CVE-2023-0266)
I’ve been spending some time outside of work messing around with writing Linux kernel exploits and analyzing Linux kernel vulnerabilities. This was my first time touching Linux kernel exploitation since reading A Guide to Kernel Exploitation: Attacking the Core and writing some basic kernel exploits almost ten years ago. From my perspective, the space has both changed significantly since then, while in many ways also remaining relatively unchanged. There are of course new mitigation technologies and there are elements that make exploitation more difficult than before, but the basic concepts used and many of the bug classes remain very similar.
To begin my research, I decided to take a look at CVE-2023-0266 after reading an excellent article written by Seth Jenkins from Google Project Zero titled Analyzing a Modern In-the-Wild Android Exploit. The article discusses an exploit chain used against a Samsung phone running Android which leverages a combination of n-days and zero-day vulnerabilities within the exploit chain. From my perspective, the most interesting aspect of the article is the number of vulnerabilities exploited by the attacker which had been patched within upstream dependencies, but hadn’t been backported to the targeted device. The attacker leveraged some very interesting kernel exploitation techniques within the article as well using a combination of vulnerabilities in the Linux kernel and the Mali GPU driver in order to exploit the vulnerability.
After reading through the article, I thought it would be neat to deep-dive a bit more into CVE-2023-0266 to better understand what common vulnerability patterns look like within the Linux kernel. The article from Project Zero provided a ton of useful technical details, but it didn’t provide a super-detailed analysis of the vulnerability. I thought it would be fun to write up a quick article describing the vulnerability in a little more-depth in an easy to explain manner.
Understanding the Vulnerability
As Seth documented in this post, the root cause of the vulnerability arises from there being a compatibility layer used for compatibility with 32-bit applications running on 64-bit systems. This compatibility layer means that there are different versions of the IOCTL available for 32-bit and 64-bit userland applications. One of the IOCTLs exposed to user-mode applications allows users to add sound control elements to a sound card and user’s can read and write to the sound control elements once they are added.
Unfortunately, while the 64-bit version of the IOCTLs implement proper locking, the 32-bit versions of the IOCTLs don’t properly check the lock when reading and writing to elements. This introduces a race condition vulnerability where the attacker can attempt to remove a sound control element while simultaneously trying to read or write to the sound control element.
The worst case scenario here would be the attacker is able trigger a use-after-free via a race condition where the write operation reads an attacker-controlled sound control element object. The sound control element fields such as function pointers and other fields which can be used to either gain control of program execution or trigger an arbitrary read or write.
To exploit the vulnerability the attacker needs the following actions to be performed:
- The attacker should trigger the removal of a sound control element while triggering a read or write of the sound control element (for the rest of this example we assume the attacker is triggering a write operation to avoid repeatedly saying both read and write).
- Sound control elements are stored in a linked list that is referenced for performing look-ups. To exploit the issue we need to trigger a free of the sound control element while at the same-time it is being read and used by the vulnerable IOCTL handlers.
- The attacker needs to reclaim this memory before it is used by the vulnerable IOCTL handlers so that they can control the member fields of the sound control element structure.
- If the attacker successfully reclaims the freed object’s memory space before it is used the attacker can gain control of the instruction programmer or trigger an arbitrary read or write within the kernel.
Often when writing these types of exploits the key is to understand the primitives provided by the vulnerability and then leverage exploitation techniques that allow you to leverage those primitives to achieve a desired outcome (e.g. root access on a server or the ability to break out of a Linux namespace).
Digging into the Linux Sound Subsystem
The root cause of this vulnerability is related to there being multiple ways to trigger IOCTLs through an exposed management interface for sound control management. There’s a 32-bit version and a 64-bit version of the IOCTL handlers, but when the code got updated, they accidentally removed the locking on the 32-bit handlers.
How do we access or invoke this functionality?
That said, my basic understanding was that this mechanism is used by utilities like alsamixer to manage system sound. To confirm this, I launched a copy of alsamixer, checked it’s open file handles, and saw it was accessing /dev/snd/controlC0. This occurred when I started adjusting sound card related settings on my laptop.
$ pgrep alsamixer
1111478
$ sudo lsof -p 1111478 2>/dev/null | grep /dev
alsamixer 1111478 adam 0u CHR 136,1 0t0 4 /dev/pts/1
alsamixer 1111478 adam 1u CHR 136,1 0t0 4 /dev/pts/1
alsamixer 1111478 adam 2u CHR 136,1 0t0 4 /dev/pts/1
alsamixer 1111478 adam 3u CHR 116,7 0t0 996 /dev/snd/controlC0
Figure 1: The alsamixer utility with its open file handles displayed, demonstrating access to /dev/snd/controlC0.
Figure 2: An active instance of the alsamixer utility running during my dynamic analysis to understand how the sound subsystem operations work.
During my research, I couldn’t find any easily accessible or detailed documentation explaining how this subsystem works. Honestly, when it comes to vulnerability research, you don’t always need to fully understand the purpose of the system you’re exploiting. If you can construct the right primitives (or write primitives 😆) and build an exploit chain, a deep dive into the functionality—like how sound control works in the Linux kernel—often isn’t necessary.
How are the IOCTL interfaces implemented?
To understand the vulnerability we need to understand the key differences between the 32-bit and 64-bit IOCTL handlers that are exposed by the sound control management interface. Our analysis is performed against the linux-4.14.223 release version. The source code for the sound control management interface is located within the sound/core/control.c source code file.
To register an IOCTL handler a file_operations structure is defined as file operations are leveraged within the Linux Kernel to interact with device drivers that expose an IOCTL interface. The .unlocked_ioctl and .compat_ioctl fields within the file_operations structure are used to define the 64-bit and 32-bit IOCTL handlers respectively.
static const struct file_operations snd_ctl_f_ops =
{
.owner = THIS_MODULE,
.read = snd_ctl_read,
.open = snd_ctl_open,
.release = snd_ctl_release,
.llseek = no_llseek,
.poll = snd_ctl_poll,
.unlocked_ioctl = snd_ctl_ioctl,
.compat_ioctl = snd_ctl_ioctl_compat,
.fasync = snd_ctl_fasync,
};
Figure 3: The snd_ctl_f_ops structure definition from control.c, showing how IOCTL handlers are registered for both 64-bit and 32-bit operations.
Understanding the 64-Bit IOCTL Implementation
To begin we can start by analyzing the 64-bit IOCTL implementation. The IOCTL handler function is passed a IOCTL number which denotes the IOCTL handler to be executed. In this case, our focus is on the SNDRV_CTL_IOCTL_ELEM_READ and SNDRV_CTL_IOCTL_ELEM_WRITE handlers which invoke the snd_ctl_elem_read_user and snd_ctl_elem_write_user handlers respectively.
static long snd_ctl_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
struct snd_ctl_file *ctl;
struct snd_card *card;
struct snd_kctl_ioctl *p;
void __user *argp = (void __user *)arg;
int __user *ip = argp;
int err;
ctl = file->private_data;
card = ctl->card;
if (snd_BUG_ON(!card))
return -ENXIO;
switch (cmd) {
case SNDRV_CTL_IOCTL_PVERSION:
return put_user(SNDRV_CTL_VERSION, ip) ? -EFAULT : 0;
case SNDRV_CTL_IOCTL_CARD_INFO:
return snd_ctl_card_info(card, ctl, cmd, argp);
case SNDRV_CTL_IOCTL_ELEM_LIST:
return snd_ctl_elem_list(card, argp);
case SNDRV_CTL_IOCTL_ELEM_INFO:
return snd_ctl_elem_info_user(ctl, argp);
case SNDRV_CTL_IOCTL_ELEM_READ:
return snd_ctl_elem_read_user(card, argp);
case SNDRV_CTL_IOCTL_ELEM_WRITE:
return snd_ctl_elem_write_user(ctl, argp);
case SNDRV_CTL_IOCTL_ELEM_LOCK:
return snd_ctl_elem_lock(ctl, argp);
case SNDRV_CTL_IOCTL_ELEM_UNLOCK:
// ...
Figure 4: The snd_ctl_ioctl handler function that dispatches to appropriate handlers based on the specified IOCTL code.
The 64-bit implementation of the read IOCTL handler includes proper locking mechanisms to prevent concurrent access to sound control elements.
static int snd_ctl_elem_read_user(struct snd_card *card,
struct snd_ctl_elem_value __user *_control)
{
struct snd_ctl_elem_value *control;
int result;
control = memdup_user(_control, sizeof(*control));
if (IS_ERR(control))
return PTR_ERR(control);
result = snd_power_wait(card, SNDRV_CTL_POWER_D0);
if (result < 0)
goto error;
down_read(&card->controls_rwsem);
result = snd_ctl_elem_read(card, control);
up_read(&card->controls_rwsem);
if (result < 0)
goto error;
if (copy_to_user(_control, control, sizeof(*control)))
result = -EFAULT;
error:
kfree(control);
return result;
}
Figure 5: The 64-bit read IOCTL handler implementation with proper locking mechanisms preventing concurrent access.
The 64-bit implementation of the write IOCTL handler includes proper locking mechanisms to prevent concurrent access to sound control elements.
static int snd_ctl_elem_write_user(struct snd_ctl_file *file,
struct snd_ctl_elem_value __user *_control)
{
struct snd_ctl_elem_value *control;
struct snd_card *card;
int result;
control = memdup_user(_control, sizeof(*control));
if (IS_ERR(control))
return PTR_ERR(control);
card = file->card;
result = snd_power_wait(card, SNDRV_CTL_POWER_D0);
if (result < 0)
goto error;
down_write(&card->controls_rwsem);
result = snd_ctl_elem_write(card, file, control);
up_write(&card->controls_rwsem);
if (result < 0)
goto error;
if (copy_to_user(_control, control, sizeof(*control)))
result = -EFAULT;
error:
kfree(control);
return result;
}
Figure 6: The 64-bit write IOCTL handler implementation including necessary locking protections.
Understanding the 32-Bit IOCTL Implementation
If we now examine the 32-bit implementation of the same handler we observe that there isn’t any locking implemented when calling snd_ctl_elem_read and snd_ctl_elem_write. Seth mentions this in his post and references the relevant commits which show how the locks were inadvertently removed as part of a code refactor.
The 32-bit read handler doesn’t properly acquire the lock before calling snd_ctl_elem_read which can lead to an unintended race-condition vulnerability.
static int ctl_elem_read_user(struct snd_card *card,
void __user *userdata, void __user *valuep)
{
struct snd_ctl_elem_value *data;
int err, type, count;
data = kzalloc(sizeof(*data), GFP_KERNEL);
if (data == NULL)
return -ENOMEM;
err = copy_ctl_value_from_user(card, data, userdata, valuep,
&type, &count);
if (err < 0)
goto error;
err = snd_power_wait(card, SNDRV_CTL_POWER_D0);
if (err < 0)
goto error;
err = snd_ctl_elem_read(card, data);
if (err < 0)
goto error;
err = copy_ctl_value_to_user(userdata, valuep, data, type, count);
error:
kfree(data);
return err;
}
Figure 7: The 32-bit read handler lacking lock acquisition before calling snd_ctl_elem_read, enabling race conditions.
The 32-bit write handler doesn’t properly acquire the lock before calling snd_ctl_elem_write which can lead to an unintended race-condition vulnerability.
static int ctl_elem_write_user(struct snd_ctl_file *file,
void __user *userdata, void __user *valuep)
{
struct snd_ctl_elem_value *data;
struct snd_card *card = file->card;
int err, type, count;
data = kzalloc(sizeof(*data), GFP_KERNEL);
if (data == NULL)
return -ENOMEM;
err = copy_ctl_value_from_user(card, data, userdata, valuep,
&type, &count);
if (err < 0)
goto error;
err = snd_power_wait(card, SNDRV_CTL_POWER_D0);
if (err < 0)
goto error;
err = snd_ctl_elem_write(card, file, data);
if (err < 0)
goto error;
err = copy_ctl_value_to_user(userdata, valuep, data, type, count);
error:
kfree(data);
return err;
}
Figure 8: The 32-bit write handler similarly missing lock protections before invoking snd_ctl_elem_write.
Digging into the Element Read and Write Functions
At this point, we know that there is something suspicious going on and that proper locking isn’t being used, but this doesn’t always have security implications. It could be a simple bug. However, digging deeper we quickly realize there are some serious security implications. In both snd_ctl_elem_read and snd_ctl_elem_write we lookup the control object-based on the user-provided data in snd_ctl_elem_value.
This is used to lookup a snd_kcontrol object called kctl in the code, however, the lack of proper locking means that there is no guarantee that our sound control element hasn’t been freed and replaced with a malicious object before being used for sensitive operations. For example, in snd_ctl_elem_read a member value “get” is used by a function pointer that is an attacker-controlled value.
The attackers in the write-up produced by Project Zero leveraged two-separate vulnerabilities in the Mali GPU driver in order to exploit this particular vulnerability. However, a key difference as documented in the Project Zero write-up is that the attacker didn’t replace these function pointers and instead replaced a member value that is used when the “put” function is called to construct an arbitrary read and write primitive.
In snd_ctl_elem_read we observed that the snd_kcontrol structure members were used for things like reading a function pointer.
static int snd_ctl_elem_read(struct snd_card *card,
struct snd_ctl_elem_value *control)
{
struct snd_kcontrol *kctl;
struct snd_kcontrol_volatile *vd;
unsigned int index_offset;
kctl = snd_ctl_find_id(card, &control->id);
if (kctl == NULL)
return -ENOENT;
index_offset = snd_ctl_get_ioff(kctl, &control->id);
vd = &kctl->vd[index_offset];
if (!(vd->access & SNDRV_CTL_ELEM_ACCESS_READ) || kctl->get == NULL)
return -EPERM;
snd_ctl_build_ioff(&control->id, kctl, index_offset);
return kctl->get(kctl, control);
}
Figure 9: How snd_ctl_elem_read uses structure member function pointers that become attacker-controllable.
A similar scenario occurs in snd_ctl_elem_write where member fields are used as function pointers such as the “put” field.
static int snd_ctl_elem_write(struct snd_card *card, struct snd_ctl_file *file,
struct snd_ctl_elem_value *control)
{
struct snd_kcontrol *kctl;
struct snd_kcontrol_volatile *vd;
unsigned int index_offset;
int result;
kctl = snd_ctl_find_id(card, &control->id);
if (kctl == NULL)
return -ENOENT;
index_offset = snd_ctl_get_ioff(kctl, &control->id);
vd = &kctl->vd[index_offset];
if (!(vd->access & SNDRV_CTL_ELEM_ACCESS_WRITE) || kctl->put == NULL ||
(file && vd->owner && vd->owner != file)) {
return -EPERM;
}
snd_ctl_build_ioff(&control->id, kctl, index_offset);
result = kctl->put(kctl, control);
if (result < 0)
return result;
if (result > 0) {
struct snd_ctl_elem_id id = control->id;
snd_ctl_notify(card, SNDRV_CTL_EVENT_MASK_VALUE, &id);
}
return 0;
}
Figure 10: Similar vulnerabilities in snd_ctl_elem_write where member fields function as exploitable function pointers.
A definition of the snd_kcontrol element structure that is attacker-controlled under a use-after-free condition.
struct snd_kcontrol {
struct list_head list; /* list of controls */
struct snd_ctl_elem_id id;
unsigned int count; /* count of same elements */
snd_kcontrol_info_t *info;
snd_kcontrol_get_t *get;
snd_kcontrol_put_t *put;
union {
snd_kcontrol_tlv_rw_t *c;
const unsigned int *p;
} tlv;
unsigned long private_value;
void *private_data;
void (*private_free)(struct snd_kcontrol *kcontrol);
struct snd_kcontrol_volatile vd[0]; /* volatile data */
};
Figure 11: The snd_kcontrol element structure definition showing attacker-controlled fields under use-after-free conditions.
Building a Vulnerable Linux Kernel
From my testing, I’ve found that building the older-versions of the Linux kernel is rather tightly coupled with the version of GCC used to build the application. Building older kernel versions with newer versions of GCC resulted in errors at compile time. In order to work around this issue, I leveraged Docker to download an older version of GCC from DockerHub. Typically, I’ll try to target a version of GCC that was released sometime around when the kernel version I’m trying to compile was released. This allowed me to successfully build the kernel without issue.
$ docker run -v `pwd`:/build -it gcc:7.4.0 /bin/bash
# apt -qq -y update && apt -qq -y install libelf-dev bc >/dev/null 2>&1
211 packages can be upgraded. Run 'apt list --upgradable' to see them.
# cd /build/ && make -j16
CHK include/config/kernel.release
CHK include/generated/uapi/linux/version.h
DESCEND objtool
CHK include/generated/utsrelease.h
CHK scripts/mod/devicetable-offsets.h
CHK include/generated/timeconst.h
CHK include/generated/bounds.h
CHK include/generated/asm-offsets.h
CALL scripts/checksyscalls.sh
CHK include/generated/compile.h
UPD include/generated/compile.h
CC init/version.o
AR init/built-in.o
GEN .version
CHK include/generated/compile.h
UPD include/generated/compile.h
CC init/version.o
AR init/built-in.o
AR built-in.o
LD vmlinux.o
Figure 12: Docker command example used for compiling vulnerable kernel versions with compatible GCC versions.
This is an important note to keep in mind as I was somewhat surprised how tightly coupled the Linux Kernel was to the version of GCC which could be leveraged to build the kernel. I believe this is because the Linux Kernel uses many advanced features of the GCC compiler within its build process and this inevitably leads to some dependencies related to specific compiler versions. When building the Linux Kernel it’s also quite useful to build in support for the 9P network file sharing protocol as this makes it easy to share files between your local computer and your test system for exploit development purposes.
Thoughts on Potential Exploitation Techniques
Exploiting this vulnerability on traditional Linux desktop or server implementations is generally significantly easier than exploiting this vulnerability on Android related systems which are often more heavily hardened and restricted from a process isolation and kernel hardening perspective. For example, I noted that a documented KASLR bypass issue within the Linux Kernel was still present within the latest version of the Linux Kernel on my personal research laptop running the latest version of Ubuntu Linux along with the research kernel I was leveraging running linux-4.14.223.
# uname -a
Linux (none) 4.14.223 #6 SMP Thu May 30 20:43:26 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
# cat /sys/kernel/notes
-r--r--r-- 1 root root 472 May 30 16:19 /sys/kernel/notes
# xxd /sys/kernel/notes
00000000: 0400 0000 1400 0000 0300 0000 474e 5500 ............GNU.
00000010: aceb 6a16 2387 34b1 f21a 6932 1db8 5434 ..j.#.4...i2..T4
00000020: bde1 8c55 0600 0000 0400 0000 0101 0000 ...U............
00000030: 4c69 6e75 7800 0000 0000 0000 0600 0000 Linux...........
00000040: 0100 0000 0001 0000 4c69 6e75 7800 0000 ........Linux...
00000050: 0000 0000 0400 0000 0800 0000 1200 0000 ................
00000060: 0a65 c300 0000 0000 0800 0000 0400 0000 .e..............
00000070: 0600 0000 0600 0000 5865 ee00 6c69 6e75 ........Xe..linu
00000080: 7800 0000 0400 0000 0400 0000 0700 0000 x...............
00000090: 7865 6e00 e200 0000 0400 0000 0800 0000 xen.............
000000a0: 0500 0000 5865 ee00 7865 ee2d 332e 3000 ....Xe..xe.-3.0.
000000b0: 0400 0000 0800 0000 0300 0000 5865 6e00 ............Xen.
000000c0: 0000 0000 ffff ffff 0400 0000 0800 0000 ................
000000d0: 0100 0000 5865 6e2e 3000 0000 0800 0000 ....Xen.0.......
000000e0: 0400 0000 0800 0000 0100 0000 5865 6e00 ............Xen.
000000f0: ff42 6c9f ffff ffff 0400 0000 1000 0000 .Bl.............
00000100: 1177 6500 5865 6e2e 6e77 7269 7461 626c .we.Xen.!writabl
00000110: 655f 7061 6765 5f74 6162 6c65 7300 0000 e_page_tables...
00000120: 0400 0000 0400 0000 0900 0000 5865 6e00 ............Xen.
00000130: 0f65 7f00 0400 0000 1000 0000 0600 0000 .e..............
00000140: 5865 6e00 0100 0000 0000 0000 0100 0000 Xen.............
00000150: 0000 0000 0400 0000 0400 0000 1000 0000 ................
00000160: 0a65 c300 0400 0000 0400 0000 0800 0000 .e..............
00000170: 0400 0000 5865 6e00 0000 0000 0000 0000 ....Xen.........
00000180: 0400 0000 0800 0000 0200 0000 5865 6e00 ............Xen.
00000190: 0001 019e ffff ffff 0400 0000 0400 0000 ................
000001a0: 1100 0000 5865 ee00 0188 0000 0400 0000 ....Xe..........
000001b0: 0400 0000 0400 0000 5865 ee00 6765 6e65 ........Xen.gene
000001c0: 7269 6300 0400 0000 0400 0000 0600 0000 ric.............
000001d0: 5865 ee00 0100 0000 Xe......
Figure 13: KASLR bypass vulnerability persisting in current Linux kernel versions, exposing kernel addresses through /sys/kernel/notes.
Initially, as part of this article, I thought about writing an exploit for this vulnerability. However, after reflecting, I decided it would be better to move onto analyzing another vulnerability. The exploitation of this issue is rather complex as the attacker uses other vulnerabilities to get the proper primitives needed (e.g. for heap spraying) and I decided I’m more interested in documenting additional bug-classes than spending an extended period of time on the exploitation side.
Although, one of the things I’ve been having fun lately is leveraged the pwn.college site for messing around with Linux Kernel Exploitation. They have a lot of fun challenges you can complete using an intentionally vulnerable driver that does a decent job of teaching you various exploitation techniques. For example, one of the challenges involves using a use-after-free vulnerability to overwrite a msg_msg object to construct an arbitrary read primitive and then triggering the issue again to overwrite another struct to trigger a ROP chain to elevate process privileges.
Conclusion
It’s fascinating to see, through the Project Zero write-up, just how much time and expertise is invested into building capabilities likely meant for government use in counter-terrorism and counter-intelligence. It might seem surprising that n-day vulnerabilities still succeed in these contexts—but when you’re targeting individuals paranoid enough to avoid installing updates out of fear they contain backdoors, it makes more sense.
The level of sophistication behind these exploit chains—backed by what are essentially defense-industrial-complex-grade resources—feels like a whole different world compared to what many red teamers experience. Most of us are out here compromising Fortune 500 companies with the technical equivalent of a slingshot and a homemade Molotov cocktail. Lina Lau’s An Inside Look at NSA (Equation Group) TTPs from China’s Lens, alongside the Project Zero analysis, gives a rare peek into that parallel universe.
I can only imagine what it’s like to run offensive operations with that kind of firepower. Still, there’s something satisfying about working with limited tools—because the asymmetric nature of offensive cyber means that even the underdog can hit above their weight. To be honest, I’m not entirely sure where I’ll take this series next. I’ve just been working on it for fun in my free time, and I’ll probably keep following whatever feels most interesting on any given weekend. I’ve found that’s usually a better approach than forcing specific goals—especially when it’s meant to be something enjoyable. No pressure, just curiosity.