_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

cryptodev-linux: the January 2026 PoC doesn't actually exploit any real distro

2026-05-06 · linuxkernelcryptodevuafoot-moduleexploit-devdisclosure

on 2026-01-12 nasm.re published a vulnerability in cryptodev-linux, the out-of-tree kernel module that hands the kernel crypto API to userspace through /dev/crypto. the bug is real. the PoC that ships with it ends on a uid=0 prompt with an SELinux context attached, which reads like a working LPE. cryptodev hasn't fixed it; the FIXME comment sitting on the offending line is four years old. i had cryptodev loaded on a Resolute lab box anyway for other work, saw the writeup, and figured i'd spend an afternoon confirming it.

it doesn't land on Ubuntu 26.04 LTS Resolute. it doesn't land on Ubuntu 22.04 LTS Jammy even with init_on_alloc=0 on the kernel cmdline. it doesn't land anywhere a real person runs Linux. the bug class isn't fake, the published chain only works on a hand-rolled defconfig kernel. below is the knob that kills the PoC, what nasm's box actually was, and a separate refcount leak i turned up in cryptodev's __get_userbuf path while i was in there.

the bug

cryptodev-linux/zc.c:234, inside get_userbuf():

if (likely(src)) {
    rc = __get_userbuf(src, src_len, 0, ses->readonly_pages,
                       ses->pages, ses->sg, task, mm);
    if (unlikely(rc)) {
        derr(1, "failed to get user pages for data input");
        return rc;
    }
}

if (likely(dst)) {
    const unsigned int writable_pages =
        ses->used_pages - ses->readonly_pages;
    struct page **dst_pages = ses->pages + ses->readonly_pages;

    rc = __get_userbuf(dst, dst_len, 1, writable_pages,
                       dst_pages, *dst_sg, task, mm);
    if (unlikely(rc)) {
        derr(1, "failed to get user pages for data output");
        release_user_pages(ses);  /* FIXME: use __release_userbuf(src, ...) */
        return rc;
    }
}

on the dst-failure path, release_user_pages(ses) walks ses->pages[0..used_pages] and put_pages every entry. trouble is ses->pages is a per-session cache, and it never got cleared after the last successful operation. so the entries it walks are stale page pointers from a previous call whose references were already dropped.

the sequence:

  1. Prime. call CIOCCRYPT with valid src/dst. get_userbuf() fills ses->pages[0..N] with fresh GUP refs on the user buffers (the anon pages behind plain and cipher). the op succeeds and release_user_pages drops those refs at the end. ses->pages[0..N] still holds the same struct page * values afterward, but the kernel-side refcount on each is back to 1 - the user mapping.
  2. Trigger. call CIOCCRYPT with src=NULL, dst=0xdeadbeef. get_userbuf sets used_pages = 256 (the dst pagecount), then __get_userbuf(dst) fails on the bad address. the FIXME branch fires release_user_pages(ses), which walks ses->pages[0..256]. those entries are the still-mapped src pages from the prime. each put_page drops their refcount from 1 to 0.
  3. Reuse. the pages are refcount-0 now, so the kernel "frees" them back to the page allocator. the user PTE mapping plain[] still points at the same physical pages. open enough file descriptors that the kernel pulls fresh kmalloc-256 slab pages off the buddy allocator; if a struct file lands in one of our freed pages, the user-readable plain[] mapping now reflects the kernel's struct file contents , including the f_op pointer at &ext4_file_operations. from there you mode-flip the f_mode field through the same dangling mapping, write an nasm::0:0:root:/root:/bin/bash line into the now-writable /etc/passwd, and su over.

the walk in step 2 is the bug. the reuse in step 3 is the exploitation strategy nasm's PoC bets on - and that's the part that falls apart on distro kernels.

what nasm claimed

the tail of the published PoC shows:

nasm@syzkaller:~$ ./poc
[*] Increasing file descriptor limit...
[*] Triggered the bug...
[*] Spraying file objects...
[+] Found potential ext4_ops: 0xffffffffab631340 at offset 0x8
[*] Done. Check /etc/passwd for new root user 'nasm'.

nasm@off:/media/cryptodev-linux-exploit$ ssh -p 10021 nasm@127.0.0.1
nasm@syzkaller:~# id
uid=0(nasm) gid=0(root) groups=0(root) context=system_u:system_r:kernel_t:s0

that output is internally consistent. the find_ext4_ops heuristic fires (0xffffffffab631340 is a kernel-half pointer with low bits matching the EXT4_FOPS_OFFSET the PoC scans for), and the /etc/passwd edit that follows hands back a uid=0 shell.

one line in nasm's own build instructions turns out to carry the whole thing:

I used the following kernel options to compile my kernel:
make defconfig && make kvm_guest.config && \
    ./scripts/config -e CONFIG_DEBUG_INFO_DWARF4 -e CONFIG_CONFIGFS_FS && \
    make olddefconfig

You can download the kernel source from
https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.15.4.tar.gz

that is not a distro kernel. the nasm@syzkaller hostname gives it away - it's a hand-rolled kvm guest image. the kernel came off a linux-6.15.42 tarball, defconfig + kvm_guest.config + two extra knobs, and none of the hardening that distros stack on top of defconfig is in it.

reproduction attempt 1: Resolute

Ubuntu 26.04 LTS Resolute, kernel 7.0.0-15-generic, prod-equivalent (no KASAN). built cryptodev.ko from upstream HEAD against /lib/modules/$(uname -r)/build, loaded it as root, confirmed /dev/crypto comes up crw-rw-rw- (mode 0666, world-readable). compiled the gist exploit untouched except for one tuning: EXT4_FOPS_OFFSET moved from 0x340 (nasm's 6.15.42) to 0xe40 (Resolute's 7.0.0-15-generic, verified with sudo grep " ext4_file_operations" /proc/kallsyms: ffffffffb9462e40 D ext4_file_operations).

ran it as np - uid 1001, no supplementary groups, no sudo. it runs to completion. /etc/passwd is unchanged. find_ext4_ops returns 0 hits in plain and cipher both, across all 16 retry iterations the PoC does before it gives up. that was the first sign something in the environment didn't match the writeup.

one thing did move: kernel.tainted went from 12288 to 12320. bit 5 is TAINT_BAD_PAGE. the kernel caught the bug and stepped in. tail /etc/passwd shows nothing new.

Resolute's hardening config:

$ grep -E 'CONFIG_(KASAN|RANDOM_KMALLOC_CACHES|SLAB_FREELIST_RANDOM|INIT_ON_ALLOC_DEFAULT_ON|HARDENED_USERCOPY)' /boot/config-$(uname -r)
# CONFIG_KASAN is not set
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_RANDOM_KMALLOC_CACHES=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
CONFIG_HARDENED_USERCOPY=y

my first hypothesis was RANDOM_KMALLOC_CACHES: it randomizes per-call-site kmalloc-N bucket placement on Linux 6.6+. with 16 randomized buckets, the naive open() spray's struct file allocations only land in our freed pages about 1/16 of the time. even at 8000 opens that's a coin flip you keep losing. wrong knob, as it turned out.

i spent a few hours building three exploit variants on top of the gist:

  1. v2: msg_msg cross-cache. 200 000 messages of 224 B across 3334 msg queues, into the dedicated kmalloc-cg-256 bucket via CONFIG_SLAB_BUCKETS. msg_msg lives in a non-randomized bucket, so the landing is deterministic by design. Result: 0 cookies, 0 kernel-pointer hits in plain[]/cipher[]. the pages still carry their pre-trigger 0xAA contents.
  2. v3: PACKET_TX_RING UNMOVABLE drain (Will Wu / Will's Root recipe). wrapped in aa-rootns to grab CAP_NET_RAW inside an unprivileged userns (prior post). allocated 1024 PF_PACKET TX rings × 256 pages each = 1 GB of UNMOVABLE order-0 allocations, forcing buddy fallback to MOVABLE so the freed anon pages get pulled into kmalloc-256 slab refills. then the 200 000 msg_msg spray. Result: plain[] still holds 0xAA. nothing reclaims the page.
  3. v4: memfd-backed buffers. moved plain and cipher off MAP_ANONYMOUS onto memfd_create + MAP_SHARED, on the idea that pagecache mapcount semantics behave differently. Result: VM hung mid-exploit, reverted with virsh snapshot-revert. tentative read: wrong knob.

same outcome every time. the bug fires, the page never gets reused. that was the point where i stopped trying to tune the spray and started reading page_alloc.c.

the actual blocker: free_pages_check mapcount validation

mm/page_alloc.c:

static inline bool page_expected_state(struct page *page,
                                       unsigned long check_flags)
{
    if (unlikely(atomic_read(&page->_mapcount) != -1))
        return false;
    ...
}

static bool free_page_is_bad(struct page *page)
{
    if (likely(page_expected_state(page, PAGE_FLAGS_CHECK_AT_FREE)))
        return false;

    /* Something has gone sideways, find it */
    bad_page(page, page_bad_reason(page, PAGE_FLAGS_CHECK_AT_FREE));
    return true;
}

anon pages keep their mapping count in page->_mapcount. -1 means no process maps the page. a user PTE referencing it bumps that to 0 (mapcount=1) via page_add_anon_rmap, and up from there. a still-mapped page therefore has _mapcount >= 0, and that fails the page_expected_state check.

what cryptodev's trigger does is exactly drop a still-mapped page's refcount to 0 without dropping the PTE reference first. free_page_is_bad sees the mismatch, calls bad_page(), which:

page_mapcount_reset(page);    // _mapcount = -1
page->mapping = NULL;
page_ref_init(page);          // refcount = 1
add_taint(TAINT_BAD_PAGE, LOCKDEP_NOW_UNRELIABLE);

and then returns true from free_page_is_bad, which propagates up: the page is not returned to the buddy allocator. it gets quarantined. no list, no free queue, just sitting there with refcount 1 and the user PTE still pointing at it. the 0xAA bytes from the prime call sit there indefinitely. no later allocation can ever land in that page because nothing knows it exists.

that's why every spray strategy came back with 0 hits.

the static-key gate: check_pages_enabled

that mapcount check only runs if a static key is enabled:

// mm/page_alloc.c
static inline bool is_check_pages_enabled(void)
{
    return static_branch_unlikely(&check_pages_enabled);
}

// callers
if (is_check_pages_enabled()) {
    if (free_page_is_bad(page))
        bad++;
    if (bad)
        return false;
}

the static branch is gated by CONFIG_DEBUG_VM at compile time, but mm/mm_init.c's mem_debugging_and_hardening_init can also flip it on at runtime under a handful of conditions:

if (_init_on_alloc_enabled_early) {
    want_check_pages = true;
    static_branch_enable(&init_on_alloc);
}
if (_init_on_free_enabled_early) {
    want_check_pages = true;
    static_branch_enable(&init_on_free);
}
...
if (page_poisoning_enabled() || debug_pagealloc_enabled()) {
    want_check_pages = true;
}
...
if (!IS_ENABLED(CONFIG_DEBUG_VM) && want_check_pages)
    static_branch_enable(&check_pages_enabled);

so check_pages_enabled is on whenever any of these hold:

distros set CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y for the uninit-leak protection it buys. that one flag enables the page-state check on its own. nasm's defconfig kernel has CONFIG_INIT_ON_ALLOC_DEFAULT_ON=n, so on his kernel the static key was off, the mapcount check never ran, and his freed pages reached the buddy allocator clean and got reused.

every distro kernel ships with that check effectively on.

reproduction attempt 2: jammy with init_on_alloc=0

the cleanest test of the theory: take a real distro kernel and turn init_on_alloc off at boot. if the check_pages_enabled story is right, the mapcount check goes away, the freed pages reach buddy, and the gist's PoC should fire straight up.

Ubuntu 22.04 LTS Jammy, kernel 5.15.0-176-generic. RANDOM_KMALLOC_CACHES isn't backported to 5.15, so the cross-cache lottery doesn't apply here. edited /etc/default/grub:

GRUB_CMDLINE_LINUX="init_on_alloc=0"

ran update-grub, rebooted, checked:

$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.15.0-176-generic root=UUID=... ro init_on_alloc=0 console=tty1 console=ttyS0

$ sudo dmesg | grep "mem auto-init"
[    0.060177] mem auto-init: stack:off, heap alloc:off, heap free:off

built and loaded cryptodev.ko. re-tuned EXT4_FOPS_OFFSET off sudo grep ext4_file_operations /proc/kallsyms: ffffffff9e64f700, low bits 0x700. made np a non-sudoer unprivileged user. ran the PoC.

still doesn't land. find_ext4_ops returns 0 hits across every retry. kernel.tainted climbs to 12320 again.

the dmesg trail is different from Resolute though:

BUG: Bad page map in process exploit  pte:8000000169a9a867 pmd:10867b067
BUG: Bad page map in process exploit  pte:8000000169a99867 pmd:10867b067
BUG: Bad page map in process exploit  pte:8000000169a98867 pmd:10867b067
... (× ~3000)
BUG: Bad rss-counter state mm:00000000e597b7a0 type:MM_FILEPAGES val:-2827
BUG: Bad rss-counter state mm:00000000e597b7a0 type:MM_ANONPAGES val:3341

that's print_bad_pte from mm/memory.c, not bad_page from mm/page_alloc.c. a different sanity layer. with the page-allocator check off, the freed pages did get reused this time. the rss-counter mismatch says ~2800 pages got reclassified from anon to file-backed accounting. and the exploit's find_ext4_ops heuristic still failed.

the reason is migration-type isolation. anon-mmap'd pages come from the MOVABLE pageblock. struct file (via filp_cachep) and msg_msg (via kmalloc-256) come from UNMOVABLE pageblocks. the buddy allocator keeps the two pools apart. UNMOVABLE only falls back to MOVABLE under sustained pressure that drains the UNMOVABLE freelists, and the PoC's stress_pcp_flush(300000) + 10485 open() calls don't apply that. they just allocate a pile of MOVABLE anon pages.

Will Wu's "Game of Cross Cache" documents the canonical UNMOVABLE-drain primitive (PACKET_TX_RING with tp_block_size=PAGE_SIZE). that's what v3 above implemented. it wants CAP_NET_RAW, which a default unprivileged user doesn't have. i got it through the aa-rootns userns bypass to test. still didn't land. the drain ran (1024 sockets × 256 pages = 1 GB), the spray ran, and none of it overlapped our specific freed pages.

net: even with check_pages_enabled off, there's a second wall between the bug and exploitation, and the gist never touches it.

what nasm's environment actually was

defconfig + kvm_guest.config gives you a kernel where:

on that kernel the gist's heuristic spray works because the anon-page-reuse path, the whole premise of the exploit, is unblocked. boot with any of the distro flags above and the premise falls apart.

no shipping Linux distribution leaves INIT_ON_ALLOC_DEFAULT_ON=y off in 2026. Ubuntu, Fedora, Debian, RHEL, SUSE, Arch's linux-hardened, even minimal ones like Alpine. all of them set it. the only places this PoC actually fires:

for the systems where anyone is actually running cryptodev-linux - production servers with crypto accelerators, HSM appliances, embedded network gear - the published PoC is a paper exploit.

the bug is still real, just not as a uid=0 primitive

on a hardened distro the cryptodev UAF degrades to:

none of those are LPE. the bug class is exploitable in principle, and on a weaker kernel it plainly is, but the published chain doesn't carry over to where people actually deploy this code. i'm not going to pretend otherwise.

sibling audit: __get_userbuf partial-pin leak

while i was in there anyway i swept the rest of cryptodev for the same shape. one real finding, low-severity and distinct from the disclosed bug:

cryptodev-linux/zc.c:97:

ret = get_user_pages_remote(mm, (unsigned long)addr, pgcount,
                            write ? FOLL_WRITE : 0, pg, NULL);
...
mmap_read_unlock(mm);
if (ret != pgcount)
    return -EINVAL;

get_user_pages_remote can return partial success. a value 0 < ret < pgcount means the kernel pinned ret pages and stored them at pg[0..ret], with their refcounts already bumped. cryptodev returns -EINVAL on that path and never releases the partial pins.

the callers - get_userbuf at zc.c:209, get_userbuf_tls at authenc.c:84, get_userbuf_srtp - all do the same thing and return straight away. none of them call release_user_pages for the partial-pinned set, because ses->used_pages was set to the requested count, not the returned count.

reachable from unprivileged context, anyone with O_RDWR on /dev/crypto. trigger is CIOCCRYPT with a cop.src / cop.dst pointer that straddles a partial-COW or sparse-mapping boundary where get_user_pages_remote succeeds for some pages and fails for others. each call leaks ret page-pin counts.

severity low to medium. it's memory-exhaustion / pin-attacker-pages-forever DoS, not a corruption primitive. RLIMIT_MEMLOCK doesn't apply to GUP. pinned pages aren't migratable, so this also breaks memory-hotplug and NUMA balancing on systems that lean on those.

fair CVE-LOW. reported through cryptodev-linux's GitHub issue tracker; i'll update this post when an ID lands.

three other shapes that looked suspicious turned out fine on a closer read: the authenc.c:136 SRTP int-cast truncation, the cryptodev_get_dst_len signed overflow at authenc.c:195, and the unreachable !ses_ptr check at ioctl.c:401. none of them cross a real security boundary on inspection. full audit notes are saved offline, ping if you want them.

reproduction notes

the FIXME comment on the offending line dates back to 2021 and the maintainers still haven't touched it. the structural blocker, free_pages_check's mapcount validation, gated by check_pages_enabled, fires any time init_on_alloc, init_on_free, page_poison, debug_pagealloc, or CONFIG_DEBUG_VM=y is in play, which is every major distro in 2026. even past that gate with init_on_alloc=0, the migration-type wall between MOVABLE anon pages and UNMOVABLE slab pages stops the naive spray; you'd need a Will-Wu-style UNMOVABLE drain via PACKET_TX_RING (CAP_NET_RAW) and a separate cross-cache landing primitive on top. an LPE on a hardened distro from this class probably also wants a FUSE-stalled munmap race (Project Zero #2329 pattern) to drop mapcount before the put_page. multi-day engineering, not an afternoon.

. _SiCk · afflicted.sh