Freecoder Dev 1 тиждень тому
батько
коміт
89c9ae78b4
4 змінених файлів з 605 додано та 1 видалено
  1. 3 1
      .gitignore
  2. 28 0
      Makefile
  3. 60 0
      README.md
  4. 514 0
      forcezero.c

+ 3 - 1
.gitignore

@@ -1 +1,3 @@
-*.so
+*.so
+/forcezero
+test.sh

+ 28 - 0
Makefile

@@ -0,0 +1,28 @@
+# forcezero — runtime exit-code injector
+#
+#   make            build forcezero and exit0.so
+#   make check      run the end-to-end tests (needs ptrace permission)
+#   make install    install forcezero into $(PREFIX)/bin
+
+CC      ?= cc
+CFLAGS  ?= -O2 -Wall -Wextra
+PREFIX  ?= /usr/local
+
+all: forcezero exit0.so
+
+forcezero: forcezero.c
+	$(CC) $(CFLAGS) -o $@ $<
+
+exit0.so: exit0.c
+	$(CC) $(CFLAGS) -shared -fPIC -o $@ $<
+
+check: forcezero
+	sh test.sh
+
+install: forcezero
+	install -D -m 0755 forcezero $(DESTDIR)$(PREFIX)/bin/forcezero
+
+clean:
+	rm -f forcezero exit0.so
+
+.PHONY: all check clean install

+ 60 - 0
README.md

@@ -0,0 +1,60 @@
+# forcezero
+
+Make a **running** process terminate with a chosen exit status (default `0`) —
+the runtime counterpart of the `exit0.so` preload helper in this repo:
+
+    sudo ./forcezero <pid> [exit-code]
+
+The injectable payload is embedded in the `forcezero` binary itself; nothing is
+written to disk when injecting, and the target needs no libc, no symbols and no
+writable code page.  Static, stripped, 32-bit (on x86-64 hosts) and
+multithreaded processes all work.
+
+## How it works
+
+1. `PTRACE_SEIZE` + `PTRACE_INTERRUPT` stop the target without delivering any
+   signal.
+2. The target's entry point (`_start`, dead code once the process is running)
+   is located via `/proc/<pid>/maps` and the ELF header read with
+   `PTRACE_PEEKDATA`.
+3. The first 12 bytes there are replaced with
+
+       mov edi, rc      ; mov eax, 231 ; syscall       (64-bit targets)
+       mov ebx, rc      ; mov eax, 252 ; int 0x80      (32-bit targets)
+
+4. `RIP` is pointed at the payload and the tracer detaches.  The kernel's
+   pending-syscall bookkeeping is neutralised (`rax = 0`) so the target resumes
+   exactly at the payload and every thread exits with the requested status,
+   which propagates to whoever waits on the target (shell, CI runner, ...).
+
+If injection fails after the memory was already patched, the original bytes and
+registers are restored and the target is left running.
+
+## Build and test
+
+    make            # build forcezero (and exit0.so)
+    make check      # end-to-end tests; needs ptrace permission
+    make install    # install forcezero into /usr/local/bin
+
+The test suite covers dynamic and static binaries, non-PIE and PIE layouts,
+multithreaded targets, a SIGSTOP-suspended target, and CLI error paths.
+
+## Permissions
+
+Tracing a process requires the same uid (with a kernel that permits it — on
+systems with Yama, `kernel.yama.ptrace_scope=0` for same-uid tracing) or root /
+`CAP_SYS_PTRACE`.  In practice:
+
+    sudo forcezero 12345        # process 12345 now exits with status 0
+    sudo forcezero 12345 3      # ... with status 3
+
+## Notes and limitations
+
+- x86-64 hosts; 32-bit compat targets are supported, so is anything with a
+  normal ELF main executable.  Shell scripts work too (the interpreter gets
+  the injection).
+- The status range is 0..255 (a kernel exit-status limit).
+- A target suspended with `SIGSTOP` stays suspended and exits with the
+  requested status once continued.
+- A process parked inside `execve()` would discard the injection; forcezero
+  detects this and asks you to retry instead of silently doing nothing.

+ 514 - 0
forcezero.c

@@ -0,0 +1,514 @@
+/*
+ * forcezero — make a running process terminate with a chosen exit status.
+ *
+ * Usage:  forcezero <pid> [exit-code]      (exit-code defaults to 0, range 0..255)
+ *         forcezero -h | --help
+ *
+ * How it works (x86-64 Linux, PTRACE_SEIZE based):
+ *
+ *   1. Seize the target and interrupt it (no SIGSTOP is ever delivered).
+ *   2. Read the target's ELF header through /proc/<pid>/maps + ptrace and
+ *      resolve the runtime address of its entry point (_start, which is dead
+ *      code once the process has started).
+ *   3. Overwrite the first 12 bytes there with an exit_group() payload that is
+ *      embedded in this binary — nothing is ever written to disk:
+ *
+ *          mov edi, rc ; mov eax, 231 ; syscall        (64-bit target)
+ *          mov ebx, rc ; mov eax, 252 ; int 0x80       (32-bit compat target)
+ *
+ *   4. Point RIP at the payload and detach.  Every thread of the target exits
+ *      with the requested status, which propagates to whoever waits on it
+ *      (shell, CI runner, build system, ...).
+ *
+ * The payload needs no libc, no symbols and no writable page inside the target,
+ * so static, stripped and setuid binaries work alike (given the privileges to
+ * trace them).  Targets that are job-control stopped stay stopped; they exit
+ * with the status once continued.
+ *
+ * Permissions: tracing needs the same uid (with yama ptrace_scope 0) or
+ * root / CAP_SYS_PTRACE.  Typical invocation: `sudo forcezero <pid>`.
+ *
+ * Build:  cc -O2 -Wall -Wextra -o forcezero forcezero.c
+ */
+
+#if !defined(__x86_64__)
+#error "forcezero supports x86-64 hosts only (x86-64 and i386 targets)"
+#endif
+
+#define _GNU_SOURCE
+#include <elf.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ptrace.h>
+#include <sys/stat.h>
+#include <sys/sysmacros.h>
+#include <sys/types.h>
+#include <sys/user.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#define MAP_MAX     4096
+#define PAYLOAD_LEN 12
+#define PATCH_WORDS 3
+
+struct mapping {
+    uint64_t start, end, file_off;
+    unsigned dev_major, dev_minor;
+    unsigned long inode;
+    int exec;
+};
+
+struct inject_state {
+    int attached;
+    int memory_patched;
+    int regs_set;
+    uint64_t win_addr;
+    size_t win_words;
+    long saved_words[PATCH_WORDS];
+    struct user_regs_struct regs;   /* original registers */
+    struct mapping maps[MAP_MAX];
+    size_t nmaps;
+};
+
+static const char *prog = "forcezero";
+static struct inject_state state;
+
+/* ---- diagnostics ---------------------------------------------------------- */
+
+static void perr(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
+
+static void perr(const char *fmt, ...)
+{
+    int err = errno;
+    va_list ap;
+
+    fprintf(stderr, "%s: ", prog);
+    va_start(ap, fmt);
+    vfprintf(stderr, fmt, ap);
+    va_end(ap);
+    if (err)
+        fprintf(stderr, ": %s", strerror(err));
+    fputc('\n', stderr);
+}
+
+/* ---- target memory access ------------------------------------------------- */
+
+static int peek_word(pid_t pid, uint64_t addr, long *out)
+{
+    long v;
+
+    errno = 0;
+    v = ptrace(PTRACE_PEEKDATA, pid, (void *)(uintptr_t)addr, NULL);
+    if (v == -1 && errno)
+        return -1;
+    *out = v;
+    return 0;
+}
+
+static int poke_word(pid_t pid, uint64_t addr, long v)
+{
+    return ptrace(PTRACE_POKEDATA, pid, (void *)(uintptr_t)addr, (void *)(uintptr_t)v);
+}
+
+/*
+ * Overwrite [addr, addr+len) in the target.  Writes happen in whole words, so
+ * the surrounding bytes of the first and last word are read first and merged
+ * back; `saved` receives the original window for a later restore_memory().
+ */
+static int patch_memory(pid_t pid, uint64_t addr, const unsigned char *bytes, size_t len,
+                        uint64_t *win_addr, size_t *win_words, long *saved)
+{
+    uint64_t start = addr & ~(uint64_t)7;
+    size_t off = (size_t)(addr - start);
+    size_t words = (off + len + 7) / 8;
+    unsigned char win[PATCH_WORDS * 8];
+    size_t i;
+
+    if (words > PATCH_WORDS) {
+        errno = EINVAL;
+        return -1;
+    }
+    for (i = 0; i < words; i++) {
+        long w;
+
+        if (peek_word(pid, start + i * 8, &w) != 0)
+            return -1;
+        saved[i] = w;
+        memcpy(win + i * 8, &w, 8);
+    }
+    memcpy(win + off, bytes, len);
+    for (i = 0; i < words; i++) {
+        long w;
+
+        memcpy(&w, win + i * 8, 8);
+        if (poke_word(pid, start + i * 8, w) != 0)
+            return -1;
+    }
+    *win_addr = start;
+    *win_words = words;
+    return 0;
+}
+
+static void restore_memory(pid_t pid, uint64_t win_addr, size_t win_words, const long *saved)
+{
+    size_t i;
+
+    for (i = 0; i < win_words; i++)
+        (void)poke_word(pid, win_addr + i * 8, saved[i]);
+}
+
+/* ---- /proc/<pid>/maps ----------------------------------------------------- */
+
+static int read_maps(pid_t pid, struct mapping *maps, size_t max, size_t *n)
+{
+    char path[64], line[1024];
+    FILE *fp;
+
+    snprintf(path, sizeof(path), "/proc/%ld/maps", (long)pid);
+    fp = fopen(path, "r");
+    if (!fp)
+        return -1;
+
+    *n = 0;
+    while (fgets(line, sizeof(line), fp) && *n < max) {
+        struct mapping m;
+        char perms[8];
+        unsigned long long start, end, off;
+        unsigned maj, min;
+        unsigned long ino;
+
+        if (sscanf(line, "%llx-%llx %7s %llx %x:%x %lu",
+                   &start, &end, perms, &off, &maj, &min, &ino) != 7)
+            continue;
+        m.start = start;
+        m.end = end;
+        m.file_off = off;
+        m.exec = (perms[2] == 'x');
+        m.dev_major = maj;
+        m.dev_minor = min;
+        m.inode = ino;
+        maps[(*n)++] = m;
+    }
+    fclose(fp);
+    return 0;
+}
+
+static const struct mapping *map_containing(const struct mapping *maps, size_t n,
+                                            uint64_t addr, int need_exec)
+{
+    size_t i;
+
+    for (i = 0; i < n; i++)
+        if (addr >= maps[i].start && addr < maps[i].end && (!need_exec || maps[i].exec))
+            return &maps[i];
+    return NULL;
+}
+
+static int exe_devino(pid_t pid, unsigned *maj, unsigned *min, unsigned long *ino)
+{
+    char path[64];
+    struct stat st;
+
+    snprintf(path, sizeof(path), "/proc/%ld/exe", (long)pid);
+    if (stat(path, &st) != 0)
+        return -1;
+    *maj = major(st.st_dev);
+    *min = minor(st.st_dev);
+    *ino = st.st_ino;
+    return 0;
+}
+
+/*
+ * Find where to put the payload.  Preferred spot is the entry point of the
+ * main executable: it is guaranteed to be executable, page-aligned enough to
+ * hold 12 bytes, and dead code for any process that already started.  If the
+ * ELF header is unavailable, fall back to the first roomy executable mapping.
+ */
+static int find_patch_addr(pid_t pid, const struct mapping *maps, size_t n,
+                           int is64, uint64_t *out)
+{
+    unsigned maj = 0, min = 0;
+    unsigned long ino = 0;
+    uint64_t base = 0;
+    int have_exe = 0, have_base = 0;
+    size_t i;
+
+    *out = 0;
+    if (exe_devino(pid, &maj, &min, &ino) == 0) {
+        have_exe = 1;
+        for (i = 0; i < n; i++) {
+            if (maps[i].inode == ino && maps[i].dev_major == maj &&
+                maps[i].dev_minor == min && maps[i].file_off == 0) {
+                base = maps[i].start;
+                have_base = 1;
+                break;
+            }
+        }
+    }
+
+    if (have_base) {
+        long w0, wtype, wentry;
+
+        if (peek_word(pid, base, &w0) == 0 && peek_word(pid, base + 16, &wtype) == 0 &&
+            peek_word(pid, base + 24, &wentry) == 0 && (w0 & 0xffffffffU) == 0x464c457fU) {
+            int etype = (int)((unsigned long)wtype & 0xffffU);
+            uint64_t entry = is64 ? (uint64_t)wentry : (uint32_t)wentry;
+
+            if (etype == ET_DYN)
+                entry += base;
+            if (etype == ET_DYN || etype == ET_EXEC) {
+                const struct mapping *m = map_containing(maps, n, entry, 1);
+
+                if (m && PAYLOAD_LEN <= m->end - entry) {
+                    *out = entry;
+                    return 0;
+                }
+            }
+        }
+    }
+
+    /* Fallback: a roomy executable page of the main executable that is not
+     * currently running any live function of interest. */
+    for (i = 0; i < n; i++) {
+        uint64_t a = maps[i].start + 0x100;
+
+        if (!maps[i].exec)
+            continue;
+        if (have_exe && !(maps[i].inode == ino && maps[i].dev_major == maj &&
+                          maps[i].dev_minor == min))
+            continue;
+        if (a + PAYLOAD_LEN <= maps[i].end) {
+            *out = a;
+            return 0;
+        }
+    }
+    /* Last resort: any file-backed executable mapping. */
+    for (i = 0; i < n; i++) {
+        uint64_t a = maps[i].start + 0x100;
+
+        if (maps[i].exec && maps[i].inode != 0 && a + PAYLOAD_LEN <= maps[i].end) {
+            *out = a;
+            return 0;
+        }
+    }
+    return -1;
+}
+
+/* ---- payload -------------------------------------------------------------- */
+
+/*
+ * The injectable payload, embedded verbatim in this binary.  The first four
+ * bytes of the "mov reg, imm32" instruction are patched with the requested
+ * exit code at runtime; everything else is fixed.
+ */
+static const unsigned char payload_tmpl[2][PAYLOAD_LEN] = {
+    /* mov edi, rc (bf imm32) ; mov eax, 231 (b8 e7 00 00 00) ; syscall (0f 05) */
+    { 0xbf, 0x00, 0x00, 0x00, 0x00, 0xb8, 0xe7, 0x00, 0x00, 0x00, 0x0f, 0x05 },
+    /* mov ebx, rc (bb imm32) ; mov eax, 252 (b8 fc 00 00 00) ; int 0x80 (cd 80) */
+    { 0xbb, 0x00, 0x00, 0x00, 0x00, 0xb8, 0xfc, 0x00, 0x00, 0x00, 0xcd, 0x80 },
+};
+
+static void build_payload(unsigned char *buf, int is64, int code)
+{
+    uint32_t rc = (uint32_t)code;
+
+    memcpy(buf, payload_tmpl[is64 ? 0 : 1], PAYLOAD_LEN);
+    memcpy(buf + 1, &rc, 4);   /* immediate of "mov reg, imm32" */
+}
+
+/* ---- injection ------------------------------------------------------------ */
+
+static void cleanup(pid_t pid)
+{
+    if (state.memory_patched)
+        restore_memory(pid, state.win_addr, state.win_words, state.saved_words);
+    if (state.regs_set)
+        (void)ptrace(PTRACE_SETREGS, pid, NULL, &state.regs);
+    if (state.attached)
+        (void)ptrace(PTRACE_DETACH, pid, NULL, NULL);
+}
+
+static int inject(pid_t pid, int code)
+{
+    struct user_regs_struct regs;
+    unsigned char payload[PAYLOAD_LEN];
+    uint64_t patch_addr;
+    int status, is64;
+
+    memset(&state, 0, sizeof(state));
+
+    /* 1. Attach and stop the target. */
+    if (ptrace(PTRACE_SEIZE, pid, NULL, NULL) == 0) {
+        state.attached = 1;
+        if (ptrace(PTRACE_INTERRUPT, pid, NULL, NULL) != 0) {
+            if (errno == ESRCH)
+                goto gone;
+            perr("interrupting pid %ld failed", (long)pid);
+            goto fail;
+        }
+    } else if (errno == EINVAL) {
+        /* Kernel without PTRACE_SEIZE (pre-3.4); plain attach will do. */
+        if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) != 0)
+            goto attach_failed;
+        state.attached = 1;
+    } else {
+        goto attach_failed;
+    }
+
+    if (waitpid(pid, &status, __WALL) < 0) {
+        perr("waiting for pid %ld failed", (long)pid);
+        goto fail;
+    }
+    if (!WIFSTOPPED(status))
+        goto gone;
+
+    /* 2. Grab the registers (we need the original set for rollback and the
+     *    segment selector to tell 32-bit compat targets apart). */
+    if (ptrace(PTRACE_GETREGS, pid, NULL, &state.regs) != 0) {
+        perr("reading registers of pid %ld failed", (long)pid);
+        goto fail;
+    }
+    is64 = (state.regs.cs == 0x33);
+
+    /* A thread parked inside execve() would replace its whole address space
+     * the moment it is resumed, taking the payload with it.  Refuse politely. */
+    if ((is64 && state.regs.orig_rax == 59) ||
+        (!is64 && state.regs.orig_rax == 11)) {
+        if (is64 ? (state.regs.rax == (unsigned long long)-ENOSYS)
+                 : ((uint32_t)state.regs.rax == (uint32_t)-ENOSYS)) {
+            errno = 0;
+            perr("pid %ld is mid-exec, retry in a moment", (long)pid);
+            goto fail;
+        }
+    }
+
+    /* 3. Find the payload site. */
+    if (read_maps(pid, state.maps, MAP_MAX, &state.nmaps) != 0) {
+        perr("cannot read /proc/%ld/maps", (long)pid);
+        goto fail;
+    }
+    if (find_patch_addr(pid, state.maps, state.nmaps, is64, &patch_addr) != 0) {
+        errno = 0;
+        perr("no usable executable page found in pid %ld", (long)pid);
+        goto fail;
+    }
+
+    /* 4. Write the payload, hijack RIP, detach. */
+    build_payload(payload, is64, code);
+    if (patch_memory(pid, patch_addr, payload, PAYLOAD_LEN,
+                     &state.win_addr, &state.win_words, state.saved_words) != 0) {
+        perr("writing payload to pid %ld at 0x%llx failed",
+             (long)pid, (unsigned long long)patch_addr);
+        goto fail;
+    }
+    state.memory_patched = 1;
+
+    regs = state.regs;
+    regs.rip = patch_addr;
+    /*
+     * A thread stopped in an interruptible syscall may carry a pending
+     * ERESTART_* in RAX; the kernel then rewrites the resume RIP (-2, to
+     * re-execute the syscall instruction) *after* PTRACE_SETREGS, which
+     * would land two bytes before our payload.  Clearing RAX makes the
+     * kernel skip the restart dance and return to user space exactly at
+     * the payload.
+     */
+    regs.rax = 0;
+    if (ptrace(PTRACE_SETREGS, pid, NULL, &regs) != 0) {
+        perr("pointing pid %ld at the payload failed", (long)pid);
+        goto fail;
+    }
+    state.regs_set = 1;
+
+    if (ptrace(PTRACE_DETACH, pid, NULL, NULL) != 0) {
+        perr("detaching from pid %ld failed", (long)pid);
+        goto fail;
+    }
+    state.attached = 0;
+    state.memory_patched = 0;   /* the payload stays; the target dies next */
+    return 0;
+
+attach_failed:
+    {
+        int err = errno;
+
+        errno = 0;   /* errno text is part of the message already */
+        if (err == ESRCH)
+            perr("no process with pid %ld", (long)pid);
+        else if (err == EPERM)
+            perr("cannot ptrace pid %ld: %s; need the same uid (yama ptrace_scope 0) "
+                 "or root/CAP_SYS_PTRACE — try 'sudo forcezero %ld'",
+                 (long)pid, strerror(err), (long)pid);
+        else
+            perr("ptrace attach to pid %ld failed: %s", (long)pid, strerror(err));
+    }
+    return -1;
+
+gone:
+    fprintf(stderr, "%s: pid %ld already exited, nothing to inject\n", prog, (long)pid);
+    return 0;
+
+fail:
+    cleanup(pid);
+    return -1;
+}
+
+/* ---- cli ------------------------------------------------------------------ */
+
+static void usage(FILE *fp)
+{
+    fputs(
+"Usage: forcezero <pid> [exit-code]\n"
+"\n"
+"Force the running process <pid> to exit with <exit-code> (0..255, default 0).\n"
+"The injectable payload is built into this executable; nothing is written to\n"
+"disk, and the target keeps no trace of the injection in its files.\n"
+"\n"
+"Examples:\n"
+"  sudo forcezero 12345        # process 12345 now exits with status 0\n"
+"  sudo forcezero 12345 3      # ... with status 3\n"
+"\n"
+"Requires permission to ptrace <pid>: same uid (with yama ptrace_scope 0) or\n"
+"root/CAP_SYS_PTRACE; 'sudo forcezero ...' covers the common cases.\n",
+        fp);
+}
+
+int main(int argc, char **argv)
+{
+    pid_t pid;
+    int code = 0;
+    char *end;
+
+    if (argc == 2 && (strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0)) {
+        usage(stdout);
+        return 0;
+    }
+    if (argc < 2 || argc > 3) {
+        usage(stderr);
+        return 2;
+    }
+
+    errno = 0;
+    pid = (pid_t)strtol(argv[1], &end, 10);
+    if (errno || *end || end == argv[1] || pid <= 0) {
+        perr("invalid pid '%s'", argv[1]);
+        return 2;
+    }
+    if (argc == 3) {
+        long c;
+
+        errno = 0;
+        c = strtol(argv[2], &end, 10);
+        if (errno || *end || end == argv[2] || c < 0 || c > 255) {
+            perr("exit code must be an integer in 0..255");
+            return 2;
+        }
+        code = (int)c;
+    }
+
+    return inject(pid, code) == 0 ? 0 : 1;
+}