ANT-2026-1BCAFJCC · torvalds/linux
use-after-free high
Severity Claude high · Security research firm - · Maintainer high
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-1BCAFJCC: futex: UAF in private-hash teardown via cross-process sys_futex_requeue leading giving LPE
A process P can allocate a private futex hash via prctl(PR_FUTEX_HASH) and then use sys_futex_requeue() to move another process C's shared-futex waiter onto a bucket in P's private table, rewriting C's futex_q.lock_ptr to point inside P's mm->futex_phash. When P exits, futex_hash_free() calls kvfree(fph) directly with no check for foreign waiters, so C's lock_ptr now points into a freed kmalloc-256 object. When C later unqueues (e.g. via io_uring futex cancel -> futex_unqueue), it performs spin_lock() and atomic_dec() at a predictable offset in the reclaimed slab. By spraying pipe_buffer arrays into that slot, the atomic_dec flips pipe_buffer.flags to set PIPE_BUF_FLAG_CAN_MERGE on a spliced read-only page, yielding a Dirty-Pipe-style arbitrary file overwrite and local root.
Target
Project: torvalds/linux
Version: Linux kernel since commit 80367ad01d93 (2025-05); requires CONFIG_FUTEX_PRIVATE_HASH=y (default in defconfig and Ubuntu 26)
Location: futex_hash_free() in kernel/futex/core.c; requeue_futex() in kernel/futex/requeue.c
Technical Details
The resize path for the private hash uses refcounting, but the exit path in futex_hash_free() simply does kvfree(rcu_dereference_raw(mm->futex_phash)) without verifying the table is empty. The comment in requeue_futex() assumes hb1 and hb2 belong to the same futex_hash_bucket_private, which fails when requeueing a shared (global-hash) waiter to a FUTEX2_PRIVATE destination in a different mm — the waiter's own mm never took a reference on the destination table. KASAN did not flag the spin_lock in testing, but a diagnostic pr_warn in futex_hash_free shows waiters=1 at kvfree time, confirming the dangling pointer.
Reproduction
- Process C submits IORING_OP_FUTEX_WAITV on a shared-mapped futex, parking C's futex_q on the global hash.
- Process P calls prctl(PR_FUTEX_HASH, PR_FUTEX_HASH_SET_SLOTS, 2), allocating a ~192-byte futex_private_hash in kmalloc-256.
- P calls sys_futex_requeue with source = C's shared futex and dest = a FUTEX2_PRIVATE futex in P's mm; C's futex_q.lock_ptr is rewritten to &fph->queues[k].lock.
- P exits; __mmput -> futex_hash_free -> kvfree(fph); C's lock_ptr now dangles into freed kmalloc-256.
- C sprays pipe_buffer arrays via fcntl(F_SETPIPE_SZ) (4*40=160 bytes -> kmalloc-256); SLUB LIFO reclaims the freed fph slot.
- C issues io_uring ASYNC_CANCEL + GETEVENTS -> io_futexv_complete -> futex_unqueue; atomic_dec(&hb->waiters) hits offset 64+k*64, i.e. pipe_buffer[1].flags, wrapping 0 -> 0xffffffff and setting PIPE_BUF_FLAG_CAN_MERGE.
- That pipe_buffer holds a splice()'d page from a read-only file; with CAN_MERGE set, write() to the pipe writes into the file's page-cache page (Dirty Pipe primitive) — overwrite /etc/passwd to add a root account.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
Before kvfree(fph) in futex_hash_free(), walk every bucket, dequeue any remaining futex_q, and set its lock_ptr = NULL (the documented 'already dequeued' signal checked by futex_unqueue). Reporter notes the patch is LLM-authored and unreviewed beyond confirming it stops the crash.
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-1BCAFJCC.
Reference: ANT-2026-1BCAFJCC
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
The change that resolved this finding.
diff --git a/kernel/futex/syscalls.c b/kernel/futex/syscalls.c
index 743c7a72823782..77ad9691f6a613 100644
--- a/kernel/futex/syscalls.c
+++ b/kernel/futex/syscalls.c
@@ -459,6 +459,14 @@ SYSCALL_DEFINE4(futex_requeue,
if (ret)
return ret;
+ /*
+ * For now mandate both flags are identical, like the sys_futex()
+ * interface has. If/when we merge the variable sized futex support,
+ * that patch can modify this test to allow a difference in size.
+ */
+ if (futexes[0].w.flags != futexes[1].w.flags)
+ return -EINVAL;
+
cmpval = futexes[0].w.val;
return futex_requeue(u64_to_user_ptr(futexes[0].w.uaddr), futexes[0].w.flags,https://github.com/torvalds/linux/commit/19f94b39058681dec64a10ebeb6f23fe7fc3f77a
Dates from discovery through public reveal.
- 2026-03-23 Sent to maintainer
- 2026-03-23 Maintainer acknowledged
- 2026-04-11 Patch released
- 2026-05-14 Reported to tracker
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
a894326e44eab8c46073384ca167d21d0639a88d42502b0b9079e4303ea321f71a1c1b7554ca427b42e0490e0b84a01fcb9c872f5ce88defebe79bee5f23d4c2
Committed 2026-03-24 14:10 PT
Revealed 2026-08-17 17:36 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-1BCAFJCC",
"bug_class": "Use-After-Free",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-05-14T22:41:40+00:00",
"description": "A process P can allocate a private futex hash via prctl(PR_FUTEX_HASH) and then use sys_futex_requeue() to move another process C's shared-futex waiter onto a bucket in P's private table, rewriting C's futex_q.lock_ptr to point inside P's mm->futex_phash. When P exits, futex_hash_free() calls kvfree(fph) directly with no check for foreign waiters, so C's lock_ptr now points into a freed kmalloc-256 object. When C later unqueues (e.g. via io_uring futex cancel -> futex_unqueue), it performs spin_lock() and atomic_dec() at a predictable offset in the reclaimed slab. By spraying pipe_buffer arrays into that slot, the atomic_dec flips pipe_buffer.flags to set PIPE_BUF_FLAG_CAN_MERGE on a spliced read-only page, yielding a Dirty-Pipe-style arbitrary file overwrite and local root.",
"discovered_at": "2026-03-24T21:10:53+00:00",
"location": "futex_hash_free() in kernel/futex/core.c; requeue_futex() in kernel/futex/requeue.c",
"poc_sha256": null,
"preimage_version": 1,
"project": "torvalds/linux",
"reproduction": [
"1. Process C submits IORING_OP_FUTEX_WAITV on a shared-mapped futex, parking C's futex_q on the global hash.",
"2. Process P calls prctl(PR_FUTEX_HASH, PR_FUTEX_HASH_SET_SLOTS, 2), allocating a ~192-byte futex_private_hash in kmalloc-256.",
"3. P calls sys_futex_requeue with source = C's shared futex and dest = a FUTEX2_PRIVATE futex in P's mm; C's futex_q.lock_ptr is rewritten to &fph->queues[k].lock.",
"4. P exits; __mmput -> futex_hash_free -> kvfree(fph); C's lock_ptr now dangles into freed kmalloc-256.",
"5. C sprays pipe_buffer arrays via fcntl(F_SETPIPE_SZ) (4*40=160 bytes -> kmalloc-256); SLUB LIFO reclaims the freed fph slot.",
"6. C issues io_uring ASYNC_CANCEL + GETEVENTS -> io_futexv_complete -> futex_unqueue; atomic_dec(&hb->waiters) hits offset 64+k*64, i.e. pipe_buffer[1].flags, wrapping 0 -> 0xffffffff and setting PIPE_BUF_FLAG_CAN_MERGE.",
"7. That pipe_buffer holds a splice()'d page from a read-only file; with CAN_MERGE set, write() to the pipe writes into the file's page-cache page (Dirty Pipe primitive) — overwrite /etc/passwd to add a root account."
],
"technical_details": "Hi --\n\nI've found a use-after-free in the futex private-hash teardown that\n\ngives an unprivileged local user a deterministic write primitive.\n\nsys_futex_requeue() can move a waiter from one process onto another\n\nprocess's private hash table, and futex_hash_free() frees that table\n\nwith a direct kvfree() when the owning process exits, leaving the\n\nforeign waiter's lock_ptr dangling. I've attached a working local\n\nprivilege escalation PoC that uses this to get root on stock Ubuntu 26\n\nrc via a Dirty-Pipe-style file overwrite.\n\nAs in my prior bugs, I used an LLM to find this, but I validated it\n\nmyself and wrote this email. I have relied on an LLM even moreso when\n\nproducing the PoC exploit: I have not validated anything in the contents\n\nof this file, except the black-box behavior that it grants me root on\n\nstock Ubuntu 26.\n\nSince commit 80367ad01d93 (2025-05, the private-futex-hash work), a\n\nprocess can allocate a per-mm hash table for its private futexes via\n\nprctl(PR_FUTEX_HASH, PR_FUTEX_HASH_SET_SLOTS, n). The table lives at\n\nmm->futex_phash and is freed by futex_hash_free() when the mm is\n\ntorn down. Waiters queued on private futexes have their\n\nfutex_q.lock_ptr pointing at a bucket lock inside this table.\n\nSeparately, sys_futex_requeue() (commit 0f4b5f972216, 2023-09) lets a\n\nprocess atomically move waiters from one futex to another. The two\n\nfutexes can have different flags---one FUTEX2_PRIVATE, one not. When\n\nthe destination is a private futex in the caller's mm, the waiter's\n\nlock_ptr gets rewritten to point at a bucket in the caller's private\n\nhash table. Nothing checks whether the waiter being moved belongs to\n\na different process.\n\nThe teardown path in kernel/futex/core.c is as follows:\n\n void futex_hash_free(struct mm_struct *mm)\n\n {\n\n struct futex_private_hash *fph;\n\n free_percpu(mm->futex_ref);\n\n kvfree(mm->futex_phash_new);\n\n fph = rcu_dereference_raw(mm->futex_phash);\n\n if (fph)\n\n kvfree(fph); // direct free here\n\n }\n\nThe resize path (bd54df5ea7ca) carefully handles replacement with\n\na different mechanism, some kind of ref-counting. But the exit path\n\njust frees the object. This means a foreign process's futex_q.lock_ptr now\n\npoints into freed memory; when that process later unqueues\n\n(via futex_unqueue or io_futexv_complete), it calls spin_lock()\n\nand atomic_dec() on the freed slot.\n\nThe requeue path has a comment documenting the assumption this\n\nviolates (kernel/futex/requeue.c, requeue_futex()):\n\n q->lock_ptr = &hb2->lock;\n\n /*\n\n * hb1 and hb2 belong to the same futex_hash_bucket_private\n\n * because if we managed get a reference on hb1 then it can't be\n\n * replaced. Therefore we avoid put(hb1)+get(hb2) here.\n\n */\n\nThat holds when both futexes are private to the same mm. It does not\n\nhold when requeueing shared->private: hb1 is global, hb2 is in the\n\ncaller's private hash, and the waiter's own mm never takes a\n\nreference on it.\n\nI have attached two files; one is a PoC that demonstrates the flaw in a\n\nminimal manner, the other is a PoC that can be used to give root on any\n\nrecent linux, including Ubuntu 26 rc with full hardening.\n\nThe primitive it makes use of is a spin_lock + atomic_dec at a\n\npredictable offset inside a reclaimed kmalloc-256 slab object.\n\nThe attached lpe.c works (I believe!) as follows:\n\n1. Process C submits IORING_OP_FUTEX_WAITV on a shared-mapped futex.\n\n C's futex_q sits on the global hash. (io_uring's futex\n\n support is used only as a convenient way to keep a futex_q alive\n\n across the requeue. It doesn't have a bug to my knowledge.)\n\n2. Process P calls prctl(PR_FUTEX_HASH, PR_FUTEX_HASH_SET_SLOTS, 2).\n\n struct futex_private_hash has a 64-byte header followed by\n\n cacheline-aligned buckets, so with 2 buckets the allocation is\n\n 64 + 2*64 = 192 bytes, landing in kmalloc-256.\n\n3. P calls sys_futex_requeue with the source being C's shared futex\n\n and the destination being a FUTEX2_PRIVATE futex in P's own\n\n address space. C's futex_q.lock_ptr is rewritten to\n\n &fph->queues[k].lock where k = jhash2(&addr, ...) & 1.\n\n4. P exits. __mmput calls futex_hash_free which calls kvfree(fph).\n\n C's lock_ptr dangles into freed kmalloc-256.\n\n5. C sprays with pipe_buffer arrays. It calls fcntl(F_SETPIPE_SZ)\n\n allocates 4 * sizeof(struct pipe_buffer) = 160 bytes, which also\n\n lands in kmalloc-256. SLUB LIFO means the spray reclaims the freed\n\n fph slot.\n\n6. C triggers io_uring ASYNC_CANCEL + GETEVENTS, which runs\n\n io_futexv_complete -> futex_unqueue. The atomic_dec(&hb->waiters)\n\n lands at offset 64 + k*64 in the reclaimed slot. For k=0 that's\n\n offset 64 = pipe_buffer[1].flags. atomic_dec(0) gives 0xffffffff,\n\n setting every flag bit including PIPE_BUF_FLAG_CAN_MERGE (0x10).\n\n7. That pipe_buffer already holds a splice()'d page from a read-only\n\n file. With CAN_MERGE set, write() to the pipe appends into the\n\n file's page-cache page. This is the CVE-2022-0847 Dirty Pipe\n\n primitive, reached through a different root cause.\n\nI've also attached crasher.c: a minimal trigger. It has just two\n\nprocesses: P requeues C's waiter then exits, C unqueues. On a kernel\n\nwith a diagnostic pr_warn in futex_hash_free, each iteration shows\n\nwaiters=1 at the kvfree followed by futex_unqueue reading lock_ptr\n\ninside the freed slot. KASAN didn't report the spin_lock in my\n\ntesting, but I'm not sure why. The pr_warn output demonstrates the\n\ndangling pointer directly.\n\nThe attached fix, written by an LLM, walks each bucket before freeing,\n\ndequeues any remaining waiter, and clears its lock_ptr (the documented\n\n\"already dequeued\" signal that futex_unqueue checks). This prevents\n\nthe bug, but I'm way out of my depth on this patch here, so I'm mostly\n\nproviding it as helpful information that it stops the crash. I did not write\n\nthis patch and do not understand the mechanics well enough to say if\n\nit's worth accepting. I tried my best to write the commit message in a\n\nway that makes sense.\n\nThanks,\n\nNicholas\n\nReproduction: the Ubuntu 26.04 daily cloud image ships a vulnerable\n\nkernel (CONFIG_FUTEX_PRIVATE_HASH is default y, depends only on\n\nFUTEX && !BASE_SMALL && MMU), so no kernel build is needed:\n\n wget https://cloud-images.ubuntu.com/daily/server/questing/current/questing-server-cloudimg-amd64.img\n\n cat > user-data <<'EOF'\n\n #cloud-config\n\n password: test\n\n chpasswd: { expire: False }\n\n ssh_pwauth: True\n\n EOF\n\n printf 'instance-id: x\\nlocal-hostname: x\\n' > meta-data\n\n genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data\n\n # overlay so the downloaded image stays pristine\n\n qemu-img create -f qcow2 -F qcow2 \\\n\n -b questing-server-cloudimg-amd64.img disk.qcow2 8G\n\n qemu-system-x86_64 -enable-kvm -m 2G -smp 4 -nographic \\\n\n -drive file=disk.qcow2,if=virtio \\\n\n -drive file=seed.iso,if=virtio,format=raw \\\n\n -netdev user,id=n0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=n0\n\n # compile statically on the host (cloud image has no gcc):\n\n gcc -O2 -static -o lpe lpe.c\n\n scp -P 2222 lpe ubuntu@localhost:/tmp/ # password 'test'\n\n # ssh -p 2222 ubuntu@localhost\n\n sudo useradd -m testuser\n\n sudo install -o testuser /tmp/lpe /home/testuser/lpe\n\n sudo -u testuser -i\n\n id\n\n # uid=1001(testuser) gid=1001(testuser) groups=1001(testuser)\n\n su root -c id\n\n # Password:\n\n # su: Authentication failure <- can't become root\n\n printf 'oot::0:0::/root:/bin/sh\\n' > /tmp/p\n\n ./lpe /etc/passwd 1 /tmp/p\n\n # [+] SUCCESS on attempt 0\n\n su root -c id\n\n # uid=0(root) gid=0(root) <- now we can\n\nAlternatively, build HEAD with make defconfig and boot your own\n\nrootfs -- defconfig enables CONFIG_FUTEX_PRIVATE_HASH.",
"title": "futex: UAF in private-hash teardown via cross-process sys_futex_requeue leading giving LPE",
"vendor_severity": null
}