Copy Fail Mitigation
CVE-2026-31431 — algif_aead local privilege escalation
This document covers four things: (1) how to remove or disable algif_aead at fleet scale, including the gotchas in the standard blacklist / install / rmmod recipe; (2) runtime policies for Docker and Kubernetes that block the exploitation path even on unpatched kernels; (3) forensic posture — finding algif_aead loaded on a general-purpose host is itself a signal worth investigating; and (4) where the public PoC needs porting effort and where a host that looks immune to the published exploit is still vulnerable to the underlying bug.
1. Detect the build mode first
The mitigation that’s circulating widely —
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null
— is fine on Debian/Ubuntu (module not loaded by default but autoloads when exploit code is called) but does nothing on RHEL-family hosts. Those kernels ship with CONFIG_CRYPTO_USER_API_AEAD=y rather than =m, so modprobe.d rules cannot block loading and rmmod cannot remove a built-in. The commands return success and leave the system unchanged. Any fleet rollout has to branch on the build mode of the running kernel.
grep -E '^CONFIG_CRYPTO_USER_API_AEAD=' /boot/config-$(uname -r)
=m— module path (modprobe.d + rmmod work, with caveats below)=y— built-in path (needsinitcall_blacklist+ reboot, see §3)- absent — already not compiled in, no action required
Runtime cross-check: lsmod | grep algif_aead only matches in the =m case.
2. Module case (Debian / Ubuntu / vanilla / cloud images)
Persistent block, active unload, initramfs regeneration so the rule survives early boot:
cat >/etc/modprobe.d/disable-algif-aead.conf <<'EOF'
install algif_aead /bin/false
install algif_skcipher /bin/false
install algif_hash /bin/false
install algif_rng /bin/false
EOF
update-initramfs -u # Debian/Ubuntu
# dracut -f # Fedora-family module kernels
modprobe -r algif_aead 2>/dev/null
This is necessary but not sufficient. The next three subsections explain why, and what to add.
2.1. Why blacklist alone is inadequate
The blacklist directive in modprobe.d is widely misunderstood. Per modprobe.d(5), it instructs modprobe to ignore the module’s internal aliases — the MODULE_ALIAS() entries compiled into the .ko. That stops kernel-triggered, alias-driven loads (udev coldplug, request_module("crypto-aead") via alias matching), but it does not stop an explicit by-name request:
$ cat /etc/modprobe.d/test.conf
blacklist algif_aead
$ modprobe algif_aead # succeeds despite the blacklist
$ lsmod | grep algif_aead
algif_aead 20480 0
The install <module> /bin/false pattern is the harder block because it tells modprobe to execute /bin/false instead of loading the module — there is no path through /sbin/modprobe that loads it, by name or by alias. Both kernel-side request_module() and userspace modprobe exec the same binary and consult the same configuration directory, so this directive covers both.
install … /bin/false, not blacklist. If the existing fleet baseline relies on blacklist for any sensitive module — not just algif_aead — audit it. blacklist is alias-suppression, not load-suppression.2.2. The install directive’s blind spot: insmod and finit_module(2)
Even install … /bin/false only binds modprobe behavior. Anything that calls finit_module(2) directly — including legacy insmod — bypasses modprobe entirely and reads the .ko straight from disk:
# Bypasses /etc/modprobe.d/ completely:
insmod /lib/modules/$(uname -r)/kernel/crypto/algif_aead.ko.zst
modprobe --ignore-install algif_aead is another end-run from userspace, and any program with CAP_SYS_MODULE can call finit_module(fd, …) on an open fd to the .ko file directly with a few lines of C. modprobe.d is policy enforced by a userspace helper; the kernel itself does not consult it. To actually deny the load you have to attack the .ko file or the loader’s privilege.
2.3. Disabling the .ko at the filesystem layer
Three layers, in increasing strength. On Debian / Ubuntu / cloud-image kernels these all apply because the module ships as a separate .ko file under /lib/modules/<version>/kernel/crypto/.
| Layer | What it does | What it doesn’t |
|---|---|---|
chmod 000 |
Stops accidental loads from non-root callers and creates a tripwire — if the perm is reverted, that’s evidence. File integrity monitors (AIDE, auditd watches) will alarm on the change. | Does not stop a caller with CAP_DAC_OVERRIDE, which most module loaders implicitly hold. Treat as a sensor, not a wall. |
Delete the .ko |
Removes the artifact. finit_module() needs an fd to a file; no file, no load. Permanent until the kernel package is reinstalled. |
An attacker who can write to /lib/modules/ and bring an ABI-matched .ko (or rebuild it) can replace it. Practically rare; requires already-root and toolchain access. |
dpkg-divert |
Records the displacement in /var/lib/dpkg/diverts, surviving apt upgrade. The next time the kernel package writes that path, dpkg honors the divert and writes to the renamed location instead. |
Diverts are per-path and per-kernel-version. New kernel installs land under a new version directory and need a fresh divert — schedule that into the kernel-install hook or the next config-management converge. |
Concrete fleet recipe for Debian / Ubuntu / Debian-derived cloud images:
#!/bin/bash
set -eu
KVER=$(uname -r)
KDIR=/lib/modules/$KVER/kernel/crypto
# Layer 1+2: chmod then delete (chmod first creates a clean audit trail
# for change-management reporting, then the artifact is removed).
for mod in algif_aead algif_skcipher algif_hash algif_rng; do
for ext in ko ko.zst ko.xz ko.gz; do
f="$KDIR/$mod.$ext"
[ -f "$f" ] || continue
chmod 000 "$f"
# Layer 3: divert + rename so package upgrades don't restore it.
dpkg-divert --add --rename \
--divert "${f}.copyfail-disabled" "$f"
done
done
# Rebuild dependency map so the loader doesn't see stale entries.
depmod -a "$KVER"
# Belt-and-suspenders: keep the modprobe.d install rule too,
# in case a fresh .ko is rebuilt and the divert hasn't been
# re-applied yet for that new kernel version.
cat >/etc/modprobe.d/disable-algif-aead.conf <<'EOF'
install algif_aead /bin/false
install algif_skcipher /bin/false
install algif_hash /bin/false
install algif_rng /bin/false
EOF
update-initramfs -u
For the strongest posture: enable kernel module signature enforcement (CONFIG_MODULE_SIG_FORCE=y), pair with secure boot, and a custom .ko that wasn’t built and signed by the distro can’t be loaded at all. Most cloud kernels do not ship with SIG_FORCE; check /sys/kernel/security/lockdown and /proc/sys/kernel/modules_disabled on a sample host before assuming.
3. Built-in case (RHEL / Alma / Rocky / CloudLinux / Oracle, some SUSE)
Module tooling is irrelevant. There is no .ko file to chmod, delete, or divert — the code is linked into vmlinuz. Suppress the initcall on next boot:
grubby --update-kernel=ALL --args="initcall_blacklist=algif_aead_init"
grubby --info=ALL | grep initcall_blacklist
# reboot required; the running kernel stays exposed until then
Until the reboot lands, the runtime control surface is seccomp/LSM rather than module management — block socket(AF_ALG, …) for any workload that doesn’t legitimately need it. §7 onwards covers this.
4. Fleet rollout fix (Ansible)
slurp/boot/config-$(uname -r), parseCONFIG_CRYPTO_USER_API_AEAD.- Module branch (
=m): apply §2 —modprobe.d install,.kochmod + delete + dpkg-divert, initramfs regen. - Built-in branch (
=y): grubby edit, flag host for reboot batch. - Verify: assert no
aeadalgorithms in/proc/crypto, noalgif_*entries inlsmod, divert recorded indpkg-divert --list | grep algif, orinitcall_blacklist=algif_aead_initin/proc/cmdlineafter reboot. - Drift check until patched kernels are rolled.
For container fleets, the OVH-style privileged DaemonSet that lays down the modprobe.d file and unloads on each node is the host-side pattern, paired with a runtime-level seccomp profile change so unpatched nodes still refuse AF_ALG from workloads. The seccomp layer is the more durable control.
5. rmmod –force: what it actually does
rmmod -f sets the O_TRUNC flag on the delete_module(2) syscall, which the kernel routes through try_force_unload(). That bypasses three things the unforced path enforces:
- the usage-count check (
module_refcount(mod) != 0normally returns-EWOULDBLOCK), - the requirement that the module be in
MODULE_STATE_LIVE, - waits for any pending grace periods, including RCU readers traversing the module’s text.
Two preconditions matter:
- Kernel must be built with
CONFIG_MODULE_FORCE_UNLOAD=y. Check viagrep CONFIG_MODULE_FORCE_UNLOAD /boot/config-$(uname -r)orzgrep /proc/config.gz. Distribution kernels (RHEL, Ubuntu LTS, SUSE Enterprise) typically ship this off, in which case the syscall returns-EINVALregardless of how loudly userspace asks. The userspacekmodaccepts the flag and forwards it; success has to be confirmed withlsmod, not exit status. - The kernel taints itself (
TAINT_FORCED_RMMOD, visible in/proc/sys/kernel/tainted), and any thread still executing inside the module’s.textor dereferencing into its data segment hits a use-after-free. For a crypto module this class is particularly nasty: outstandingtfmcontexts, queued requests in workqueues, async completion callbacks, and in-flight scatterlists can all resume into freed pages.
-f is the wrong tool. If algif_aead is busy, drain the holders (lsof | grep AF_ALG, ss -f alg, scan /proc/*/fd) and unload cleanly. If the holders genuinely cannot be drained, the install … /bin/false rule plus .ko removal already prevents re-load, and the patched kernel + reboot is the proper exit; a forced unload buys nothing the install directive plus reboot wouldn’t give more safely.6. Forensic posture — algif_aead loaded is itself an indicator
algif_aead is rarely present in normal operation on a typical server. Default-built kernels do not auto-load it at boot; it loads on-demand via the kernel’s request_module() path when something binds an AF_ALG socket to an AEAD algorithm. The well-known kernel crypto consumers — LUKS / dm-crypt, kTLS, IPsec / xfrm, OpenSSL / GnuTLS / NSS in default builds, OpenSSH — all use kernel-internal crypto APIs directly and never touch AF_ALG.
The legitimate consumers are narrow:
- libkcapi command-line tools (
kcapi-enc,kcapi-dgst) used in specific embedded or testing contexts - OpenSSL’s
afalgengine, rarely enabled and historically unreliable - Vendor-specific hardware crypto offload that exposes AF_ALG to userspace
- A handful of unusual strongSwan / IKE configurations
On a general-purpose server — web, database, app, k8s node, jump host, build agent — finding algif_aead loaded should be treated as suspicious. Because the module was the staging ground for Copy Fail, a loaded state that cannot be accounted for is consistent with either an in-progress exploitation attempt or a failed one that left the module attached. The kernel does not auto-unload modules when their refcount drops to zero, so a previously-exploited host can show the module loaded long after the attacker’s process has exited.
lsmod on a production host shows any of algif_aead, algif_skcipher, algif_hash, or algif_rng loaded, and the host’s role does not include libkcapi tooling, an OpenSSL build with the afalg engine enabled, or hardware crypto offload — open a ticket and preserve volatile state before unloading.6.1. Live triage queries
# Is any algif_* family member currently loaded?
lsmod | grep -E '^algif_(aead|skcipher|hash|rng)'
# Open AF_ALG sockets right now (newer iproute2):
ss -fa alg
# Older systems:
lsof 2>/dev/null | grep AF_ALG
# Module load history from the audit subsystem:
ausearch -m MODULE_LOAD -ts recent
# or:
grep -E 'init_module|finit_module' /var/log/audit/audit.log
# Kernel ring buffer (dyndbg-dependent, but worth checking):
dmesg -T | grep -iE 'algif|af_alg'
# Module-specific taint flags (forced load, unsigned, out-of-tree):
cat /sys/module/algif_aead/taint 2>/dev/null
cat /sys/module/algif_aead/refcnt 2>/dev/null
cat /proc/sys/kernel/tainted
# When was the .ko file last accessed? (atime --- note relatime caveats)
stat /lib/modules/$(uname -r)/kernel/crypto/algif_aead.ko*
If lsmod shows the module loaded but ss -fa alg shows no open sockets, that is still meaningful — the loader process may have closed its sockets and exited while the module stayed resident. Capture the fact and time before unloading; correlate against process exit events in the EDR.
6.2. Audit rules to deploy ahead of any incident
# Watch the module directory for tampering --- perm changes,
# deletions, new .ko drops:
auditctl -w /lib/modules -p wa -k module_changes
# Catch every load/unload at the syscall level, with PID/UID:
auditctl -a always,exit -F arch=b64 \
-S init_module -S finit_module -S delete_module \
-k module_lifecycle
# Persistent: drop the equivalents into /etc/audit/rules.d/
# and run augenrules --load.
The module_lifecycle key is what to grep for after the fact. MODULE_LOAD records carry the loader’s pid, uid, exe, and the module name as supplied to the syscall — sufficient to attribute the load to a specific process tree.
6.3. SIEM correlation angles
- Alert on any kernel module load outside an approved change window for production hosts.
- Alert specifically on
algif_*family loads anywhere in the fleet not on a known-legitimate allow-list (libkcapi-using build agents, hardware crypto offload nodes). - Cross-reference with the AF_ALG socket creation rule from §13: an AF_ALG socket created by an unexpected binary, around the same time as a module load, is high-confidence.
- Look for
python/perl/ruby/gointerpreters with AF_ALG sockets — the published PoC is a 732-byte Python script and copycats will follow the same shape. An interpreter with an AF_ALG fd in/proc/<pid>/fdon a non-developer host is worth investigating. - Remember the page-cache effect: on-disk
su/sudo/passwdbinaries are unchanged after exploitation. File-integrity monitors that compare against on-disk state will report clean. Investigation has to lean on process telemetry and audit, not on file hashing.
Container runtime mitigations
Docker, Podman, Kubernetes — seccomp, admission policy, detection
The base ingredient is one seccomp profile that returns EAFNOSUPPORT for any socket(AF_ALG, …) call. The same JSON works in Docker, Podman, and Kubernetes (via Localhost profile). Below: the profile, then how each runtime references it, then enforcement at the cluster admission layer, then the detection counterpart.
7. The seccomp profile
AF_ALG is socket family 38. Returning EAFNOSUPPORT (97) rather than EPERM makes the runtime behavior indistinguishable from a kernel with CONFIG_CRYPTO_USER_API=n, so applications that probe AF_ALG fall back gracefully (OpenSSL’s afalg engine, libkcapi consumers) instead of bubbling up “permission denied” errors.
copy-fail-deny.json:
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["socket", "socketpair"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 97,
"args": [
{
"index": 0,
"value": 38,
"op": "SCMP_CMP_EQ"
}
],
"comment": "Block AF_ALG (CVE-2026-31431 Copy Fail)"
}
]
}
This profile is additive only — defaultAction: SCMP_ACT_ALLOW lets everything else through. In production, merge this rule into Docker’s default seccomp profile (moby’s profiles/seccomp/default.json) rather than replace it. The merge is purely appending one entry to the syscalls array.
Correctness notes:
- The filter triggers on
socket(2)andsocketpair(2). On x86_64, ARM64, and every modern container target, glibc calls these directly. On x86_32 and a few legacy arches, glibc routes throughsocketcall(2)with the family hidden behind a userspace pointer that seccomp cannot dereference. For 32-bit workloads, complement this with a deny onsocketcall. - Privileged containers (
--privilegedin Docker,securityContext.privileged: truein K8s) bypass seccomp entirely. The profile gives no protection there; for those, the kernel-level mitigation is the only answer.
8. Docker
Per-container:
docker run \
--security-opt seccomp=/etc/docker/seccomp/copy-fail-deny.json \
myimage
Daemon-wide default in /etc/docker/daemon.json:
{
"seccomp-profile": "/etc/docker/seccomp/copy-fail-deny.json"
}
then systemctl reload docker. Verify from inside any container:
python3 -c 'import socket; socket.socket(38, socket.SOCK_SEQPACKET, 0)'
# OSError: [Errno 97] Address family not supported by protocol
9. Podman
Same JSON, slightly different invocation:
podman run \
--security-opt seccomp=/etc/containers/seccomp/copy-fail-deny.json \
myimage
System default in /etc/containers/seccomp.json (Podman reads this path automatically when seccomp_profile is set in containers.conf).
10. Kubernetes — distribute the profile
The kubelet looks for Localhost profiles under <root-dir>/seccomp/, default /var/lib/kubelet/seccomp/. A privileged DaemonSet drops the file in place:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: install-copy-fail-seccomp
namespace: kube-system
spec:
selector:
matchLabels: { app: install-copy-fail-seccomp }
template:
metadata:
labels: { app: install-copy-fail-seccomp }
spec:
tolerations:
- operator: Exists
containers:
- name: installer
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
set -eu
mkdir -p /host/seccomp/profiles
cat > /host/seccomp/profiles/copy-fail-deny.json <<'EOF'
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [{
"names": ["socket","socketpair"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 97,
"args": [{"index":0,"value":38,"op":"SCMP_CMP_EQ"}]
}]
}
EOF
exec sleep infinity
securityContext:
privileged: true
runAsUser: 0
volumeMounts:
- name: seccomp
mountPath: /host/seccomp
volumes:
- name: seccomp
hostPath:
path: /var/lib/kubelet/seccomp
type: DirectoryOrCreate
For production, prefer the Security Profiles Operator (SPO) — it owns this distribution problem properly via a SeccompProfile CRD and handles node churn.
Reference from pods
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/copy-fail-deny.json
containers:
- name: app
image: myimage
The path is relative to the kubelet’s seccomp root, not absolute.
11. Enforce cluster-wide with Kyverno
Validation policy (rejects pods that don’t carry the profile):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-copy-fail-seccomp
spec:
validationFailureAction: Enforce
background: false
rules:
- name: pod-must-have-copy-fail-seccomp
match:
any:
- resources:
kinds: [Pod]
exclude:
any:
- resources:
namespaces: [kube-system]
validate:
message: >
Pods must set spec.securityContext.seccompProfile to
Localhost:profiles/copy-fail-deny.json (CVE-2026-31431 mitigation).
pattern:
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/copy-fail-deny.json
Mutation variant (injects the profile rather than rejecting pods that lack it — friendlier rollout):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: inject-copy-fail-seccomp
spec:
rules:
- name: add-seccomp-profile
match:
any:
- resources:
kinds: [Pod]
mutate:
patchStrategicMerge:
spec:
securityContext:
+(seccompProfile):
type: Localhost
localhostProfile: profiles/copy-fail-deny.json
The +(seccompProfile) adds the field only when absent, so workloads that already declare RuntimeDefault or another Localhost profile aren’t clobbered.
12. OPA/Gatekeeper equivalent
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredseccomp
spec:
crd:
spec:
names: { kind: K8sRequiredSeccomp }
validation:
openAPIV3Schema:
type: object
properties:
requiredProfile: { type: string }
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredseccomp
violation[{"msg": msg}] {
input.review.kind.kind == "Pod"
profile := input.review.object.spec.securityContext.seccompProfile
not profile.type == "Localhost"
msg := "Pod must set seccompProfile.type=Localhost"
}
violation[{"msg": msg}] {
input.review.kind.kind == "Pod"
profile := input.review.object.spec.securityContext.seccompProfile
profile.localhostProfile != input.parameters.requiredProfile
msg := sprintf("seccompProfile.localhostProfile must be %v",
[input.parameters.requiredProfile])
}
Then the constraint:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredSeccomp
metadata:
name: require-copy-fail-seccomp
spec:
match:
kinds: [{ apiGroups: [""], kinds: ["Pod"] }]
excludedNamespaces: ["kube-system"]
parameters:
requiredProfile: profiles/copy-fail-deny.json
13. Detection layer (Falco)
Pair prevention with telemetry on AF_ALG attempts so exploit attempts hitting the wall are visible:
- list: af_alg_legitimate_users
items: [strongswan, charon, ipsec, kcapi-enc, kcapi-dgst]
- rule: AF_ALG socket creation
desc: >
socket(AF_ALG,...) is the first step of CVE-2026-31431 Copy Fail.
Outside known IPsec/libkcapi binaries it is suspicious.
condition: >
evt.type = socket and evt.dir = > and
evt.arg.domain = AF_ALG and
not proc.name in (af_alg_legitimate_users)
output: >
AF_ALG socket created (proc=%proc.name pid=%proc.pid
cmdline=%proc.cmdline container=%container.id image=%container.image.repository)
priority: WARNING
tags: [linux, container, mitre_privilege_escalation, cve-2026-31431]
Tetragon equivalents work too if that’s the stack — same observable, different sensor.
14. Verification
End-to-end test from a pod or container:
# Should now fail with Errno 97
python3 -c 'import socket; socket.socket(38, socket.SOCK_SEQPACKET, 0)'
# C version, useful in scratch images
cat > t.c <<'EOF'
#include <sys/socket.h>
#include <stdio.h>
#include <errno.h>
int main(void){
int s = socket(38, SOCK_SEQPACKET, 0);
printf("rc=%d errno=%d\n", s, errno);
return 0;
}
EOF
gcc t.c -o t && ./t # expect rc=-1 errno=97
If the test instead returns errno=13 (EACCES) or the socket succeeds, the profile isn’t being applied — most likely the pod is privileged, the runtime is kata / gvisor with its own filter chain, or the kubelet path is different from /var/lib/kubelet/seccomp/. Check crictl inspect <container> for the seccomp path the runtime actually loaded.
Exploit portability
The §6 forensic guidance assumes the published PoC is what’s hitting your fleet. That assumption holds for hosts that match the PoC’s development environment, and it breaks for everything else. A target that the public exploit walks past untouched is not a target that’s safe from algif_aead corruption — the kernel bug doesn’t care which userspace binary the attacker chose to poison. This section walks through where the off-the-shelf script needs porting work, where it fails outright, and what that means for both the patch-priority calculus and the detection rules from §6.
15. Where the public PoC fits and where it doesn’t
The published PoC for Copy Fail is a 732-byte Python script. It opens the page-cache mapping for /usr/bin/su, drives algif_aead through the in-place AEAD path until the kernel’s copy step writes attacker-supplied bytes into the cached page, then runs su. The kernel uses the poisoned page on the next exec because mmap returns whatever the page cache holds; the on-disk file is unchanged. That description fits a glibc Debian or Ubuntu host on x86_64 with 4 KiB pages — the development environment the PoC author wrote against.
Three things in that description are assumptions, and each one breaks on a class of system worth thinking through.
15.1. The path: /usr/bin/su is not on every system
shadow-utils ships /usr/bin/su as a setuid-root ELF on Debian, Ubuntu, RHEL, Fedora, Alma, Rocky, SUSE, and most other glibc distros. The PoC’s hardcoded offsets are computed from a specific shadow-utils build linked against a specific glibc; a build drift moves the target. Recompiling the PoC for a different distro is mostly mechanical — re-derive the offset, rebuild the patch payload — but not zero-effort, and the PoC as published won’t run cleanly across distros without that step.
Alpine has no /usr/bin/su. It ships busybox, with /bin/su as a symlink. The setuid bit lives on /bin/busybox.suid (from the busybox-suid package), a separate ELF that handles the applets that need root: su, login, passwd, mount. Plain /bin/busybox is not setuid. busybox dispatches on argv[0], so the same binary is dozens of tools, and an attacker who poisons busybox.suid to subvert the su applet has to land their patch inside the right applet’s code path inside one shared dispatch tree. The binary is statically linked against musl rather than dynamically against glibc, and the layout has nothing in common with shadow-utils su. The PoC’s offsets miss by megabytes.
The same shape holds for embedded systems built around busybox: most consumer routers, NAS appliances, IoT gateways, and minimal container base images. A container that’s FROM alpine or FROM busybox makes the PoC’s first open("/usr/bin/su") fail with ENOENT. The attempt looks like nothing in your telemetry beyond a brief Python process and a closed AF_ALG socket — which, per §13, is exactly the signal you should be alerting on regardless.
doas-based systems are a separate case. Void, Artix, hardened Alpine builds, and a few Gentoo profiles run doas instead of sudo, often replacing su outright with a doas-friendly wrapper or removing it in favor of root login. The doas binary is roughly an order of magnitude smaller than sudo (40 KiB versus 500+ KiB), the code path is much shorter, and its layout has no overlap with su’s. Porting Copy Fail to doas means rewriting the offsets and the shellcode hook locations end to end; recompilation alone won’t bridge it.
15.2. The arch: x86_64 shellcode does not run on aarch64
Cloud aarch64 fleets (Graviton, Ampere Altra, Azure Cobalt, Oracle Ampere) and the entire ARM-based edge run a different ISA. The PoC’s payload is x86_64 instruction bytes — typically a stub that calls setuid(0) and then execve("/bin/sh"). On aarch64 those bytes decode to garbage. Three things change:
- Instructions are fixed-width 32-bit, not the variable-length encoding x86_64 uses. The shellcode has to be reassembled, not patched.
- Syscall numbers differ.
setuidis__NR_setuid = 146on aarch64 (asm-generic) versus105on x86_64.execveis221versus59. - The calling convention puts arguments in
x0–x5with the syscall number inx8and the trap issvc #0, rather thanrdi/rsi/…/raxandsyscall. The setuid+execve stub is structurally identical across the two architectures; the bytes are entirely different.
The aarch64 equivalent of a “setuid(0); execve(”/bin/sh”, argv, 0)” stub looks roughly like:
mov x0, #0
mov x8, #146 // __NR_setuid
svc #0
adr x0, sh // "/bin/sh\0"
mov x1, #0
mov x2, #0
mov x8, #221 // __NR_execve
svc #0
sh: .ascii "/bin/sh\0"
The x86_64 version is a different instruction stream of different total length and lands at a different offset inside the poisoned page.
Page size adds another fork. x86_64 is 4 KiB throughout. aarch64 has three options the kernel picks at compile time: 4 KiB, 16 KiB, or 64 KiB. RHEL aarch64 builds have shipped 64 KiB pages by default for years. Asahi Linux on Apple Silicon runs 16 KiB. Most Debian and Ubuntu aarch64 cloud images stay at 4 KiB. The exploit corrupts page-cache contents at PAGE_SIZE granularity, so on a 64 KiB-page kernel one page covers sixteen times the binary that one 4 KiB page does. Different offsets land within the same page (so a single corruption can reach further), the alignment math the PoC uses to find its target shifts, and the corruption blast radius widens. A working exploit on a 64 KiB-page aarch64 host is a different shellcode at a different offset against the same kernel bug — same vulnerability, fully different payload.
An attacker assessing arch and page size before running anything is one command:
uname -m && getconf PAGESIZE
If the answer is not x86_64 4096, the public PoC won’t do useful work without a rewrite.
15.3. Other setuid candidates if su doesn’t fit
When /usr/bin/su is missing or its shape doesn’t match, the next targets are whatever else is setuid root and reachable:
find / -perm -4000 -type f 2>/dev/null
On a typical glibc distro that yields passwd, chsh, chfn, mount, umount, pkexec on systems with polkit installed, and ping on older kernels that haven’t shifted to file capabilities. pkexec is the popular pivot: small ELF, setuid root by default on most desktops and many servers, and a successful corruption hands back a root shell directly without PAM in the way. On Alpine, the candidates reduce to whatever applets the busybox-suid build includes plus any third-party setuid binaries the image ships — dropbearmulti, tinyssh-shipped helpers, vendor-specific tools.
The forensic takeaway: a loaded algif_aead followed by no su exec is not evidence the attempt failed. It’s evidence the attacker chose a different setuid binary, or chose a non-setuid target the kernel will exec from cache anyway (a daemon binary that root will re-exec on SIGHUP, for example). Watch every setuid exec from interpreter or shell processes during an algif_* load window, not just su.
15.4. Detection adjustments for non-x86_64-Debian targets
Three changes to the §6 detection guidance follow.
The AF_ALG socket creation rule from §13 fires uniformly across architectures — that part is portable. The follow-up triage that compares page-cache contents against the on-disk binary needs to read PAGE_SIZE from /proc/self/auxv (AT_PAGESZ) rather than assuming 4096. On 64 KiB-page aarch64 a 4 KiB-aligned readback returns aliased data, which produces false negatives on clean systems and false positives on dirty ones.
On Alpine and busybox-based images the right artifact to hash is /bin/busybox.suid, not /bin/su. Symlink resolution and the busybox-suid package status both matter: if the package is absent the image has no setuid root binaries from the busybox family at all, and the AF_ALG signal from §13 carries less weight (the attacker would need a different exec target, and the candidate list is short).
In mixed fleets, treat “PoC didn’t run” as a negative result for the public exploit script, not a negative result for the underlying vulnerability. A target that’s immune to the public PoC because of path or arch differences is still vulnerable to algif_aead in-place corruption — the kernel bug is in the kernel, not in any one binary. Apply §1–§5 host mitigation and §7–§14 container mitigation regardless of how exposed any given host looks to the public exploit.
16. Portability quick reference
| Target | Setuid binary the PoC expects | Arch / page size | Public PoC behavior |
|---|---|---|---|
| Ubuntu / Debian x86_64 | /usr/bin/su |
x86_64 / 4 KiB | Runs as shipped |
| RHEL / Fedora x86_64 | /usr/bin/su |
x86_64 / 4 KiB | Runs after offset re-derivation for the local shadow-utils + glibc build |
Alpine x86_64 (with busybox-suid) |
/bin/busybox.suid |
x86_64 / 4 KiB | Wrong target binary; PoC opens a missing path and exits. Vulnerable to a custom build that targets busybox.suid. |
Alpine x86_64 (no busybox-suid) |
— | x86_64 / 4 KiB | No suitable target in the busybox family; attacker has to pick another setuid ELF. |
| doas-using distros (Void, Artix, hardened Alpine) | /usr/bin/doas |
x86_64 / 4 KiB | Wrong target binary; full rewrite of offsets and shellcode hooks needed. |
| Ubuntu / Debian aarch64 | /usr/bin/su |
aarch64 / 4 KiB | Wrong arch; shellcode bytes decode to garbage; payload rewrite needed. |
| RHEL aarch64 | /usr/bin/su |
aarch64 / 64 KiB | Wrong arch and wrong page size; payload rewrite plus offset re-derivation against 64 KiB granularity. |
| Asahi (Apple Silicon Linux) | /usr/bin/su |
aarch64 / 16 KiB | Wrong arch and 16 KiB pages; payload rewrite plus offset adjustment for the larger page. |
| Embedded busybox (router / NAS / IoT) | /bin/busybox.suid (when present) |
varies (often armv7, mips, mipsel) | Wrong target plus often wrong arch; case-by-case rewrite. |
The rows that say “Runs” or “Runs after re-derivation” are the urgent patch-now group for the public exploit. The rows that say “rewrite needed” are not safe — they’re vulnerable to a custom build, and that build is a few days of work for a competent operator. Patch all of them.
17. Patch precedence stays the same
Nothing in §15 or §16 changes the patch-precedence calculus from §1–§5: apply the kernel update from your distribution and reboot. The interim controls in §2 (module case) and §3 (built-in case) hold across all the targets in the §16 table, because they remove or suppress algif_aead itself rather than blocking any specific exploit shape. The seccomp profile from §7–§12 likewise applies regardless of arch or distro, because it intercepts the AF_ALG socket creation step that every variant of the exploit has to make first.
The portability discussion in this section is for triage and detection tuning, not for re-prioritising patches. A host the public PoC can’t touch today is a host a custom build can touch tomorrow.

