cryptodev-linux: the January 2026 PoC doesn't actually exploit any real distro
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:
- Prime. call
CIOCCRYPTwith valid src/dst.get_userbuf()fillsses->pages[0..N]with fresh GUP refs on the user buffers (the anon pages behindplainandcipher). the op succeeds andrelease_user_pagesdrops those refs at the end.ses->pages[0..N]still holds the samestruct page *values afterward, but the kernel-side refcount on each is back to 1 - the user mapping. - Trigger. call
CIOCCRYPTwithsrc=NULL, dst=0xdeadbeef.get_userbufsetsused_pages = 256(the dst pagecount), then__get_userbuf(dst)fails on the bad address. theFIXMEbranch firesrelease_user_pages(ses), which walksses->pages[0..256]. those entries are the still-mapped src pages from the prime. eachput_pagedrops their refcount from 1 to 0. - 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 freshkmalloc-256slab pages off the buddy allocator; if astruct filelands in one of our freed pages, the user-readableplain[]mapping now reflects the kernel'sstruct filecontents , including thef_oppointer at&ext4_file_operations. from there you mode-flip thef_modefield through the same dangling mapping, write annasm::0:0:root:/root:/bin/bashline into the now-writable/etc/passwd, andsuover.
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:
- v2: msg_msg cross-cache. 200 000 messages of 224 B across 3334
msg queues, into the dedicated
kmalloc-cg-256bucket viaCONFIG_SLAB_BUCKETS. msg_msg lives in a non-randomized bucket, so the landing is deterministic by design. Result: 0 cookies, 0 kernel-pointer hits inplain[]/cipher[]. the pages still carry their pre-trigger0xAAcontents. - v3: PACKET_TX_RING UNMOVABLE drain (Will Wu / Will's Root recipe).
wrapped in
aa-rootnsto grabCAP_NET_RAWinside 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 holds0xAA. nothing reclaims the page. - v4: memfd-backed buffers. moved
plainandcipheroffMAP_ANONYMOUSontomemfd_create+MAP_SHARED, on the idea that pagecache mapcount semantics behave differently. Result: VM hung mid-exploit, reverted withvirsh 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:
CONFIG_DEBUG_VM=y(compile-time)init_on_alloc=1on the kernel cmdline (orCONFIG_INIT_ON_ALLOC_DEFAULT_ON=ywith no override)init_on_free=1- Active page poisoning (
CONFIG_PAGE_POISONING=y+page_poison=1) - Active
debug_pagealloc
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:
CONFIG_INIT_ON_ALLOC_DEFAULT_ONis off →check_pages_enabledstatic branch is off →free_page_is_badnever gets called → the mapcount mismatch is silently accepted → pages reach buddy.CONFIG_RANDOM_KMALLOC_CACHESis on (it landed in 6.6 and is defconfig-y), but with noinit_on_allocthe rest of the spray chain is easy.- no
CONFIG_HARDENED_USERCOPY, no SLAB_FREELIST_RANDOM, no SLAB_FREELIST_HARDENED, no PAGE_POISONING. none of the distro hardenings that pin down heap behavior further.
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:
- custom defconfig kernels (CTFs, dev VMs, syzkaller).
- embedded devices with stripped configs that omit
INIT_ON_ALLOC. - boot-arg overrides like the one i tried, and even there the migration-type wall stops the spray.
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:
- a
TAINT_BAD_PAGEprimitive: every trigger firesbad_page(), sets the taint bit, and quarantines a user page in the kernel's leak limbo. - a small per-trigger memory leak: each fire pulls ~1 MB of physical
memory out of circulation. repeatable from unprivileged context for as long
as the
cryptodevmodule is loaded and/dev/crypto's ACL lets you open it. - a kernel-log noise primitive: spammable from unprivileged context to churn out dmesg entries an admin will notice.
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.
- cryptodev-linux at upstream HEAD as of 2026-05-06; commit hash recorded
in saved kbug bugs.db (verdict
cve, hash8bd259a5fab1). - tested on Ubuntu 26.04 LTS Resolute (kernel 7.0.0-15-generic) and Ubuntu 22.04 LTS Jammy (kernel 5.15.0-176-generic), both running as guests on libvirt/KVM with bridged networking.
- the PoC binary, the jammy and resolute test images, and the full kbug bugs.db with all triage data stay on the lab rig, not posted publicly.
- the
aa-rootnstool used in v3's UNMOVABLE drain is the same single-binary userns bypass from the prior aa-rootns post.
. _SiCk · afflicted.sh