Files
SKELETONKEY/docs/EXPLOITED.md
T
KaraZajac 73c7d09445
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
overlayfs: direct uid=0 witness; broaden false-OK audit (all OK sites clean)
overlayfs previously claimed EXPLOIT_OK from a getxattr proxy (the persisted
security.capability xattr is the bug's signature) then exec-transferred the
cap'd payload. Tightened to a DIRECT witness: the payload now takes a proof-path
arg and, running as root (setuid(0) works because of the persisted cap), drops a
root-owned proof + setuid bash; the module forks it, then reports OK only after
stat() confirms the proof is root-owned. Re-verified on focal 5.4.0-26 (uid 1000
-> root-owned proof + -rwsr-xr-x root:root bash).

Broadened the false-EXPLOIT_OK audit from exec-transfer callers to EVERY
EXPLOIT_OK return site. The copy_fail_family root-pop (exploit_su.c) runs
verify_plant() — confirming the shellcode actually landed in the setuid binary's
page cache, reverting otherwise — before exec'ing it, so it's proxy-verified like
dirtydecrypt/fragnesia, not a blind exec-transfer. Conclusion recorded in
docs/EXPLOITED.md: no false-OKs remain beyond the four already fixed; every OK is
backed by a real out-of-band check (root-owned artifact stat, getxattr, passwd
grep, setuid(0) gate, or page-cache verify_plant).

Host build + unit harness green (33 + 115).

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

262 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 fixes**`chdir("/")` + 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 rewrite**`skeletonkey --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 bugs**`skeletonkey --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 `execve`s
(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 `stat`s 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.
- **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.