Files
SKELETONKEY/docs/EXPLOITED.md
T
KaraZajac af01d112a5
build / build (clang / debug) (push) Waiting to run
build / build (clang / default) (push) Waiting to run
build / build (gcc / debug) (push) Waiting to run
build / build (gcc / default) (push) Waiting to run
build / sanitizers (ASan + UBSan) (push) Waiting to run
build / clang-tidy (push) Waiting to run
build / drift-check (CISA KEV + Debian tracker) (push) Waiting to run
build / static-build (push) Waiting to run
docs: netfilter_xtcompat (CVE-2021-22555) — empirical note on primitive difficulty
Attempted the most tractable kernel primitive to gauge the frontier. Kernel
confirmed vulnerable (focal GA 5.4.0-26 and a provisioned mainline 5.8.0, both
pre the 5.4.0-77/5.8.0-53 fix). But Andy Nguyen's well-regarded single-file
public exploit consistently fails at STAGE 1 ("could not corrupt any primary
message") on both — it is tuned for Ubuntu's exact 5.8.0-48-generic slab config
(freelist randomisation / memcg kmem accounting / SLUB merge), which mainline
kernels don't match, and Ubuntu's EOL 5.8.0-48 HWE debs aren't readily
sourceable. Recorded in docs/EXPLOITED.md: kernel primitives are
config-and-version-specific exploit-dev — even a reference exploit needs its
exact target kernel image + per-target tuning + a full port, a per-primitive
multi-session effort. No code change; documentation of the empirical result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
2026-07-23 23:49:28 -04:00

21 KiB
Raw Blame History

Exploit verification ledger

What this is: results of actually running each exploit against a genuinely vulnerable VM and confirming uid=0 out of band (an independent root-owned write / /etc/shadow read / setuid-bash sentinel — never the module's own self-report). This is distinct from docs/VERIFICATIONS.jsonl's historical records, which only checked that detect() returns the right verdict.

Harness: rootless qemu/KVM over frozen point-release cloud images (unpatched → vulnerable by default), driver in the session scratch dir. Root witnessed via witness.sh (shell-probe + setuid-bash finisher + module sentinels + /etc/passwd tamper check).

Headline finding: the corpus was only ever detect-verified, never exploit-verified. Running the exploits shows a mix of genuinely-working, honestly-failing, and falsely-succeeding modules. Three modules reported EXPLOIT_OK while obtaining no root at all (pwnkit, ptrace_traceme, dirty_pipe) — a false positive from the dispatcher's "execve transferred → clean child exit = OK" path (the exploit execlp's a helper that then fails). dirty_pipe additionally corrupted the running system (its unprivileged drop_caches revert left /etc/passwd poisoned). All three are fixed below and now either land real root or fail honestly.

Confirmed landing root (uid=0 witnessed out of band)

module CVE target notes
refluxfs CVE-2026-64600 Rocky 9.8 / 5.14.0-687.el9 full chain, /etc/passwd → root (earlier)
overlayfs CVE-2021-3493 Ubuntu 20.04.0 / 5.4.0-26 userns + xattr copy-up; direct uid=0 witness (cap'd payload drops a root-owned proof)
overlayfs_setuid CVE-2023-0386 Ubuntu 22.04.0 / 5.15.0-25 after a full rewrite — see below
pwnkit CVE-2021-4034 Ubuntu 20.04.0 / polkit 0.105-26ubuntu1 after a fix — see below
sudo_runas_neg1 CVE-2019-14287 Ubuntu 18.04.2 / sudo 1.8.21p2 + sudoers (ALL,!root) sudo -u#-1 → uid 0
sudoedit_editor CVE-2023-22809 Ubuntu 22.04.0 / sudo 1.9.9 + sudoers sudoedit grant after 2 fixeschdir("/") + helper basename match; su skel → uid 0
sudo_host CVE-2025-32462 Ubuntu 22.04.0 / sudo 1.9.9 + host-restricted sudoers rule works as shipped; sudo -h <host> → uid 0 (needs a host-scoped rule + resolvable host)
ptrace_traceme CVE-2019-13272 Ubuntu 18.04.0 / 4.15.0-50 + pkexec + active-session polkit after a full rewriteskeletonkey --exploit ptrace_traceme (uid 1000) → root-owned setuid bash. See below
sudo_samedit CVE-2021-3156 Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27 after a full rewrite — Baron Samedit; skeletonkey --exploit sudo_samedit (uid 1000, non-sudoer) → root-owned setuid bash. See below
dirty_pipe CVE-2022-0847 mainline 5.16.0 on Ubuntu 22.04 userspace after fixing 3 bugsskeletonkey --exploit dirty_pipe (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below
dirty_cow CVE-2016-5195 mainline 4.8.0 on Ubuntu 16.04.7 after fixing the false-OK — verbatim-module standalone (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below

Fixed this session

  • pwnkit — reported EXPLOIT_OK but did not root (glibc "Could not open converter … to PWNKIT"). Root cause: missing the GCONV_PATH=. re-injection directory + chdir(workdir). Fixed → now lands real root on a vulnerable host. (commit 24b839e)
  • ptrace_traceme (CVE-2019-13272) — first made honest (it had reported a false EXPLOIT_OK with a placeholder that had the mechanism backwards — attaching to the parent), then rewritten and now lands real root (uid=0 witnessed out-of-band on Ubuntu 18.04.0 / 4.15.0-50). The correct mechanism is the reverse of the old placeholder: a middle process execs setuid pkexec (euid 0 for a window); its child spins until it sees that euid-0, calls PTRACE_TRACEME (recording the parent's root creds as its ptracer_cred — the bug), then execs pkexec itself — the traced setuid exec is not degraded because ptracer_cred is root, so the child becomes real root, and a staged execveat() self-re-exec injects the payload. Ported the proven Jann Horn / bcoles PoC verbatim (only spawn_shell() changed, to plant a root-owned proof + setuid bash), embedded as ptrace_helper_src.h, compiled on the target at runtime with unique -DSK_PROOF/-DSK_ROOTBASH paths, run, and verified by stat()-ing the root-owned artifacts. Real-world precondition (honestly reported): pkexec must authorize an auto-discovered implicit-active=yes helper, which needs an active local session (desktop) or an equivalently permissive polkit policy; over a bare inactive ssh session pkexec returns "Not authorized" and the module reports EXPLOIT_FAIL with that diagnosis. On the headless VM this was isolated with a permissive pkla for the backlight helper action — the kernel bug and the whole technique are confirmed; the gate is polkit, not the exploit.
  • overlayfs_setuid (CVE-2023-0386) — rewritten and now lands real root (uid=0 witnessed out-of-band on Ubuntu 22.04.0 / 5.15.0-25). The shipped module used a bogus chown-the-merged-view technique that never worked. The real bug needs a FUSE lower layer exporting a setuid-root file; overlay copy-up then materialises it in the real upper as a genuine setuid-root binary. Key findings from the port (all four were required):
    1. Overlay refuses a userns-mounted FUSE lowerdir (ENOSYS) — FUSE must be mounted in the init ns via the setuid fusermount helper (libfuse does this). A raw /dev/fuse server was tried and abandoned: its INIT handshake needs poll() on the non-blocking fd, and a malformed reply destabilised the kernel — fragile and inappropriate. libfuse is linked conditionally (pkg-config fuse/fuse3), matching pack2theroot.
    2. fuse2 low-level API (fuse_mount/fuse_new/fuse_loop_mt, empty args) — fuse_main advertises splice/copy_file_range caps that make the kernel attempt copy_file_range at copy-up → ENOSYS with no fallback.
    3. read_buf callback (copy-up's splice read path).
    4. ioctl callback — copy-up issues FS_IOC_GETFLAGS on the lower; a server without an ioctl handler returns ENOSYS and copy-up fails. This was the last missing piece. Debugging was isolated by driving the exploit orchestration against the public PoC's ./fuse, then swapping servers, then comparing fops.
  • sudo_samedit (CVE-2021-3156, "Baron Samedit") — the corpus's hardest userspace target, rewritten and now lands real root (uid=0 witnessed out-of-band on Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27, as an unprivileged non-sudoer). The shipped module drove a structural trigger with no offsets and honestly reported EXPLOIT_FAIL. Ported blasty's technique: the sudoedit -s unescape overflow overwrites a glibc NSS service_user, so the lookup dlopen's an attacker-planted libnss_X/'P0P_SH3LLZ_ .so.2' from CWD; its constructor runs while sudo is still root. The module compiles the NSS payload on the target (unique -DSK_PROOF/-DSK_ROOTBASH), lays out the libnss_X/ dir, execs sudoedit with the crafted argv/env (per-libc grooming lengths: Ubuntu 56/54/63/212, Debian 64/49/60/214), and verifies root by stat()-ing the artifacts. Primary lengths landed first try; a null_stomp_len sweep (±8, the axis blasty's brute.sh perturbs) is the fallback for libc drift. Needs cc on the target.
  • dirty_pipe (CVE-2022-0847) — three bugs fixed; now lands real root (uid=0 witnessed out-of-band on a genuinely pre-fix mainline 5.16.0 kernel — provisioned by installing the kernel.ubuntu.com 5.16.0 debs on the jammy image, since every cached cloud image was either pre-5.8 or backport-patched). The shipped exploit (1) flipped the caller's UID to 0000 and ran su self, which still demands the caller's password — it never rooted anything; (2) execlp'd su, so the dispatcher's exec-transfer path reported a false EXPLOIT_OK even on the auth failure; and (3) reverted with drop_caches, which needs root — so as an unprivileged caller it left the running system's /etc/passwd page cache corrupted (this actually broke sshd's user resolution in testing). Rewrote it to the reliable technique: overwrite root's password field with a known crypt hash, authenticate as root over a pty with the matching password (su reads the password from the controlling tty, not stdin), plant a root-owned proof + setuid bash, and revert the page cache via the Dirty Pipe primitive itself (write the saved original bytes back — no root, no drop_caches). Verified /etc/passwd is byte-identical afterward. Root judged only by the out-of-band artifact.
  • dirty_cow (CVE-2016-5195) — same three bugs as dirty_pipe, fixed the same way (found by the false-EXPLOIT_OK audit below). It raced the caller's UID field to 0000 then ran su self (needs the caller's password → never rooted anything), execlp'd su so the exec-transfer path reported a false EXPLOIT_OK, and reverted with drop_caches (needs root → corrupts the running /etc/passwd). Rewrote to: race root's password field to a known $6$ hash → authenticate as root over a pty → plant a root-owned proof + setuid bash → revert by racing the original bytes back through the Dirty COW primitive. Also fixed a latent buffer overflow (the success-check readback[16] was too small for a >16-byte payload), and made the su-over-pty step poll for the prompt with a hard 20s cap — a fixed-delay write raced su's prompt setup and hung on xenial, which (without the cap) would have blocked the revert and left /etc/passwd poisoned. The same robust su helper was back-ported to dirty_pipe. Verified end-to-end on a genuinely Dirty-COW-vulnerable mainline 4.8.0 kernel (provisioned by installing the kernel.ubuntu.com 4.8.0 deb on a 16.04 image + a virtio-rng for entropy): a standalone built verbatim from the module's primitive + escalation + robust su raced root's passwd field, authenticated as root, planted a root-owned setuid bash, and left /etc/passwd byte-identical. (The full skeletonkey binary won't compile on xenial's 4.4-era uapi headers — several unrelated nft_* modules use newer kernel constants — so the verbatim standalone stands in for --exploit dirty_cow on that box.)
  • cgroup_release_agent — two real bugs fixed (commit 8c45b2b): it read getuid() after unshare(CLONE_NEWUSER) (→ 65534, so uid_map write was "0 65534 1" → EPERM), and it omitted CLONE_NEWCGROUP (→ cgroup-v1 mount EPERM). Now the userns+cgroupns+mount setup is correct. It still can't root a bare unprivileged user on a stock systemd host: every v1 controller is pre-mounted (its release_agent is init-owned → EACCES from the userns) and a fresh named hierarchy is refused. Reachable in a container context (CAP_SYS_ADMIN / an ownable cgroup) — matches its "host root from rootless container" framing. The getuid()-after-unshare bug is a pattern to grep for across the other userns modules.

False-EXPLOIT_OK audit (every module that transfers the process via exec*)

The dispatcher's run_callback_isolated forks the exploit and, if it execves (FD_CLOEXEC closes the result pipe → parent reads EOF, no crash signal), reports EXPLOIT_OK regardless of whether the exec'd program actually rooted anything. So any exploit whose main path exec's a not-guaranteed-root target lies. Audited every exec*-calling module:

module verdict why
dirty_cow false-OK → fixed + verified raced own UID + su self; execlp(su) transfer = OK. Fixed + verified end-to-end on mainline 4.8.0 (see above).
pwnkit fixed earlier now re-injects gconv + verifies
ptrace_traceme fixed earlier rewritten; verifies OOB artifact
dirty_pipe fixed earlier rewritten; verifies OOB artifact
sudo_host safe runs sudo -n -h <host> id -u witness (uid 0) before the exec
sudo_chwoot safe forks sudo in a child, then stats the setuid bash root-owned
cgroup_release_agent safe polls for the root-owned setuid shell before exec
fuse_legacy honest gates the exec on real setuid(0)==0 && getuid()==0; else EXPLOIT_FAIL
overlayfs ⚠️ proxy (low risk) confirms the security.capability xattr persisted via getxattr before exec'ing the cap'd payload — strong proxy, works, but not a direct root witness
sudoedit_editor ⚠️ works, reporting unverified plants a passwordless skel:0:0 entry + su skel (confirmed to root), but returns EXPLOIT_OK unconditionally — would false-OK if su failed
dirtydecrypt, fragnesia, copy_fail_family (exploit_su.c) proxy-verified exec the hijacked setuid target only after verifying the shellcode/payload actually landed in the page cache (verify_plant / rc==1 / WEXITSTATUS==0) and reverting otherwise — a real effect-check, not a blind exec-transfer. 2026-target-gated.
ptrace_pidfd n/a the execve is the victim being raced (fd-steal), not an escalation
mutagen_astronomy n/a env-gated scaffold; SIGSEGVs by design

Net: the exec-transfer trap produced four genuine false-OKs (pwnkit, ptrace_traceme, dirty_pipe, dirty_cow) — all now fixed and verified. The audit was then broadened to every EXPLOIT_OK return site (not just exec-transfer): the rest are backed by a genuine out-of-band check — a root-owned artifact stat (sudo_chwoot, overlayfs), a getxattr bug-signature, a /etc/passwd grep of the injected entry (sudoedit_editor, refluxfs), a real setuid(0)==0 gate (fuse_legacy), or a page-cache verify_plant before the hijack exec (copy_fail_family, dirtydecrypt, fragnesia). No further false-OKs remain. overlayfs was additionally upgraded from its getxattr proxy to a direct uid=0 witness (the cap'd payload now drops a root-owned proof, re-verified on focal 5.4.0-26).

Needs a faithful PoC port (genuinely vulnerable target, exploit doesn't land)

module CVE target tested what's wrong
overlayfs_setuid CVE-2023-0386 Ubuntu 22.04.0 / 5.15.0-25.25 Kernel confirmed vulnerable empirically — the upstream PoC (xkaneiki, libfuse) pops root here (uid=0(root), root-owned witness). The working technique: mount a FUSE fs exporting /file (st_uid=0, mode 04777) in the init ns via the setuid fusermount3 helper, then overlay-in-userns with that FUSE lowerdir + copy-up. My module's non-FUSE chown variant yields upper/file uid=1000 (no escalation); mounting FUSE inside the userns → overlay ENOSYS. Attempted a self-contained raw /dev/fuse port: got the fusermount fd-passing handshake (SCM_RIGHTS) + mount working, but the server hits EINVAL on read() after FUSE_INIT (non-blocking fd → needs poll()), and even with poll/buffer fixes the raw server serving was flaky and repeatedly wedged/rebooted the VM — i.e. the raw protocol reimplementation is fragile and can destabilise the target, which is worse for the corpus than a lib dependency. Conclusion: use libfuse (proven, robust; matches the pack2theroot conditional-lib precedent). Port is scoped and ready; needs a clean session to implement + verify.
(none left in this table — sudo_samedit was the last, now working; see "Fixed this session")

Inconclusive (detect version-blind vs vendor backport)

(none outstanding — dirty_pipe was here; now verified on a genuinely pre-fix mainline 5.16.0 kernel, see "Fixed this session".)

Kernel primitives — offset path fixed; nf_tables gap scoped (this session)

Resolver bug fixed (core/offsets.c, commit cd9bea6). The documented env-var offset override (SKELETONKEY_MODPROBE_PATH etc.) was silently wiped on every default host: parse_symfile reads /proc/kallsyms, which returns all-zero addresses under kptr_restrict, and then unconditionally zeroed modprobe_path/init_task — clobbering the values apply_env had just set. Net effect: every --full-chain primitive reported "offsets couldn't be resolved" even with correct offsets supplied. Now the all-zero path only clears fields it tagged OFFSETS_FROM_KALLSYMS itself. This was the blocker for the entire primitive full-chain path. Verified fixed on Ubuntu 22.04.0 / 5.15.0-25: --full-chain now prints modprobe_path=0x… (env), the finisher engages, and the arb-write fires.

nf_tables (CVE-2024-1086) — kernel CONFIRMED vulnerable; module gap scoped. Followed the full methodology (test → confirm kernel → pull PoC → diff):

  • Kernel is genuinely vulnerable. Built Notselwyn's public universal PoC (github.com/Notselwyn/CVE-2024-1086, musl-static) on jammy 5.15.0-25 (below the patched branch 5.15.149) and ran it: it drove the exploit and hit the deliberate post-exploitation kernel BUG at mm/slub.c:379 / Kernel panic — i.e. the cross-cache slab corruption fired. Kernel confirmed exploitable.

  • The difference. The module (its own header is honest about this) is a trigger + groom scaffold: it builds the NFT_GOTO+NFT_DROP verdict combo that nft_verdict_init() fails to reject, fires the double-free, and runs the msg_msg cg-96 groom — all real. But its arb-write is "FALLBACK-DEPTH": the exact pipapo_elem layout + value-pointer offset needed to redirect the write at modprobe_path is a documented TODO, so the write doesn't land → honest EXPLOIT_FAIL. Notselwyn's working exploit uses a different, heavier technique entirely — universal cross-cache → dirty-pagetable (arbitrary physical R/W, no per-kernel offsets), ~2000 LOC across multiple files with static libnftnl/libmnl.

  • Scope of the remaining fix. Making nf_tables --full-chain land root means either (a) completing the module's own per-kernel pipapo_elem arb-write layout, or (b) porting Notselwyn's universal technique. Both are substantial dedicated exploit-dev — this is the hardest module in the corpus, not a spot-the-bug fix. The offset resolver (above) is the piece that was actually broken and is now fixed + pushed.

  • Other 🟡 kernel primitives (nft_set_uaf, nft_payload, nft_fwd_dup, netfilter_xtcompat, af_packet, af_packet2, af_unix_gc, cls_route4, fuse_legacy, stackrot, sequoia, nft_pipapo, vsock_uaf, pintheft): same shape — real trigger/groom scaffolds returning EXPLOIT_FAIL by design. The resolver fix unblocks feeding them offsets; each still needs its arb-write primitive completed against a matching vulnerable kernel.

netfilter_xtcompat (CVE-2021-22555) — empirical note on why the primitives are hard. Attempted the corpus's most tractable primitive first: it has a clean, well-regarded single-file public exploit (Andy Nguyen / Google, the IPT_SO_SET_ REPLACE heap-OOB → msg_msg cross-cache → cred overwrite). Kernel confirmed vulnerable (Ubuntu 20.04 GA 5.4.0-26, and a provisioned mainline 5.8.0 — both pre the 5.4.0-77 / 5.8.0-53 fix). But the reference exploit consistently fails at STAGE 1 ("could not corrupt any primary message") on both, because it is tuned for Ubuntu's exact 5.8.0-48-generic config (the tested target). The slab behaviour that governs whether the OOB write lands next to a sprayed msg_msg (freelist randomisation, memcg kmem accounting, SLUB merge) differs between mainline and Ubuntu-patched kernels, and Ubuntu's EOL 5.8.0-48 HWE debs are no longer readily sourceable. Takeaway: kernel primitives are config-and-version- specific exploit-dev — even a "drop-in" reference exploit needs its exact target kernel image plus per-target slab tuning, and then a full port (~760 LOC here, ~2000 for nf_tables/Notselwyn). This is a per-primitive, multi-session effort; it is NOT the "spot the bug and fix it" tier the userspace modules were.

  • Structural userspace (sudoedit_editor, sudo_chwoot, sudo_host): need specific sudo versions + sudoers config; likely tractable.
  • 2026 CVEs (copy_fail ×5, dirtydecrypt, fragnesia, cifswitch, nft_catchall, ptrace_pidfd): need vulnerable 2026 kernels; the reconstructed race triggers (bad_epoll, ghostlock, nft_catchall) are deliberately under-driven and won't pop root by design.
  • Environment-blocked: vmwgfx (VMware guest only), dirty_cow (needs ≤4.4), mutagen_astronomy (CentOS 6 / Debian 7).
  • D-Bus/desktop (pack2theroot, udisks_libblockdev): need the polkit/D-Bus stack + a provisioner rule.

Method notes for continuation

  • Frozen images: cloud-images-archive.ubuntu.com/releases/<name>/release-<date>/ are unpatched and vulnerable-by-default for CVEs disclosed after that date — far easier than downgrading packages on current images.
  • gcc must be present in the VM (several exploits compile payloads at runtime); on EOL LTS, point apt at the archive main pocket.
  • Always verify root out of band. The module self-report is not trustworthy (two flagships lied). witness.sh is the reference check.