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

every kernel mitigation on Resolute, what it does, and what we did about it

2026-05-07 · linuxkernelmitigation-bypasskaslrrandom-kmallocapparmorusernsexploit-devresolute

Ubuntu 26.04 LTS Resolute, kernel 7.0.0-15-generic, ships the deepest stack of kernel-side exploit mitigations of any current desktop distro. most public exploit research still aims at older kernels, defconfig-debug builds, or assumes mitigations Resolute has actually turned off. before i chase any specific bug into LPE i wanted to map every mitigation first, pin down the structural class each one lands in, and write the generic bypass for that class. a bypass is reusable across bugs. per-bug exploit-dev should never re-derive one.

three structural classes

every mitigation drops into one of three classes, and that class determines the bypass, not the specific mitigation. once you have the framework, "how do i beat FOO" becomes "what class is FOO."

Class A. informational

the mitigation hides a secret. recover the secret and the mitigation collapses to a static lookup. KASLR hides the kernel base offset. RANDOM_KMALLOC_CACHES hides the per-boot 64-bit seed. kptr_restrict hides symbol addresses. SLAB_FREELIST_RANDOM hides the per-page slot permutation.

bypass: leak the secret. three sub-paths. privileged read once plus static computation, side-channel observation (cache timing / branch prediction / prefetch), or chain another bug as an info-leak primitive.

Class B. structural

the mitigation kills a specific bug-class behavior. INIT_ON_ALLOC kills UAF-read-of-stale-data. HARDENED_USERCOPY bounds-checks copy_to/from_user. FORTIFY_SOURCE bounds-checks compile-time-sized memcpy. PAGE_POISONING poisons freed pages. MOVABLE/UNMOVABLE pageblocks partition buddy. VMAP_STACK isolates kernel stacks.

bypass: reach for a bug class the mitigation doesn't catch. OOB-write or UAF-write instead of UAF-read. runtime-sized memcpy instead of compile-time-sized. in-kernel memcpy/memmove instead of usercopy. force pageblock fallback under controlled pressure.

Class C. gating

the mitigation wants a permission/capability/sysctl that's off for unpriv. kernel.unprivileged_bpf_disabled=2, vm.unprivileged_userfaultfd=0, kernel.apparmor_restrict_unprivileged_userns=1, kernel.dmesg_restrict=1, kernel.perf_event_paranoid=4.

bypass: alternate primitive or alternate capability path. eBPF → io_uring (similar primitives, different gate). uffd → FUSE / io_uring submission stall / MAP_POPULATE on swap. userns → aa-rootns trick (apparmor profile-hop + ambient cap launder). dmesg_restrict → out-of-band signal channels.

Class A: leaking what's hidden

KASLR (CONFIG_RANDOMIZE_BASE)

kernel base load address randomized at boot. kptr_restrict=1 blocks unprivileged /proc/kallsyms reads, dmesg_restrict=1 blocks dmesg, perf_event_paranoid=4 blocks the trace-side leaks. the obvious paths are gone.

EntryBleed works straight out of the box. Resolute enables no prefetch-side-channel hardening for KASLR, no mitigations=...kaslr-prefetch=on or equivalent in the boot-args. the classical timing trick: prefetch each candidate kernel address, time the cycles. from the verified run on resolute-desk:

$ /tmp/entrybleed
[bleed] minimum at step 438: avg latency 65 cycles
[bleed] candidate runtime address: 0xffffffffb7c00000
[bleed] KASLR slide: 0x36c00000

65 cycles for the hit, 148-149 for the misses, 2.3× ratio. no statistics needed. three seconds, 200 trials × 512 steps. i checked it against /proc/kallsyms under sudo: entry_SYSCALL_64 sits at ffffffffb7c00080, and the prefetch-timing leak nailed it.

with the slide known, every static kernel symbol's runtime address is System.map_address + slide. kptr_restrict stops mattering. the symbol table ships in the binary distribution, not in /proc/kallsyms.

entrybleed.c.

RANDOM_KMALLOC_CACHES

plain kmalloc(size, GFP_KERNEL) hashes the return-IP and routes to one of 16 buckets (kmalloc-rnd-00-N through kmalloc-rnd-15-N). distinct call sites land in different buckets unless the hash collides. so any "spray gadget X to fill the cache, then trigger bug Y in the same cache" recipe needs gadget X and bug Y in one bucket, and they won't be unless you control the hash.

the hash is just hash_64(caller ^ random_kmalloc_seed, 4). one per-boot 64-bit value. recover the seed and the whole mechanism drops to a static lookup.

the seed lives in kernel .bss at a fixed offset from the kernel base. with the slide from EntryBleed and the offset from System.map, the seed's runtime address is computable. read it once through any kernel-pointer-leak primitive (or in the lab, through a debugfs LKM that just reads random_kmalloc_seed directly):

seed_leak.c: debugfs LKM, root-only read.

with the seed in hand, precompute the bucket assignment for every kmalloc call site you care about. i disassembled vmlinux, enumerated 3830 unique kmalloc call sites, hashed each through hash_64(caller ^ seed, 4), and built a per-bucket sister-site catalog. for any target bug's call site the catalog tells me which other allocations land in the same bucket and are therefore good as spray gadgets.

to check it i predicted epoll_create's kmalloc call site hashes to bucket 4, then spawned 5000 epoll fds. /sys/kernel/slab/kmalloc-rnd-04-256/objects went up by 5000. zero scatter into other buckets.

verify_bucket_spray2.c: the verification harness.

no info-leak primitive on hand? the seed isn't actually required to exploit. there are 16 buckets. for each target bug, enumerate all 16 spray strategies, one per bucket guess. each iteration is a full bug trigger plus verification, usually seconds, so all 16 run in under a minute. brute-16 is a pure exploit-time technique with zero kernel-side prerequisites.

kptr_restrict, perf_event_paranoid, dmesg_restrict

all three are secondary to KASLR. with the slide from EntryBleed they don't matter.

SLAB_FREELIST_RANDOM

within a slab the per-page freelist is randomly permuted at slab init. allocations don't hand back the highest-numbered free slot first. so predicting "next allocation lands at offset X within the slab" doesn't work unless you've watched the permutation.

heavy spray beats it. fill the whole slab. once a slab is fully allocated the free state is determinate (empty), and the next slab page's permutation is fresh but observable through the order slots get populated, if you can watch the allocations. for cross-cache and same-page-OOB exploits, ~80% slab fill turns this into a complexity tax rather than a security barrier.

Class B: what doesn't apply

the catalog. every mitigation here gets sold as defeating "memory corruption", and none of them touch write-class slab bugs.

INIT_ON_ALLOC

every kmalloc result zeroed before return. a UAF-read of stale slab data comes back zeros, not the previous occupant.

doesn't affect: UAF-write (you're writing, not reading), OOB-write (you're writing past the end), UAF-read after re-alloc and re-write (attacker spray has already re-occupied the slot, so the new content is what you read). the classic dirty-cred and pipe-primitive chains are write-side, so INIT_ON_ALLOC is irrelevant.

for the rare read-side primitive: spray, free, re-spray with attacker content, read. the second spray writes the controlled content, and the zeroing is gone.

INIT_ON_FREE (off on Resolute)

Resolute turns this off. if it were on, freed slab data would get zeroed at free-time too, killing UAF-read-of-just-freed. it's off here so it's not my problem. worth a note for distros that enable it.

HARDENED_USERCOPY

bounds-checks copy_to_user/copy_from_user against the slab's stated metadata. kernel-to-user reads or user-to-kernel writes past the slab object's size get rejected.

doesn't affect: in-kernel memcpy/memmove (no slab-bound check), OOB writes from kernel-internal sources (a filesystem parser writing past its buffer doesn't go through usercopy), UAF (the access is by-pointer, not by-buffer-bound).

most of the target bugs i care about are kernel-internal: NTFS3 journal-replay memmove (CVE-2026-31716), FUSE readdir memcpy (CVE-2026-31694), JFS dtree namelen mismatch. none of them touch usercopy.

FORTIFY_SOURCE

compile-time bounds checks on memcpy with constant-size arguments. memcpy(dst, src, 8) where sizeof(dst) is statically 4 → compile error or runtime trap.

doesn't affect: runtime-sized memcpy. memcpy(dst, src, n) where n comes from user input slips past FORTIFY entirely, and almost every kernel OOB-write bug has a runtime-sized length.

PAGE_POISONING

freed pages get poisoned with a sentinel pattern. a UAF-read of a freed page returns the poison.

doesn't affect: UAF-write, cross-cache reuse (the freed page comes back as a fresh slab page and the new occupant overwrites poison with fresh data). under cross-cache exploitation the next allocator overwrites the poison immediately.

MOVABLE / UNMOVABLE pageblocks

buddy partitions free pages into pageblocks (default 4 MB on x86_64), each tagged with a migrate type:

page-allocator-level OOB writes need the OOB-victim page physically adjacent to a target page. by default the two live in different pageblocks, so no adjacency.

the fallback chain in mm/page_alloc.c openly allows MOVABLE → RECLAIMABLE → UNMOVABLE pageblock allocation once the MOVABLE freelist is empty. so drain MOVABLE first.

drain-and-groom recipe
/* 1. allocate K user VMAs at PMD-distinct virtual addresses
 *    -> forces K PTE-table allocations into UNMOVABLE pageblocks */
for (i = 0; i < K; i++)
    mmap((void*)(BASE + i*PMD_SIZE), PAGE_SIZE,
         PROT_READ|PROT_WRITE,
         MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);

/* 2. drain MOVABLE order-0 freelist + per-CPU pcp lists */
for (i = 0; i < DRAIN_PAGES; i++) {
    void *p = mmap(NULL, PAGE_SIZE,
                   PROT_READ|PROT_WRITE,
                   MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0);
    *((volatile char*)p) = 1;  /* fault in */
}

/* 3. trigger bug allocation (e.g. FUSE cache page, MOVABLE by default).
 *    with MOVABLE freelist empty, kernel falls back to UNMOVABLE pageblock.
 *    bug page is now physically adjacent to PTE tables / slab pages. */

1500-3000 anon pages is usually enough to flip a MOVABLE allocation over to UNMOVABLE.

drain_and_groom.c.

VMAP_STACK, SLAB_BUCKETS, KFENCE, STACKPROTECTOR, CFI_CLANG

VMAP_STACK isolates kernel stacks, which does nothing to a heap-class bug. SLAB_BUCKETS is per-process slab partitioning, irrelevant when the bug fires in our own process. KFENCE is sampling-based KASAN-lite at ~0.1% sample rate, a rate you can live with for a single-shot exploit. STACKPROTECTOR catches linear stack overflows and leaves the heap alone. CFI_CLANG isn't enabled on Resolute. if it were, an indirect-call hijack through overwritten function pointers would be type-checked and i'd need type-equivalent gadgets or a data-only attack. on Resolute it doesn't come up.

Class C: alternate paths around the gates

vm.unprivileged_userfaultfd=0

unprivileged userfaultfd is blocked. you can still call userfaultfd() with UFFD_USER_MODE_ONLY, but kernel-mode page faults are NOT delivered to the uffd handler. they fail with -EFAULT instead. that kills the classical "uffd-stall the race" technique.

FUSE-as-stall is the cleanest substitute. FUSE is always there on Linux, /dev/fuse is world-accessible, fusermount3 allows unprivileged mounts, and there's no unprivileged_* sysctl gating it.

the pattern: mount a FUSE filesystem unprivileged through fusermount3, create a file in it whose content you control from the server side, mmap the file at address V, then trigger the bug so the kernel reads V mid-race. on the wire the kernel sends FUSE_READ to the userspace handler, the handler does the attacker work (free, spray, side-action), the handler responds, and the kernel resumes. same kernel-stall semantics as uffd.

it's already in production in the CVE-2026-31694 and CVE-2025-39888 PoCs, and the pattern carries over to any race-window bug.

other alternates: io_uring SQ saturation (kernel blocks waiting for completions), MAP_POPULATE on swap (major-fault stall), MAP_POPULATE on page-cache-miss (disk-I/O stall). FUSE is the cleanest of them.

kernel.apparmor_restrict_unprivileged_userns=1

Resolute's apparmor strips capabilities inside unprivileged user namespaces. unshare(CLONE_NEWUSER) still succeeds, but the resulting userns has CAP_SYS_ADMIN denied by an apparmor profile, so the classic userns-as-cap-bypass falls over.

the answer is a two-hop AppArmor profile transition: aa-exec -p crun -- aa-exec -p chrome -- /your_program. the crun profile is loose enough to transition into chrome, and the chrome profile is loose enough to allow CAP_SYS_ADMIN inside its own namespace stack. chain the two and Resolute's userns-restrict is done.

deeper writeup at bypass-pwn and aa-rootns.

kernel.unprivileged_bpf_disabled=2

the bpf() syscall is blocked for unpriv, so eBPF-based spray gadgets (BPF map allocations) and BPF-based info-leak primitives are out.

io_uring fills the gap. it gives you comparable kernel-internal allocation primitives: SQE/CQE rings (kernel-allocated), IORING_REGISTER_BUFFERS (pinned page arrays), IORING_OP_PROVIDE_BUFFERS (slab allocations of attacker-sized buffers). anywhere the literature reaches for BPF maps to spray, io_uring's allocator does the same job. Resolute keeps io_uring on by default.

kernel.dmesg_restrict=1, perf_event_paranoid=4, mmap_min_addr=64K

dmesg blocked, perf counters blocked, page-zero mapping blocked. so dmesg-based pointer leaks are gone, perf side-channels are gone, and a NULL-deref-to-userspace exploit can't map page 0.

none of it touches me. EntryBleed handles KASLR without dmesg or perf, and i don't NULL-deref.

putting it together

each bypass is a generic tool. per-bug exploit-dev composes them; it doesn't re-derive them.

the end-to-end demo chains all the Class-A bypasses on stock Resolute, fully unprivileged:

[stage 1] EntryBleed - leaking KASLR slide...
[stage 1] KASLR slide   = 0x36c00000
[stage 1] Kernel base   = 0xffffffffb7c00000

[stage 2] Compute target's runtime RET_IP...
[stage 2] fuse_retrieve kmalloc RET_IP (runtime) = 0xffffffffb86b214b

[stage 3] Look up bucket via (cached) seed...
[stage 3] Bucket: kmalloc-rnd-04-512

[stage 4] Identify spray gadgets in bucket 04...
[stage 4] Same-bucket sister-sites:
            - epoll_create (verified spray)
            - sock_alloc_inode (kernel-internal)
            - filp_alloc (kernel-internal)
            - 12 others

KASLR slide, target bucket, a verified same-bucket spray gadget. the rest is bug-specific weaponization.

end_to_end_demo.c.

for auto-detect, kbypass-enum.sh reads the running kernel's mitigation state and prints a bypass plan.

$ ./kbypass-enum.sh
Class A: KASLR=on  kptr_restrict=1  perf_event_paranoid=4  RANDOM_KMALLOC=on
   --> EntryBleed + seed-leak / brute-16
Class B: HARDENED_USERCOPY=on  FORTIFY=on  INIT_ON_ALLOC=on  KFENCE=on
         VMAP_STACK=on  STACKPROTECTOR=on  SLAB_FREELIST_RANDOM=on
         SLAB_BUCKETS=on  PAGE_POISONING=on
         (PAGE_TABLE_CHECK=off, CFI=off, INIT_ON_FREE=off)
   --> use write-class primitives; INIT_ON_ALLOC catalog applies
Class C: unpriv_bpf=2  unpriv_uffd=0  apparmor_userns=1  dmesg_restrict=1
         yama=1  mmap_min_addr=64K  io_uring=enabled
   --> aa-rootns for userns; FUSE-as-stall for uffd; io_uring for bpf
CPU: all spectre/meltdown variants mitigated

kbypass-enum.sh.

what's still hard

three things in this corpus aren't bypassed:

CFI_CLANG. Resolute doesn't enable it, but a port to a CFI-enabled kernel needs type-equivalent gadgets or data-only attacks. the generic bypass is per-target hard work. i haven't built one.

PAGE_TABLE_CHECK. detects PTE corruption at swap-in/swap-out time. Resolute disables it. enable it and classic Dirty Pagetable breaks. i have a partial alternative (cross-cache via shmem instead of PTE adjacency) but haven't fully validated it on a PAGE_TABLE_CHECK-enabled kernel.

the bug itself. mitigations don't exist to stop every exploit, they exist to raise the cost. with EntryBleed, RANDOM_KMALLOC bypassed, FUSE-as-stall, aa-rootns, and the cross-cache crossover, the cost-multiplier lands near 1. what's left is bug-specific exploit-dev, and that's still real work.

code drop

categoryfilestate
KASLR leakentrybleed.cverified unpriv on Resolute
RANDOM_KMALLOC seed leakseed_leak.cdebugfs LKM, root-only read
RANDOM_KMALLOC bucket-graphverify_bucket_spray2.cverified epoll → bucket 04
aa-rootnswriteupverified, separate post
pageblock fallbackdrain_and_groom.cdrain verified, adjacency lab-tested
FUSE-as-stallused in cryptodev-poc-doesnt-land and the 31694/39888 PoCstheory + production use
cross-cache crossoverwriteup100% rate, separate post
framework chainend_to_end_demo.cunpriv KASLR + bucket lookup
mitigation enumeratorkbypass-enum.shoutputs bypass plan

hardened distros aren't impenetrable. they're cost-multipliers, and the multiplier per mitigation is flat. once the generic bypass is built every later exploit pays the same fixed cost, zero, because the bypass is reused. the cost lives in the bypass-development phase, not in per-bug exploitation.

five generic bypasses (EntryBleed, RANDOM_KMALLOC seed leak / brute-16, aa-rootns, FUSE-as-stall, cross-cache crossover) flatten the multiplier on every Class-A and Class-C item on Resolute. the Class-B mitigations are almost all catalog-only, they don't touch write-class slab bugs. verified and moved on.

the hard part of any specific exploit is still the bug.

. _SiCk · afflicted.sh