ANT-2026-PVD37V09 · torvalds/linux

use-after-free high

Severity Claude high · Security research firm - · Maintainer -

REPORT

Anthropic's analysis of this finding, sealed at approval.

ANT-2026-PVD37V09: LPE via io_uring io-wq worker pool

In io_uring's io-wq worker pool, cancelling a hashed bucket-0 work item whose list predecessor is non-hashed causes io_wq_remove_pending() to install the non-hashed io_kiocb as wq->hash_tail[0], since io_get_work_hash() returns 0 for all non-hashed work. The non-hashed request completes and is freed via req_cachep while hash_tail[0] still points at it, and because hash_tail[] lives on the per-task io_wq it survives ring close. The next hashed bucket-0 enqueue writes an 8-byte kernel heap pointer to offset 216 of the freed 256-byte slab slot via wq_list_add_after(). An unprivileged local attacker controls the SQE sequence needed to trigger this deterministically, and by cross-cache reclaiming the freed page with pipe_buffer arrays can land the write on pipe_buffer.flags, set PIPE_BUF_FLAG_CAN_MERGE on a spliced page-cache page, and perform a Dirty Pipe overwrite of a SUID binary to gain root. Demonstrated on Ubuntu 22.04/24.04/26.04, Debian 11/13, Fedora 41, Rocky 9 (io_uring re-enabled), Alpine 3.21, and mainline HEAD.

Target

Project: torvalds/linux
Version: >= 5.7
Location: io_wq_remove_pending() in io_uring/io-wq.c

Technical Details

The inner check if (prev_work && io_get_work_hash(prev_work) == hash) omits io_wq_is_hashed(prev_work); because io_get_work_hash() is just flags >> 24 and returns 0 for every non-hashed item, a non-hashed predecessor spuriously matches bucket 0 and is stored in wq->hash_tail[0]. That pointer is never cleared on the non-hashed fast path, goes stale when the io_kiocb is kmem_cache_free()'d, and is later written through in io_wq_insert_work() (pos->next = node). KASAN reports slab-use-after-free in io_wq_enqueue within seconds when running the reproducer as an unprivileged user.

Crash signature: KASAN: slab-use-after-free in io_wq_enqueue (WRITE of size 8 at freed io_kiocb + 216)

Reproduction

  1. Pin task and limit io-wq to one bound worker; find a tmpfs file that hashes to bucket 0.
  2. Submit a 4-SQE batch: B=FSYNC on a dirtied ext4 file (blocks the worker), N=FSYNC (non-hashed), H=WRITEV on the bucket-0 file (hashed), C=ASYNC_CANCEL(H).
  3. CANCEL runs inline while the pending list is [N, H]; io_wq_remove_pending(H, prev=N) plants wq->hash_tail[0] = &N->work.
  4. Let N complete, flush the per-ring io_kiocb cache (drain-NOP on 6.16+, or munmap+close the ring and wait for io_ring_exit_work on older kernels), and wait out the RCU grace period so N is freed to SLUB.
  5. Free N's slab neighbours so the page returns to the buddy allocator, then spray thousands of pipes with F_SETPIPE_SZ to reclaim the page as kmalloc-cg-64 pipe_buffer[1] arrays; offset 256*K+216 lands on pipe_buffer.flags for every K.
  6. splice() one byte of the SUID binary's entry-point page into every sprayed pipe.
  7. Submit another hashed bucket-0 WRITEV; io_wq_insert_work() writes &new_io_kiocb->work.list (low byte 0xD8, bit 4 set) into the victim pipe_buffer.flags, setting PIPE_BUF_FLAG_CAN_MERGE.
  8. write() shellcode to every pipe; the corrupted one merges into the SUID binary's page-cache page.
  9. execve() the SUID binary; shellcode runs as root. Retry on miss (~10-25% per attempt).

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Add the missing io_wq_is_hashed(prev_work) check before comparing hashes in io_wq_remove_pending(): if (prev_work && io_wq_is_hashed(prev_work) && io_get_work_hash(prev_work) == hash). Patch 0001-io-wq-check-that-the-predecessor-is-hashed-in-io_wq_.patch attached; author also suggests alternatively always clearing hash_tail on cancel and re-establishing on next enqueue.

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-PVD37V09.


Reference: ANT-2026-PVD37V09
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

TIMELINE

Dates from discovery through public reveal.

  1. 2026-05-12 Sent to maintainer
  2. 2026-05-12 Maintainer acknowledged
  3. 2026-05-12 Patch released
  4. 2026-05-14 Reported to tracker
  5. 2026-07-13 Publicly revealed
PROVENANCE

SHA-3-512 hash:

a7e9f89b53dc32206b0904cae2476224b0524baf01050ea0f605e33aba927598c5b282ae8230e87f3e3b389a3479d05d73acf33cf59d6e504ff745af970a36af

Committed 2026-05-13 04:11 PT

Revealed 2026-07-13 19:22 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-PVD37V09",
  "bug_class": "Use-After-Free",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-05-14T22:42:58+00:00",
  "description": "In io_uring's io-wq worker pool, cancelling a hashed bucket-0 work item whose list predecessor is non-hashed causes io_wq_remove_pending() to install the non-hashed io_kiocb as wq->hash_tail[0], since io_get_work_hash() returns 0 for all non-hashed work. The non-hashed request completes and is freed via req_cachep while hash_tail[0] still points at it, and because hash_tail[] lives on the per-task io_wq it survives ring close. The next hashed bucket-0 enqueue writes an 8-byte kernel heap pointer to offset 216 of the freed 256-byte slab slot via wq_list_add_after(). An unprivileged local attacker controls the SQE sequence needed to trigger this deterministically, and by cross-cache reclaiming the freed page with pipe_buffer arrays can land the write on pipe_buffer.flags, set PIPE_BUF_FLAG_CAN_MERGE on a spliced page-cache page, and perform a Dirty Pipe overwrite of a SUID binary to gain root. Demonstrated on Ubuntu 22.04/24.04/26.04, Debian 11/13, Fedora 41, Rocky 9 (io_uring re-enabled), Alpine 3.21, and mainline HEAD.",
  "discovered_at": "2026-05-13T11:11:26+00:00",
  "location": "io_wq_remove_pending() in io_uring/io-wq.c",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "torvalds/linux",
  "reproduction": [
    "1. Pin task and limit io-wq to one bound worker; find a tmpfs file that hashes to bucket 0.",
    "2. Submit a 4-SQE batch: B=FSYNC on a dirtied ext4 file (blocks the worker), N=FSYNC (non-hashed), H=WRITEV on the bucket-0 file (hashed), C=ASYNC_CANCEL(H).",
    "3. CANCEL runs inline while the pending list is [N, H]; io_wq_remove_pending(H, prev=N) plants wq->hash_tail[0] = &N->work.",
    "4. Let N complete, flush the per-ring io_kiocb cache (drain-NOP on 6.16+, or munmap+close the ring and wait for io_ring_exit_work on older kernels), and wait out the RCU grace period so N is freed to SLUB.",
    "5. Free N's slab neighbours so the page returns to the buddy allocator, then spray thousands of pipes with F_SETPIPE_SZ to reclaim the page as kmalloc-cg-64 pipe_buffer[1] arrays; offset 256*K+216 lands on pipe_buffer.flags for every K.",
    "6. splice() one byte of the SUID binary's entry-point page into every sprayed pipe.",
    "7. Submit another hashed bucket-0 WRITEV; io_wq_insert_work() writes &new_io_kiocb->work.list (low byte 0xD8, bit 4 set) into the victim pipe_buffer.flags, setting PIPE_BUF_FLAG_CAN_MERGE.",
    "8. write() shellcode to every pipe; the corrupted one merges into the SUID binary's page-cache page.",
    "9. execve() the SUID binary; shellcode runs as root. Retry on miss (~10-25% per attempt)."
  ],
  "technical_details": "Hi --\n\nI believe I've found another local privilege escalation vulnerability\n\nin the kernel. This one is in io_uring's io-wq worker pool and doesn't\n\nrequire a second leak: it's a pure data-corruption chain that ends at\n\nDirty Pipe. The vuln itself is five years old and affects every kernel\n\nsince 5.7. I've gotten root with it on Ubuntu 22.04/24.04/ 26.04,\n\nDebian 11/13, Fedora 41, Rocky 9, Alpine 3.21, and mainline HEAD.\n\n(As in my prior bugs, I used an LLM to find this, and to write and\n\nport the exploit.)\n\nThe vulnerability is in io_wq_remove_pending(), in io_uring/io-wq.c.\n\nio-wq is io_uring's worker thread pool, used to run requests that\n\ncan't complete inline. Each task has one io-wq (tctx->io_wq). Pending\n\nwork is kept on a singly-linked list (acct->work_list). Write-type ops\n\non regular files (WRITEV, WRITE, FALLOCATE, etc.) are \"hashed\" by file\n\ninode so writes to the same file run serially rather than in parallel,\n\nand the tail of each inode's chain is cached in wq->hash_tail[bucket]\n\nso new work can be appended after it. Reads, NOPs, FSYNC, and\n\neverything else are \"non-hashed\" and any free worker can run them\n\nconcurrently.\n\nIORING_OP_ASYNC_CANCEL lets a user remove a pending work item before a\n\nworker dequeues it, which goes through io_wq_remove_pending(). If the\n\ncancelled item was the tail of its hash bucket, this function has to\n\ndecide what the new tail should be. It does that by comparing the\n\npredecessor's hash value:\n\n    static inline void io_wq_remove_pending(struct io_wq *wq,\n\n                                            struct io_wq_acct *acct,\n\n                                            struct io_wq_work *work,\n\n                                            struct io_wq_work_node *prev)\n\n    {\n\n        unsigned int hash = io_get_work_hash(work);\n\n        struct io_wq_work *prev_work = NULL;\n\n        if (io_wq_is_hashed(work) && work == wq->hash_tail[hash]) {\n\n            if (prev)\n\n                prev_work = container_of(prev, struct io_wq_work, list);\n\n            if (prev_work && io_get_work_hash(prev_work) == hash)\n\n                wq->hash_tail[hash] = prev_work;\n\n            else\n\n                wq->hash_tail[hash] = NULL;\n\n        }\n\n        wq_list_del(&acct->work_list, &work->list, prev);\n\n    }\n\nThe outer condition correctly checks that the cancelled work is\n\nhashed. But the inner one does not check that the *predecessor* is.\n\nThis matters because io_get_work_hash() is not a reliable\n\ndiscriminator: it's just `atomic_read(&work->flags) >> 24`, and the\n\nhash bits are never set on non-hashed work, so it returns 0 for every\n\nnon-hashed work item---which is also a valid bucket number. So when a\n\nhashed bucket-0 work (about 1/64 of regular files) is cancelled while\n\na non-hashed work happens to be its list predecessor, the check `0 ==\n\n0` spuriously passes and a pointer to the non-hashed io_kiocb is\n\nstored in wq->hash_tail[0]. This work item that is never part of any\n\nhash chain and never will be.\n\nBecause non-hashed work is dequeued via the fast path in\n\nio_get_next_work(), which never touches hash_tail[], nothing ever\n\nclears that stale pointer. The non-hashed io_kiocb completes normally,\n\ngoes back to the per-ring request cache, and is eventually\n\nkmem_cache_free()'d to req_cachep---at which point wq->hash_tail[0] is\n\na dangling pointer into freed slab. And the io_wq that holds\n\nhash_tail[] is per-*task* (tctx->io_wq), not per-ring. Closing the\n\nring only sets a worker-exit flag and never touches hash_tail[]. The\n\ndangling pointer therefore persists for the lifetime of the task,\n\nacross ring open/close.\n\nThe next hashed bucket-0 enqueue on that io_wq dereferences the\n\ndangling pointer in io_wq_insert_work() executes this code:\n\n    tail = wq->hash_tail[hash];     /* dangling */\n\n    wq->hash_tail[hash] = work;\n\n    if (!tail)\n\n        goto append;                /* not taken */\n\n    wq_list_add_after(&work->list, &tail->list, &acct->work_list);\n\nwq_list_add_after() does `pos->next = node` into the freed object at\n\noffsetof(io_kiocb, work.list.next) == 216. This gives the attack\n\nprimitive, an 8-byte write of a kernel heap pointer\n\n(&new_io_kiocb->work.list) into offset 216 of a freed 256-byte\n\nio_kiocb slab slot.\n\nI think this was introduced by 204361a77f40 (\"io-wq: fix hang after\n\ncancelling pending hashed work\", 2020-08-23), which added this\n\npredecessor fixup and was Cc: stable@ # 5.7+.\n\nThe attached crasher.c is a minimal KASAN trigger. It pins the task\n\nand the io-wq worker to CPU 0 and limits the io-wq to a single bound\n\nworker. Then it iterates over tmpfs files (~1/64 of which land in\n\nbucket 0), and per file submits a four-SQE batch in one\n\nio_uring_enter(). I'll call the four requests B (a blocker), N (the\n\nnon-hashed predecessor that gets planted in hash_tail), H (the\n\nhashed work that gets cancelled), and C (the cancel):\n\n    B: FSYNC(ext4_blocker)    bound, non-hashed, keeps the worker busy\n\n    N: FSYNC(f)               bound, non-hashed\n\n    H: WRITEV(f)              bound, hashed  (WRITEV has hash_reg_file)\n\n    C: ASYNC_CANCEL(H)        inline\n\nThe blocker is a freshly-dirtied ext4 file, so the single worker\n\nsits in vfs_fsync() for milliseconds; by the time the inline CANCEL\n\nruns, the pending list is [N, H]. io_wq_remove_pending(H, prev=&N)\n\nfires with N as the non-hashed predecessor and plants hash_tail[0]\n\n= &N->work. After N completes, a NOP with IOSQE_IO_DRAIN flushes\n\nthe per-ring cache (io_drain_req -> io_queue_deferred ->\n\n__io_req_caches_free -> kmem_cache_free(req_cachep, N)), a\n\nmembarrier(GLOBAL) waits out the SLAB_TYPESAFE_BY_RCU grace period,\n\nand a second WRITEV on the same file dereferences the dangling\n\nhash_tail[0]. Under a KASAN kernel this reports slab-use-after-free\n\nin io_wq_enqueue within a few seconds, running as a plain\n\nunprivileged user.\n\nOne detail that cost me (rather: my LLM) some time is that none of\n\nthese SQEs can use IOSQE_ASYNC. With IOSQE_ASYNC, io_submit_sqe()\n\nroutes the request to io_queue_sqe_fallback() -> io_queue_iowq() ->\n\nio_prep_async_work() *before* io_assign_file() runs, so req->file is\n\nNULL and the REQ_F_ISREG hashing branch is skipped. Without it, FSYNC\n\nand tmpfs-WRITEV both take -EAGAIN from io_issue_sqe() (which has\n\nassigned req->file) and punt through io_queue_async() ->\n\nio_queue_iowq() with req->file set, which is the path that hashes.\n\nThe drain-NOP cache flush the crasher uses was added in commit\n\n8fb7aee05591 and first appears in v6.16, so the crasher as attached\n\ntargets HEAD and other 6.16+ kernels; older kernels need to close the\n\nring and wait for io_ring_exit_work() instead (see below).\n\nThe attached exploit.c turns this UAF write into an LPE.\n\nTo recap what we have: after the trigger, hash_tail[0] points at a\n\nfreed io_kiocb (call it N) somewhere on a slab page owned by\n\nreq_cachep. The next hashed bucket-0 enqueue will write an 8-byte\n\nkernel heap pointer to N + 216. I don't get to choose the value\n\nwritten---it's whatever address the new io_kiocb happens to be\n\nallocated at---and I don't get to choose when the slot gets reused.\n\nSo this is a narrow primitive: one write of an uncontrolled value to\n\na fixed offset inside freed memory.\n\nThe exploit's job is to put something at N + 216 where writing an\n\nuncontrolled kernel pointer does something useful. The exploit targets\n\nstruct pipe_buffer.flags, because it has two properties that make it\n\ntolerate an uncontrolled write:\n\n  - Only one bit of .flags matters: anon_pipe_write() tests\n\n    PIPE_BUF_FLAG_CAN_MERGE (bit 4, 0x10). A pipe is a ring of\n\n    pipe_buffers, each one holding a reference to a page and a .flags\n\n    field. Anonymous pipe data carries CAN_MERGE so small write()s can\n\n    append into the existing page instead of allocating a new one. But\n\n    a pipe_buffer that was filled by splice()ing file content in\n\n    references the file's *page-cache* page directly, and splice\n\n    deliberately leaves CAN_MERGE clear --- merging into that page\n\n    would silently modify the cached file. If an attacker can set\n\n    CAN_MERGE on a spliced buffer, a plain write() to the pipe\n\n    scribbles into the page cache of a read-only file. That's the\n\n    Dirty Pipe bug (CVE-2022-0847). I'm re-creating the bad state\n\n    through the UAF rather than the original pipe_init() bug.\n\n  - The uncontrolled write happens to set that bit. The value\n\n    written is &H2->work.list where H2 is a live io_kiocb. io_kiocb\n\n    is 248 bytes, HWCACHE-aligned to a 256-byte slot, and work is at\n\n    offset 216 --- so the written pointer is always\n\n    (some multiple of 256) + 216, and its low byte is always\n\n    216 == 0xD8. 0xD8 has bit 4 set, so CAN_MERGE is set. The other\n\n    bits in 0xD8 and the garbage in the upper bytes of .flags aren't\n\n    consulted by pipe_write().\n\nSo the plan is to arrange for N + 216 to be the .flags field of a live\n\npipe_buffer that's holding a page-cache page of a SUID binary, then\n\ntrigger the write, then Dirty Pipe. Two things stand in the way:\n\n  - req_cachep is a dedicated slab cache that only holds io_kiocb,\n\n    so I can't reclaim N's slot directly with a pipe_buffer. But all\n\n    slab caches draw their backing pages from the same page\n\n    allocator (the \"buddy\" allocator), so I can reclaim at the page\n\n    level instead: free every io_kiocb on N's slab page, SLUB\n\n    returns the empty page to the buddy allocator, and a\n\n    kmalloc-cg-64 allocation pulls the same page back as its own\n\n    fresh slab. pipe2() followed by fcntl(F_SETPIPE_SZ, 4096)\n\n    allocates a pipe_buffer[1] array via kcalloc(1, 40), which goes\n\n    to kmalloc-cg-64, so spraying a few thousand pipes reclaims the\n\n    page with pipe_buffer arrays. This is the fragile part of the\n\n    exploit; see the per-kernel notes below.\n\n  - I don't know where N was on its page. io_kiocb slots are at\n\n    bytes 0, 256, 512, ..., 3840 of the page --- 256*K for K in\n\n    0..15, 16 slots, and SLUB chose K. After the cross-cache the\n\n    same page holds 64-byte kmalloc-cg-64 slots, and I need\n\n    256*K + 216 to land on a pipe_buffer's .flags for *any* K.\n\n    Conveniently it does:\n\n        256*K + 216 = 64*(4K + 3) + 24\n\n    i.e. slot (4K + 3) at byte 24, for every K in 0..15. And\n\n    offsetof(struct pipe_buffer, flags) == 24. So I don't need to\n\n    know K.\n\nPutting it together, the attack tries to trigger the plant; free N and\n\nits slab-mates and wait for the page to reach the buddy allocator;\n\nspray pipes sized to one pipe_buffer; splice() one byte of a SUID\n\nbinary's entry-point page into every pipe; trigger the write; write()\n\nshellcode to every pipe (the one with the stomped .flags merges it\n\ninto the SUID binary's page cache); execve() the SUID binary. When it\n\nworks, the shellcode runs as root. The cross-cache reclaim is\n\nprobabilistic, so the exploit retries if it fails. My observed success\n\nrate is roughly 10-25% per attempt, and it typically lands within\n\n10-150 attempts, under a minute or two.\n\nI've verified that this exploit affects a number of distro kernels. At\n\nthe bottom of the email I've provided some analysis of what's\n\nnecessary to tweak for each of the attacks. (The per-kernel tweaks are\n\nall in the grooming and cross-cache stages; the bug, the UAF write,\n\nand the Dirty Pipe payload are identical everywhere.)\n\nI've also attached a patch that adds the missing io_wq_is_hashed()\n\ncheck on the predecessor. This is the smallest change I could find\n\nthat fixes the vulnerability, but I don't know if it's the right\n\nchange (maybe hash_tail should just always be cleared on cancel and\n\nre-established on the next enqueue?). Feel free to disregard.\n\nThanks,\n\nNicholas\n\nAttached:\n\n  - crasher.c: minimal KASAN reproducer, runs as any unprivileged\n\n    user. Needs a tmpfs /tmp and an ext4 /var/tmp (or cwd); does\n\n    not need a SUID target. Targets HEAD / 6.16+ (uses the\n\n    drain-NOP cache flush).\n\n  - exploit.c: the LPE for HEAD / Ubuntu 26.04 / Ubuntu 24.04 HWE\n\n    (kernels where offsetof(io_kiocb, work) = 216, the dedicated\n\n    req_cachep cache exists, and the drain-NOP cache flush is\n\n    present). Targets a SUID ELF at /suid_target whose _start is at\n\n    file offset 0x80 (see reproduction notes). Ports to other\n\n    kernels per the list above with changes to the pipe_buffer\n\n    retarget, the cache-flush mechanism, and the grooming\n\n    constants.\n\n  - root_helper.c: a tiny root-owned helper the shellcode execves.\n\n  - 0001-io-wq-check-that-the-predecessor-is-hashed-in-io_wq_.patch\n\n      --- a/io_uring/io-wq.c\n\n      +++ b/io_uring/io-wq.c\n\n      @@ io_wq_remove_pending @@\n\n           if (io_wq_is_hashed(work) && work == wq->hash_tail[hash]) {\n\n               if (prev)\n\n                   prev_work = container_of(prev, struct io_wq_work, list);\n\n      -        if (prev_work && io_get_work_hash(prev_work) == hash)\n\n      +        if (prev_work && io_wq_is_hashed(prev_work) &&\n\n      +            io_get_work_hash(prev_work) == hash)\n\n                   wq->hash_tail[hash] = prev_work;\n\n               else\n\n                   wq->hash_tail[hash] = NULL;\n\n           }\n\nReproduction of the LPE on stock Ubuntu 26.04 (the attached\n\nexploit.c runs here unmodified):\n\n  wget\n\nhttps://cloud-images.ubuntu.com/daily/server/resolute/current/resolute-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\n\nmeta-data\n\n  qemu-img create -f qcow2 -F qcow2 \\\n\n    -b resolute-server-cloudimg-amd64.img disk.qcow2 8G\n\n  qemu-system-x86_64 -enable-kvm -m 2G -smp 2 -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 exploit exploit.c\n\n  gcc -O2 -static -o root_helper root_helper.c\n\n  scp -P 2222 exploit root_helper ubuntu@localhost:/tmp/   # password 'test'\n\n  # ssh -p 2222 ubuntu@localhost, then set up the SUID test target\n\n  # (this is test scaffolding only -- any SUID ELF with a few bytes\n\n  #  of writable entry-point padding works; the exploit hijacks it\n\n  #  via page-cache write, the file on disk is never touched):\n\n  sudo install -o root -m 0755 /tmp/root_helper /root_helper\n\n  python3 - <<'PY' | sudo tee /suid_target >/dev/null\n\n  import struct,sys\n\n  # minimal static x86-64 ELF, _start at file offset 0x80 = 128 NOPs +\n\nexit(0)\n\n  eh = struct.pack('<4s5B7x2HI3QI6H', b'\\x7fELF',2,1,1,0,0, 2,0x3e, 1,\n\n       0x400080, 0x40, 0, 0, 64, 56, 1, 0, 0, 0)\n\n  ph = struct.pack('<2I6Q', 1,5, 0, 0x400000, 0x400000, 0x200, 0x200,\n\n0x1000)\n\n  body = b'\\x90'*128 + b'\\x48\\x31\\xff\\x6a\\x3c\\x58\\x0f\\x05'\n\nsys.stdout.buffer.write(((eh+ph).ljust(0x80,b'\\0')+body).ljust(0x200,b'\\0'))\n\n  PY\n\n  sudo chmod 4755 /suid_target\n\n  sudo useradd -m testuser\n\n  sudo install -o testuser /tmp/exploit /home/testuser/exploit\n\n  sudo -u testuser -i\n\n  id\n\n  # uid=1001(testuser) gid=1001(testuser)\n\n  ./exploit\n\n  # [*] io_uring io-wq hash_tail[] UAF -> Dirty Pipe LPE\n\n  # [*] uid=1001 euid=1001\n\n  # [*] probing 384 ramfs files for io-wq hash bucket 0...\n\n  # [+] f7 hashes to bucket 0 (candidate 1/4)\n\n  # ...\n\n  # [*] attempt  4/80: W merged=1\n\n  # [+] Dirty Pipe write landed -- /suid_target hijacked\n\n  # [*] execve(/suid_target)\n\n  # uid=0 gid=0 euid=0 egid=0\n\n  # root@...# id; head -1 /etc/shadow\n\n  # uid=0(root) gid=0(root) groups=0(root),1001(testuser)\n\n  # root:$y$j9T$...:20584:0:99999:7:::\n\nFor the KASAN crasher, build HEAD with KASAN:\n\n  git clone\n\nhttps://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git\n\n  cd linux\n\n  make defconfig\n\n  scripts/config -e KASAN -e KASAN_GENERIC\n\n  make olddefconfig\n\n  make -j$(nproc) bzImage\n\nboot with any rootfs, and run ./crasher unprivileged; expect a\n\nslab-use-after-free in io_wq_enqueue within a few seconds.\n\nImpact:\n\n  * Linux HEAD (7.1.0-rc2-g81d6f7807536, x86_64_defconfig) -- the\n\n    reference target. offsetof(io_kiocb, work) = 216, kmalloc-cg-64.\n\n    Root.\n\n  * Ubuntu 26.04 (7.0.0-14-generic) -- the attached exploit.c ran\n\n    unchanged from the HEAD build. All offsets identical. Root on\n\n    attempt 4 and 9 of 80, under 10 seconds each.\n\n  * Ubuntu 24.04 HWE (6.17.0-23-generic) -- ran unchanged. Same\n\n    offsets, and 6.17 has the drain-NOP cache flush. 6.17 is still\n\n    classic SLUB (the per-CPU sheaf allocator the HEAD exploit was\n\n    tuned against landed in 7.0) but the grooming constants worked\n\n    anyway. Root on attempt 5 and 13.\n\n  * Debian 13 / trixie (6.12.86+deb13-amd64) -- same offsets.\n\n    Pre-v6.16 kernels don't flush the per-ring io_kiocb free list\n\n    from io_queue_deferred(), so the exploit has to munmap() and\n\n    close() the trigger ring and wait for io_ring_exit_work() to\n\n    free the cache (munmap is needed because the SQ/CQ/SQE mappings\n\n    hold vm_file references; close() alone doesn't start teardown).\n\n    io_ring_exit_work runs on an unbound kworker on a different\n\n    CPU, so N's freed page lands deep in that CPU's PCP free list;\n\n    the pipe spray was raised to 8000 and split across both CPUs.\n\n    Root 3/3 cold runs.\n\n  * Alpine 3.21 (linux-virt 6.12.81-0) -- same offsets, same\n\n    pre-v6.16 close-and-reopen workaround as Debian 13. One\n\n    Alpine-specific trap: the default pipe is 8 slots (a 320-byte\n\n    pipe_buffer array on an order-1 slab page), so shrinking\n\n    straight to 1 slot frees enough order-1 pages to trip the\n\n    buddy allocator's free_high heuristic and drain the whole PCP\n\n    mid-spray, evicting N's page before it can be reclaimed. The\n\n    port shrinks the pipes in a step that keeps the frees order-0.\n\n    Root, ~1 in 2-5 cold boots.\n\n  * Fedora 41 (6.11.4-301.fc41) -- same offsets. Same pre-v6.16\n\n    close-and-reopen ring workaround, and the pipe spray runs from\n\n    both CPUs to chase the unbound kworker. SELinux enforcing\n\n    (targeted policy) doesn't help---the exploit's syscall surface\n\n    is all allowed for unconfined_t. Root on attempt 15 and 18.\n\n  * Rocky Linux 9.7 / RHEL 9 (5.14.0-611.5.1.el9_7) -- same\n\n    offsets (216). Same two-ring close-and-reopen, and the spray\n\n    alternates CPUs. The important caveat: RHEL 9 ships\n\n    kernel.io_uring_disabled=2 by default, which hard-disables\n\n    io_uring for everyone and blocks the bug completely. I had to\n\n    set it to 0 to test. Any system that's re-enabled io_uring\n\n    (Podman, PostgreSQL 17+, QEMU) is fully exploitable. Root on\n\n    attempt 17/200 under Enforcing.\n\n  * Ubuntu 24.04 GA and 22.04 HWE (both 6.8.0-xx-generic) --\n\n    offsetof(io_kiocb, work) is 208 on 6.8, not 216.\n\n    256*K + 208 lands on pipe_buffer.ops in kmalloc-cg-64, which\n\n    is a guaranteed oops. I retargeted kmalloc-cg-192\n\n    (F_SETPIPE_SZ(16384) -> kcalloc(4, 40) -> pipe_buffer[4]);\n\n    the write then lands on pipe_buffer[3].flags for 5 of 16\n\n    slots. The low byte is 0xD0 instead of 0xD8, but\n\n    0xD0 & CAN_MERGE is still set. Also needed the two-ring\n\n    close workaround and munmap() of the SQ/CQ/SQE mappings\n\n    (close() alone doesn't drop the last vm_file reference on\n\n    6.8). Root on attempt 1-16 across runs.\n\n  * Ubuntu 22.04 GA (5.15.0-173-generic) -- offsetof(io_kiocb,\n\n    work) is 184 on 5.15, and io_kiocb is merged into the shared\n\n    :A-0000256 cache rather than a dedicated cache. Retargeted\n\n    kmalloc-cg-96 (F_SETPIPE_SZ(8192) -> pipe_buffer[2]); 5/16\n\n    slots land on .flags, low byte 0xB8 (CAN_MERGE still set).\n\n    5.15's classic SLUB plus LIFO free order means N's slab page\n\n    parks on the per-CPU partial list and never reaches buddy; a\n\n    post-trigger POLL_ADD burst pushes enough extra pages through\n\n    to discard it. Root on attempt 17/120.\n\n  * Debian 11 / bullseye (5.10.0-42-amd64, 5.10.251-4) -- the\n\n    oldest kernel I tried. offsetof(io_kiocb, work) is 184;\n\n    merged :A-0000256 cache (and no kmalloc-cg-* split yet on\n\n    5.10). Retargeted kmalloc-96 / pipe_buffer[2]; same 5/16\n\n    alignment as 5.15, low byte 0xB8. The per-ring recycle cache\n\n    on 5.10 is very aggressive and NOPs never hit SLUB, so the\n\n    groom uses POLL_ADDs on a never-readable pipe (each one holds\n\n    its io_kiocb until ring teardown). Plus a pre-drain of 768\n\n    pipes to exhaust the shared cache's partial slabs. Root on\n\n    attempt 10/150.\n\n  * Debian 12 / bookworm (6.1.0-47-amd64, 6.1.170-3) -- the bug\n\n    is present and I demonstrated the UAF write, a kernel-heap\n\n    pointer infoleak (via a reclaimed signalfd's /proc fdinfo),\n\n    and a kernel panic on close. But offsetof(io_kiocb, work) is\n\n    200 on 6.1, and 256*K + 200 mod 64 == 8 lands on\n\n    pipe_buffer.offset, not .flags. I didn't find a reclaim\n\n    target that lines up on 6.1; the Dirty Pipe chain as written\n\n    didn't port. The memory-safety violation, infoleak, and DoS\n\n    are all demonstrated regardless.",
  "title": "LPE via io_uring io-wq worker pool",
  "vendor_severity": null
}