## https://sploitus.com/exploit?id=A3FC5F6F-DCD4-519A-85EE-45553BBD1171
# Copy Fail - Defense-in-Depth Primitives for CVE-2026-31431
Kernel-side audit probe and a userspace mitigation shim for the
`AF_ALG` / `authencesn` page-cache corruption bug, a.k.a. **Copy
Fail** (CVE-2026-31431).
| Primitive | File | Layer |
|---|---|---|
| Comprehensive detection / posture auditor | `copyfail-local-check.py` | userspace, read-only |
| `LD_PRELOAD` shim that blocks `AF_ALG` socket creation | `no-afalg.c` | userspace, glibc-resident |
Both are designed to be safe to fleet-deploy: read-only by design
(the auditor) or fail-open on any non-`AF_ALG` traffic (the shim).
---
## The bug, briefly
`AF_ALG`'s `authencesn(hmac(sha256),cbc(aes))` AEAD path miscomputes
output length on decrypt, returning more bytes to userspace than were
actually authenticated. When the destination of that decrypt is a pipe
spliced from the page cache of a SUID binary or privileged config
file, the kernel writes attacker-controlled bytes into clean
page-cache pages - visible to every subsequent reader, including
`execve()` of `/usr/bin/su`. No file on disk is changed; the
corruption lives in RAM until pages are evicted.
The result is a local privilege-escalation primitive that does not
require a kernel module to be loaded by the attacker (the relevant
crypto modules are auto-loaded by `socket(AF_ALG, ...)` itself on most
distributions) and that leaves no on-disk forensic artefacts.
---
## Defense-in-depth framing
No single layer is enough. The reasons each layer can be defeated are
the reasons all of them ship together:
1. **Kernel patch (vendor).** The only complete fix. Not always
available - EL7 is out of maintenance, and EL8/9/10 patch rollout
lags disclosure by days to weeks. Until then:
2. **`modprobe` blacklist of `algif_aead`, `authenc`, `authencesn`.**
Defeats the auto-load path. Bypassed if the modules are already
resident (e.g. another consumer pulled them in earlier in boot) or
if the kernel is built with these statically.
3. **systemd `RestrictAddressFamilies=` drop-in dropping `AF_ALG`
(`38`) from the global default.** Defeats *new* services started
by systemd. Bypassed by anything started outside systemd: cron
jobs, sshd login shells, container payloads with their own pid 1,
anything inheriting from a pre-restriction unit.
4. **`LD_PRELOAD` shim (this repo's `no-afalg.so`).** Defeats every
dynamically-linked process that goes through libc's `socket(2)` /
`socketpair(2)` - which is essentially every distro binary. **It
does not stop:** static binaries, processes that issue the syscall
instruction directly (`syscall(SYS_socket, AF_ALG, โฆ)` or inline
asm), or processes that disable `LD_PRELOAD` (setuid binaries
strip it; the kernel patch is the answer there, not a userspace
shim).
5. **seccomp filter at the systemd-unit or container-runtime level.**
The only userspace mechanism that catches the direct-syscall
bypass. Operationally heavier than the others; recommended for
high-value services that you can wrap individually (sshd,
cpsrvd, tomcat, etc.).
The auditor scores all five layers and tells you which are present,
which are stale, and which are bypassable on the current host. It
does **not** assume that any single layer is sufficient.
---
## Building and installing the LD_PRELOAD shim
`no-afalg.c` is single-file, no build system. Tested on EL7 (gcc 4.8 /
glibc 2.17), EL8 (gcc 8.5 / glibc 2.28), EL9 (gcc 11.5 / glibc 2.34),
and EL10 (gcc 14 / glibc 2.39). x86_64 only.
```sh
gcc -shared -fPIC -O2 -Wall -Wextra \
-o /usr/lib64/no-afalg.so no-afalg.c -ldl
```
Install system-wide (every dynamically-linked process inherits it):
```sh
echo /usr/lib64/no-afalg.so > /etc/ld.so.preload
```
Verify it took effect - quick one-liner:
```sh
python3 -c 'import socket; socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0)'
# Expect: PermissionError: [Errno 1] Operation not permitted
# Without the shim: succeeds silently and returns a socket object.
```
Or with `strace` to see the syscall return directly:
```sh
strace -e trace=socket python3 -c \
'import socket; socket.socket(38,5,0)' 2>&1 | grep AF_ALG
# Expect: socket(AF_ALG, SOCK_SEQPACKET, 0) = -1 EPERM
```
Blocked attempts log to `auth.priv`:
```
no-afalg[12345]: blocked AF_ALG (domain=38) via socket uid=0 euid=0 pid=12345
```
To remove:
```sh
rm /etc/ld.so.preload # or: edit out the line if other shims share the file
```
### What the shim deliberately does NOT do
It does not wrap `syscall(2)`. Reading six `long` varargs
unconditionally is undefined behaviour, and the bypasses it would
catch (`syscall(SYS_socket, AF_ALG, โฆ)` and inline-asm `syscall`
instruction) are unblockable from userspace anyway. Pair with seccomp
or the kernel patch for that surface.
---
## Using the auditor
```sh
./copyfail-local-check.py # human-readable, only flags non-OK
./copyfail-local-check.py --verbose # show passing checks too
./copyfail-local-check.py --json # SIEM ingestion (stdout = JSON)
./copyfail-local-check.py --skip-trigger # no live AF_ALG probe
./copyfail-local-check.py --skip-hardening # skip suid/page-cache audit
./copyfail-local-check.py --category KERNEL,MITIGATION
./copyfail-local-check.py --no-progress # suppress stderr progress line
```
Categories: `ENV`, `KERNEL`, `MITIGATION`, `HARDENING`, `DETECTION`.
### Exit codes
| Code | Meaning |
|---|---|
| 0 | Clean - no vulnerability, mitigations adequate |
| 1 | Test framework error |
| 2 | **VULNERABLE** - trigger or page-cache corruption confirmed, no mitigation |
| 3 | Vulnerable kernel but at least one userspace mitigation is active |
| 4 | Hardening recommendations only (no active exploitability) |
Designed for fleet rollout: parse `--json` exit + `summary.vuln`,
not the human report.
### Safety guarantees
- Writes only to `mkdtemp()` sentinel files; never touches `/usr/bin`,
`/etc`, or anything you care about.
- All page-cache reads are passive - no file contents are modified.
- The trigger probe targets a freshly-created sentinel, never a real
SUID binary.
- Runs unprivileged (some checks degrade gracefully without root).
---
## Why these checks?
The auditor is structured by attack-chain layer, not by checklist
convenience. Each category answers a specific question about the
current host.
### `KERNEL` - is the primitive actually reachable?
- **`AF_ALG` socket reachability.** Open `socket(AF_ALG, SOCK_SEQPACKET,
0)`. If `EAFNOSUPPORT`, the family is gone - done. If it succeeds,
the auto-load path works, which by itself is an exposure even
without the rest of the cipher chain.
- **`authencesn(hmac(sha256),cbc(aes))` cipher availability.** Bind
the algorithm name. Failure here means the specific composite
cipher driving the bug is not present on this kernel build.
- **`algif_aead` setsockopt acceptance.** Confirm the AEAD operation
state machine is reachable; needed because some downstream patches
refuse the cipher at bind() time but still allow the family.
- **Live trigger probe.** Mirrors the public PoC against a sentinel
file (never a SUID binary). This is the only check that produces a
*VULN* verdict on its own - kernel-version heuristics lie too often
on backported distros.
### `MITIGATION` - if the kernel is vulnerable, is anything stopping the bug?
Each check below corresponds to one rung of the defense-in-depth
ladder above and exists because the ladder above it is bypassable:
- **`/etc/ld.so.preload` contains `no-afalg.so`** - verifies the shim
is wired at the global linker level.
- **Shim live-blocks `AF_ALG`** - actually calls `socket(AF_ALG)` from
a child shell and confirms `EPERM`. Catches stale, deleted, or
ABI-broken shim binaries.
- **`modprobe` blacklist for `algif_aead`, `authenc`, `authencesn`**
- drops the auto-load path. Cross-checks `/etc/modprobe.d/*.conf`
against `/proc/modules` because a blacklist set after the modules
loaded is a no-op.
- **`/proc/modules` shows the modules absent** - independent of the
blacklist config; this is the runtime ground truth.
- **systemd `RestrictAddressFamilies=` drops `AF_ALG`** - parsed from
the *effective* unit configuration, with explicit handling for the
`~AF_ALG` (deny-list) and positive-list dialects, because both are
in use across distros.
- **Per-service drop-in freshness** - units that were loaded before
the drop-in landed are still permissive. The check compares unit
load time vs drop-in mtime and flags stale units that need
`daemon-reload` + restart.
- **seccomp filter active for the running PID** (when run as a
service wrapper) - catches the direct-syscall bypass at a
per-process granularity.
### `HARDENING` - if mitigation fails, what does the blast radius look like?
- **SUID binary inventory** with `find / -perm -4000`, recorded so
that a future delta against this output is meaningful even after
a successful exploit (the bug doesn't change file contents, but it
changes *which* SUID binaries an attacker would target).
- **Page-cache integrity sample.** For each `PRIV_CONFIG_FILES` /
`SUID_BINARIES` entry: hash the file via the page cache (`read`)
and via direct I/O (`O_DIRECT`) and compare. Divergence = the bug
is not just present, it has already been triggered.
- **File capabilities** - `getcap -r` over `/usr/bin`, `/usr/sbin`,
`/usr/local`, `/opt`. Files with `cap_setuid` etc. are
hijack-equivalent to SUID and need the same audit.
### `DETECTION` - would we know if someone tried this?
- **`auditd` running, with rules covering `socket(AF_ALG)`** - the
one syscall-level signature of the bug that survives userspace
evasion. The check confirms both that auditd is up and that a
rule actually targets `a0=38` (the `AF_ALG` constant).
- **Recent IOC signals** - short window scan of `auth.priv` for
`no-afalg blocked` lines (forward-leaning IOC: a process *tried*
to open `AF_ALG` and was stopped) and of audit log for matching
`socket(38, โฆ)` records.
### `ENV` - does the script have what it needs to give a meaningful answer?
Architecture, kernel version, glibc version, Python version, root
status. Surfaces "skip" reasons up front so an unhelpful run doesn't
look like a clean run.
---
## Limitations
- x86_64 only. The `AF_ALG` constant is universal but the LD_PRELOAD
shim has architecture asserts and the trigger probe's struct layout
is tested only on x86_64. Patches welcome for arm64.
- The userspace shim is irrelevant to static binaries and
syscall-instruction issuers. This is a known design boundary, not
a bug - see "What the shim deliberately does NOT do" above.
- `modprobe` blacklists do not unload already-resident modules. If
`/proc/modules` shows them present after a blacklist edit, you
need a reboot or `rmmod`.
- The trigger probe is destructive *only* against its own sentinel.
It will not corrupt anything you would notice. It will, however,
briefly load `algif_aead` and friends if they aren't already loaded
- which is the point.
---
## License
GPL v2. See `LICENSE`.