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

jfs_probe: a diagnostic kernel module for slab-adjacency exploit dev

2026-05-03 · linuxkernelmoduleexploit-devslabkprobe

companion to the JFS jfs_readdir OOB-write post. while weaponizing that primitive against current Resolute the thing that kept stalling me wasn't the crafter or the trigger - it was not knowing what slab class lives in the page next to the OOB write target. KASAN will tell you "slot was X bytes from allocated region of size Y in cache Z", but only after the fact, after you've already crafted bad bytes and crashed something. for spray work you want to see the page layout before you fire the bug.

so i built jfs_probe.ko. ~650 lines of GPL'd kernel module plus a ~265-line userspace ctl tool. root-only, CAP_SYS_ADMIN-gated on every ioctl. it exposes kernel internals userland normally can't see. i'm publishing it because the next person doing slab-OOB work on a current kernel will need exactly this, and rebuilding it from scratch is a couple of evenings nobody should have to spend.

what it does

ioctlfunction
KVA_PFNkernel virtual address → pfn (linear / vmalloc / direct-map handled)
PAGE_INFOfor a pfn: order, migratetype, slab cache name, slab page state, refcount, mapping flags
ADJACENCYsame metadata for pfn-1 and pfn+1. the two pages physically adjacent to a chosen one in the buddy partition
MY_CREDkva and pfn of the calling task's struct cred. canonical "where is my cred?" lookup
DUMP_CACHEdump a kmem_cache's layout: object size, slab page order, object count per slab, alignment, flags. useful for figuring out which cache your spray is actually hitting under SLAB_MERGE_DEFAULT / RANDOM_KMALLOC_CACHES
WALK_FD_MAPwalk the current process's fd_array reporting kva/pfn of each struct file
WALK_IPIMAPwalk JFS imap inodes (specific to the JFS work; less generally useful)
VICTIMS_ALLOCkernel-side spray helper: allocate N objects of size S in a chosen cache, optionally pinning them in memory
VICTIMS_CHECKread back the sprayed objects' first N bytes; useful to detect whether OOB-write content landed in any of them
VICTIMS_FREEfree a chosen subset of sprayed objects to create holes
VICTIMS_PUNCHfree a single sprayed object (used to manufacture a free slot at a known kva for the OOB to target)
READ_BYTESread N bytes of kernel memory at a chosen kva. the obvious diagnostic primitive

all of these are trivial-to-write rootkit primitives. they don't cross any new privilege boundary - they're a strict subset of what insmod already hands any user with init_root. the value is having them packaged behind a clean ioctl interface so you can diff state between attempts while you're weaponizing, instead of guessing from dmesg alone.

the kallsyms_lookup_name trick

since 5.7, kallsyms_lookup_name is no longer exported to modules. deliberate hardening to make rootkit dev a little less convenient. jfs_probe resolves it at module init with a kprobe:

static int resolve_unexported_symbols(void)
{
    struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" };
    kallsyms_lookup_name_t kln;
    int r = register_kprobe(&kp);
    if (r < 0) return r;
    kln = (kallsyms_lookup_name_t)kp.addr;
    unregister_kprobe(&kp);
    if (!kln) return -ENOENT;
    resolved_get_pfnblock_migratetype =
        (get_pfnblock_migratetype_t)kln("get_pfnblock_migratetype");
    return 0;
}

register_kprobe with a symbol name resolves the address internally and stores it in kp.addr. unregister, cast, call. it's the standard out-of-tree workaround, documented in dozens of kernel-research posts. the point is that EXPORT_SYMBOL_GPL removal was never a security boundary against in-tree kprobes from a CAP_SYS_ADMIN-loaded module, and was never meant to be one.

once kallsyms_lookup_name is in hand, anything else unexported is one more lookup away (here that's get_pfnblock_migratetype, for the buddy migrate-type adjacency reports).

how we actually used it during the JFS work

before each weaponization attempt:

  1. jfs_probe_ctl mycred. record the test process's cred kva/pfn.
  2. jfs_probe_ctl pageinfo <cred-pfn>. confirm the cred lives in :A-0000192 (the merged kmalloc-cg-192 cache after SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT merge).
  3. set up the JFS image and stage the mount.
  4. jfs_probe_ctl dumpcache jfs_dirent. sanity-check the dirent buffer cache.
  5. spray a chosen victim cache via VICTIMS_ALLOC; record the kvas.
  6. punch a hole at a strategically-chosen kva via VICTIMS_PUNCH. this is the slot we want the freshly-allocated __get_free_page(GFP_KERNEL) dirent buffer to land adjacent to.
  7. fire the trigger (ls /mnt/poc).
  8. jfs_probe_ctl adj <dirent-pfn>. what page actually landed next to the dirent buffer? KASAN tells you after the fact; this tells you whether the grooming worked before you risk the crash.
  9. jfs_probe_ctl victims_check. if the OOB landed in a sprayed victim, the bytes will show up here.

prep, observe, fire, observe, diff. without the module you're guessing which slab class is adjacent; with it you know going in. didn't stop me from failing to finish the JFS LPE chain, but at least i knew exactly what was adjacent when i failed.

not a weapon

jfs_probe needs init_user_ns CAP_SYS_ADMIN just to insmod in the first place. kernel.modules_disabled=1 blocks it, an unprivileged user can't load it, and an aa-rootns userns root can't load it either because module loading is gated against init_user_ns. once it's loaded every ioctl re-checks capable(CAP_SYS_ADMIN) and returns -EPERM otherwise. none of it is reachable from unprivileged userspace. an attacker who can insmod already owns the kernel and doesn't need this module.

the use case is the lab kernel and nothing else. you have root on a VM, you have the module source, you build it against a kernel you control, you load it, and you watch what your would-be-unprivileged primitive is doing under the hood. nothing about this module makes any kernel weaker for real users.

the alternative is a kernel-debugger setup, gdb against qemu-kvm with -s -S and the kernel vmlinux. more powerful, slower to iterate on. jfs_probe is the cheap version i actually used.

what's missing

build and use

# on a kernel matching the running uname -r:
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

# load
sudo insmod jfs_probe.ko

# build the userland tool
gcc -O2 -o jfs_probe_ctl jfs_probe_ctl.c

# inspect your own cred
sudo ./jfs_probe_ctl mycred

# adjacency for a chosen pfn
sudo ./jfs_probe_ctl adj 0x1234567

# dump a cache layout
sudo ./jfs_probe_ctl dumpcache cred_jar

# unload
sudo rmmod jfs_probe

it refuses to load on a kernel different from the one it was built against, so rebuild against the target kernel's headers each time.

downloads

SHA-256:

b68a1d0f0920ee7d256737f1139cc72c42b26bf7d34b2483de5bed0a7d218d13  jfs_probe.c
5d6ead1d243b3483af408b9e4fa9d779ffcbebcc3decda3ad5aa9b2fb6605c8d  jfs_probe_ctl.c
0ae9a9137c4db11f4bc36ca6c4eb700c0fb54d6138eccf659e9b451807567b73  Makefile

credit

_SiCk <sick@afflicted.sh> (https://afflicted.sh)

no warranty. no support. no apologies.

. _SiCk · afflicted.sh