From 996c8dc826c91bcce46c8c0f936ca902b8b1f95c Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:02:39 +1000 Subject: [PATCH 1/2] fix(ghidra): port decompiler post-scripts to Java + follow symlinked installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the whole-app decompiler against a real Ghidra 12 install and found two problems, now fixed: - Ghidra 11.3+/12 route `.py` scripts through PyGhidra (CPython), which our headless invocation doesn't enable, so both post-scripts failed with "Ghidra was not started with PyGhidra." Rewrote PCDumpProgram and PCDecompileFunction as Java GhidraScripts, which Ghidra compiles itself — no Python/pip dependency, works on every Ghidra version. This also fixes the pre-existing single-function "Decompile" action on network call sites. - The install locator used the URL's `hasDirectoryPath`, which is false for a symlink, so it missed symlinked Ghidra installs (e.g. a Homebrew Ghidra linked into ~/Applications). It now follows symlinks via FileManager. Adds an opt-in integration test (PC_GHIDRA_INTEGRATION=1 + JAVA_HOME) that actually runs headless Ghidra and asserts real decompiled C for both the whole-app dump and the single-function path. Verified green against Ghidra 12.1.2 / JDK 21. Full suite 276 tests (3 integration skipped by default); app builds. Co-Authored-By: Claude Fable 5 --- .../Analysis/GhidraDecompiler.swift | 153 ++++++++------ .../Analysis/GhidraProgramDump.swift | 196 +++++++++++------- .../GhidraProgramDumpIntegrationTests.swift | 81 ++++++++ 3 files changed, 288 insertions(+), 142 deletions(-) create mode 100644 privacycommand/Tests/privacycommandCoreTests/GhidraProgramDumpIntegrationTests.swift diff --git a/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift b/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift index 507b163..eb405b4 100644 --- a/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift +++ b/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift @@ -138,10 +138,16 @@ public actor GhidraDecompiler { for root in roots { guard let entries = try? fm.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey]) else { continue } - for dir in entries - where dir.hasDirectoryPath - && dir.lastPathComponent.lowercased().hasPrefix("ghidra") { - let headless = dir.appendingPathComponent("support/analyzeHeadless") + for entry in entries + where entry.lastPathComponent.lowercased().hasPrefix("ghidra") { + // Resolve through symlinks: a very common install shape is a link + // into ~/Applications (or /opt) pointing at a Homebrew Ghidra, and + // the URL's `hasDirectoryPath` is `false` for a symlink entry — so + // use FileManager's symlink-following directory check instead. + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDirectory), + isDirectory.boolValue else { continue } + let headless = entry.appendingPathComponent("support/analyzeHeadless") if fm.isExecutableFile(atPath: headless.path) { return headless } } } @@ -264,70 +270,85 @@ public actor GhidraDecompiler { // MARK: - Embedded Ghidra post-script - static let postScriptName = "PCDecompileFunction.py" + static let postScriptName = "PCDecompileFunction.java" - /// Ghidra/Jython post-script. Run by `analyzeHeadless` after the program is - /// open; receives ` ` as script args, finds the - /// function (tolerating a leading-underscore mismatch), decompiles it, and + /// Ghidra post-script as a **Java** GhidraScript. Ghidra 11.3+/12 route + /// `.py` scripts through PyGhidra (CPython), which our headless launch + /// doesn't enable — so a `.py` script fails with "Ghidra was not started + /// with PyGhidra." A Java GhidraScript is compiled by Ghidra itself and + /// works on every version. Receives ` + /// [address-hex]`, finds the function (tolerating a leading-underscore + /// mismatch, falling back to the containing address), decompiles it, and /// writes the C — or a `DECOMPILE_*:` marker — to the output path. - static let postScriptSource = """ - # Auto-generated by privacycommand. Decompiles one function to C. - # Args: [address-hex] - from ghidra.app.decompiler import DecompInterface - from ghidra.util.task import ConsoleTaskMonitor - - def _write(path, text): - fh = open(path, "w") - try: - fh.write(text) - finally: - fh.close() - - args = getScriptArgs() - if len(args) < 2: - print("PCDecompile: expected [address-hex]") - else: - target = str(args[0]) - out_path = str(args[1]) - addr_hex = str(args[2]) if len(args) > 2 else "" - - # Ghidra may store the symbol with or without a leading underscore. - candidates = [target] - if target.startswith("_"): - candidates.append(target[1:]) - else: - candidates.append("_" + target) - - fm = currentProgram.getFunctionManager() - found = None - it = fm.getFunctions(True) - while it.hasNext(): - f = it.next() - if str(f.getName()) in candidates: - found = f - break - - # Fall back to address when the symbol isn't in Ghidra's view (stripped - # or differently-named functions, e.g. FUN_0001abcd). - if found is None and addr_hex != "": - try: - space = currentProgram.getAddressFactory().getDefaultAddressSpace() - found = fm.getFunctionContaining(space.getAddress(long(addr_hex, 16))) - except: - found = None - - if found is None: - _write(out_path, "DECOMPILE_NOT_FOUND: " + target) - else: - decomp = DecompInterface() - decomp.openProgram(currentProgram) - res = decomp.decompileFunction(found, 60, ConsoleTaskMonitor()) - if res is not None and res.decompileCompleted(): - _write(out_path, res.getDecompiledFunction().getC()) - else: - err = res.getErrorMessage() if res is not None else "no result" - _write(out_path, "DECOMPILE_FAILED: " + str(err)) - """ + static let postScriptSource = #""" + // Auto-generated by privacycommand. Decompiles one function to C. + // Args: [address-hex] + import ghidra.app.script.GhidraScript; + import ghidra.app.decompiler.DecompInterface; + import ghidra.app.decompiler.DecompileResults; + import ghidra.program.model.address.Address; + import ghidra.program.model.listing.Function; + import ghidra.program.model.listing.FunctionIterator; + import ghidra.program.model.listing.FunctionManager; + import ghidra.util.task.ConsoleTaskMonitor; + import java.io.FileWriter; + import java.io.PrintWriter; + + public class PCDecompileFunction extends GhidraScript { + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 2) { + println("PCDecompile: expected [address-hex]"); + return; + } + String target = args[0]; + String outPath = args[1]; + String addrHex = args.length > 2 ? args[2] : ""; + + // Ghidra may store the symbol with or without a leading underscore. + String alt = target.startsWith("_") ? target.substring(1) : ("_" + target); + + FunctionManager fm = currentProgram.getFunctionManager(); + Function found = null; + FunctionIterator it = fm.getFunctions(true); + while (it.hasNext()) { + Function f = it.next(); + String name = f.getName(); + if (name.equals(target) || name.equals(alt)) { found = f; break; } + } + + // Fall back to the function containing an address when the symbol + // isn't in Ghidra's view (stripped / renamed, e.g. FUN_0001abcd). + if (found == null && !addrHex.isEmpty()) { + try { + Address a = currentProgram.getAddressFactory() + .getDefaultAddressSpace().getAddress(Long.parseUnsignedLong(addrHex, 16)); + found = fm.getFunctionContaining(a); + } catch (Exception e) { found = null; } + } + + if (found == null) { + write(outPath, "DECOMPILE_NOT_FOUND: " + target); + return; + } + DecompInterface decomp = new DecompInterface(); + decomp.openProgram(currentProgram); + DecompileResults res = decomp.decompileFunction(found, 60, new ConsoleTaskMonitor()); + if (res != null && res.decompileCompleted()) { + write(outPath, res.getDecompiledFunction().getC()); + } else { + write(outPath, "DECOMPILE_FAILED: " + (res != null ? res.getErrorMessage() : "no result")); + } + } + + private void write(String path, String text) throws Exception { + PrintWriter w = new PrintWriter(new FileWriter(path)); + w.print(text); + w.close(); + } + } + """# } /// Lock-protected stdout/stderr accumulator for `runHeadless` — the readability diff --git a/privacycommand/Sources/privacycommandCore/Analysis/GhidraProgramDump.swift b/privacycommand/Sources/privacycommandCore/Analysis/GhidraProgramDump.swift index 56b2bd7..cd96f15 100644 --- a/privacycommand/Sources/privacycommandCore/Analysis/GhidraProgramDump.swift +++ b/privacycommand/Sources/privacycommandCore/Analysis/GhidraProgramDump.swift @@ -161,80 +161,124 @@ public actor GhidraProgramDump { // MARK: - Embedded Ghidra post-script - static let dumpScriptName = "PCDumpProgram.py" - - /// Ghidra/Jython post-script. Enumerates every function, filters by scope, - /// decompiles each to C, and writes one JSON object per line to the output - /// path — plus a final `{"__meta__": …}` summary. Skips external functions - /// and thunks (no meaningful body). Reuses one `DecompInterface` so the - /// analysis cost isn't repaid per function. - static let dumpScriptSource = """ - # Auto-generated by privacycommand. Dumps decompiled functions to JSON Lines. - # Args: - from ghidra.app.decompiler import DecompInterface - from ghidra.util.task import ConsoleTaskMonitor - import json - - args = getScriptArgs() - if len(args) < 3: - print("PCDump: expected ") - else: - out_path = str(args[0]) - scope = str(args[1]) - try: - cap = int(str(args[2])) - except: - cap = 1500 - - fh = open(out_path, "w") - try: - decomp = DecompInterface() - decomp.openProgram(currentProgram) - monitor = ConsoleTaskMonitor() - fm = currentProgram.getFunctionManager() - it = fm.getFunctions(True) - count = 0 - truncated = False - while it.hasNext(): - f = it.next() - if f.isExternal() or f.isThunk(): - continue - ns = f.getParentNamespace() - is_global = ns.isGlobal() - if scope == "namedClasses" and is_global: - continue - if count >= cap: - truncated = True - break - cls = None if is_global else str(ns.getName(True)) - try: - sig = str(f.getPrototypeString(False, False)) - except: - sig = None - entry = str(f.getEntryPoint()) - c_code = "" - try: - res = decomp.decompileFunction(f, 45, monitor) - if res is not None and res.decompileCompleted(): - df = res.getDecompiledFunction() - if df is not None: - c_code = df.getC() - except: - c_code = "" - rec = {"class": cls, "function": str(f.getName()), - "signature": sig, "entryHex": entry, "c": c_code} - fh.write(json.dumps(rec)) - fh.write("\\n") - count += 1 - meta = {"__meta__": True, "truncated": truncated, "functionCount": count} - fh.write(json.dumps(meta)) - fh.write("\\n") - except Exception as e: - try: - fh.write("DUMP_ERROR: " + str(e) + "\\n") - except: - pass - finally: - fh.close() - """ + static let dumpScriptName = "PCDumpProgram.java" + + /// Ghidra post-script as a **Java** GhidraScript (not Python). Ghidra 11.3+ + /// / 12.x route `.py` scripts through PyGhidra (CPython), which needs a + /// separate `pip install pyghidra` and a PyGhidra-enabled launch our + /// headless invocation doesn't do — so a `.py` script fails with "Ghidra was + /// not started with PyGhidra." A Java GhidraScript is compiled by Ghidra + /// itself and works on every version, no Python required. + /// + /// Enumerates every function, filters by scope, decompiles each to C, and + /// writes one JSON object per line plus a final `{"__meta__": …}` summary. + /// Skips external functions and thunks. Reuses one `DecompInterface`. + static let dumpScriptSource = #""" + // Auto-generated by privacycommand. Dumps decompiled functions to JSON Lines. + // Args: + import ghidra.app.script.GhidraScript; + import ghidra.app.decompiler.DecompInterface; + import ghidra.app.decompiler.DecompileResults; + import ghidra.app.decompiler.DecompiledFunction; + import ghidra.program.model.listing.Function; + import ghidra.program.model.listing.FunctionIterator; + import ghidra.program.model.listing.FunctionManager; + import ghidra.program.model.symbol.Namespace; + import ghidra.util.task.ConsoleTaskMonitor; + import java.io.FileWriter; + import java.io.PrintWriter; + + public class PCDumpProgram extends GhidraScript { + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 3) { + println("PCDump: expected "); + return; + } + String outPath = args[0]; + String scope = args[1]; + int cap; + try { cap = Integer.parseInt(args[2]); } catch (Exception e) { cap = 1500; } + + PrintWriter fh = null; + try { + fh = new PrintWriter(new FileWriter(outPath)); + DecompInterface decomp = new DecompInterface(); + decomp.openProgram(currentProgram); + ConsoleTaskMonitor monitor = new ConsoleTaskMonitor(); + FunctionManager fm = currentProgram.getFunctionManager(); + FunctionIterator it = fm.getFunctions(true); + int count = 0; + boolean truncated = false; + while (it.hasNext()) { + Function f = it.next(); + if (f.isExternal() || f.isThunk()) continue; + Namespace ns = f.getParentNamespace(); + boolean isGlobal = ns.isGlobal(); + if (scope.equals("namedClasses") && isGlobal) continue; + if (count >= cap) { truncated = true; break; } + String cls = isGlobal ? null : ns.getName(true); + String sig; + try { sig = f.getPrototypeString(false, false); } catch (Exception e) { sig = null; } + String entry = f.getEntryPoint().toString(); + String cCode = ""; + try { + DecompileResults res = decomp.decompileFunction(f, 45, monitor); + if (res != null && res.decompileCompleted()) { + DecompiledFunction df = res.getDecompiledFunction(); + if (df != null) cCode = df.getC(); + } + } catch (Exception e) { cCode = ""; } + fh.println(obj(cls, f.getName(), sig, entry, cCode)); + count++; + } + fh.println("{\"__meta__\": true, \"truncated\": " + (truncated ? "true" : "false") + + ", \"functionCount\": " + count + "}"); + } catch (Exception e) { + if (fh != null) { + fh.println("DUMP_ERROR: " + e.getMessage()); + } else { + try { + PrintWriter ew = new PrintWriter(new FileWriter(outPath)); + ew.println("DUMP_ERROR: " + e.getMessage()); + ew.close(); + } catch (Exception ignore) {} + } + } finally { + if (fh != null) fh.close(); + } + } + + // Build one JSON object; null class/signature emit JSON null. + private String obj(String cls, String func, String sig, String entry, String c) { + return "{\"class\": " + val(cls) + + ", \"function\": " + val(func) + + ", \"signature\": " + val(sig) + + ", \"entryHex\": " + val(entry) + + ", \"c\": " + val(c) + "}"; + } + + // Minimal JSON string encoder with proper escaping. + private String val(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + switch (ch) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (ch < 0x20) sb.append(String.format("\\u%04x", (int) ch)); + else sb.append(ch); + } + } + sb.append("\""); + return sb.toString(); + } + } + """# } diff --git a/privacycommand/Tests/privacycommandCoreTests/GhidraProgramDumpIntegrationTests.swift b/privacycommand/Tests/privacycommandCoreTests/GhidraProgramDumpIntegrationTests.swift new file mode 100644 index 0000000..46a9942 --- /dev/null +++ b/privacycommand/Tests/privacycommandCoreTests/GhidraProgramDumpIntegrationTests.swift @@ -0,0 +1,81 @@ +import XCTest +#if SWIFT_PACKAGE +@testable import privacycommandCore +#else +@testable import privacycommand +#endif + +/// **Opt-in integration test** — actually runs Ghidra headless, so it's skipped +/// unless `PC_GHIDRA_INTEGRATION=1` is set *and* a Ghidra install is +/// discoverable. It needs a JDK reachable by Ghidra's launcher, so run it with +/// `JAVA_HOME` pointing at a JDK, e.g.: +/// +/// PC_GHIDRA_INTEGRATION=1 \ +/// JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ +/// swift test --filter GhidraProgramDumpIntegrationTests +/// +/// This is the one test that exercises the real `PCDumpProgram.py` under a +/// genuine Ghidra — everything else about the dump is unit-tested against +/// fixture output. +final class GhidraProgramDumpIntegrationTests: XCTestCase { + + private func requireGhidra() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["PC_GHIDRA_INTEGRATION"] == "1", + "set PC_GHIDRA_INTEGRATION=1 (and JAVA_HOME) to run the Ghidra integration test") + try XCTSkipUnless(GhidraProgramDump.isAvailable(), + "no Ghidra install found in the standard search roots") + } + + /// Decompile a tiny real Mach-O with `.everything` and assert we got real C + /// back. Proves: analyzeHeadless invocation, the materialised Jython script, + /// its JSON-Lines output, and the parser — end to end. + func testEverythingScopeProducesRealC() async throws { + try requireGhidra() + let index = try await GhidraProgramDump().dump( + binary: URL(fileURLWithPath: "/bin/echo"), + scope: .everything(cap: 15), + timeout: 900) + + XCTAssertEqual(index.source, .ghidra) + XCTAssertGreaterThan(index.functionCount, 0, "should decompile at least one function") + let functions = index.classes.flatMap(\.functions) + XCTAssertTrue(functions.contains { !$0.cCode.isEmpty }, + "at least one function should have decompiled C") + XCTAssertTrue(functions.contains { $0.cCode.contains("{") }, + "decompiled C should look like C") + } + + /// `.namedClasses` on the same (now-analysed, cached) binary must not crash + /// and must return a valid index. A pure-C binary has no named classes, so + /// an empty-but-valid result is the expected, correct outcome — the point is + /// that the namespace-filter branch of the script runs cleanly. + func testNamedClassesScopeRunsCleanly() async throws { + try requireGhidra() + let index = try await GhidraProgramDump().dump( + binary: URL(fileURLWithPath: "/bin/echo"), + scope: .namedClasses, + timeout: 900) + XCTAssertEqual(index.source, .ghidra) + XCTAssertFalse(index.truncated) + // classCount may legitimately be 0 for a C binary — no assertion needed. + } + + /// Also exercise the single-function decompiler's Java script (used by the + /// network call-site "Decompile" action): dump to find a real function, + /// then decompile it by name + address and assert we get C, not a marker. + func testSingleFunctionDecompileProducesC() async throws { + try requireGhidra() + let binary = URL(fileURLWithPath: "/bin/echo") + let index = try await GhidraProgramDump().dump( + binary: binary, scope: .everything(cap: 8), timeout: 900) + let target = try XCTUnwrap(index.classes.flatMap(\.functions).first { !$0.cCode.isEmpty }, + "need at least one decompiled function to target") + + let decompiled = try await GhidraDecompiler().decompile( + binary: binary, function: target.name, address: target.entryHex, timeout: 900) + + XCTAssertFalse(decompiled.cCode.isEmpty) + XCTAssertFalse(decompiled.cCode.hasPrefix("DECOMPILE_"), + "should be real C, not a not-found/failed marker") + } +} From 2a07158dc7b1046ea996a3fdf46fc3a3b3815020 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:10:46 +1000 Subject: [PATCH 2/2] fix(ghidra): auto-discover a JDK so headless Ghidra works from the GUI app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Finder-launched .app has no JAVA_HOME and no TTY, so Ghidra's analyzeHeadless couldn't find a JDK and couldn't prompt for one — it died with "Unable to locate a Java Runtime", making the decompile features look broken in the app even though the logic is correct. GhidraDecompiler now locates a JDK itself (via /usr/libexec/java_home, then Homebrew's keg-only openjdk under /opt/homebrew/opt and /usr/local/opt) and sets JAVA_HOME on the Ghidra process when it isn't already set. Applies to both the whole-app dump and the single-function decompile (shared runHeadless). Verified: the Ghidra integration tests pass with NO JAVA_HOME in the environment (i.e. the app self-discovers the JDK the way it must for a Finder-launched app). Co-Authored-By: Claude Fable 5 --- .../Analysis/GhidraDecompiler.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift b/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift index eb405b4..cebe404 100644 --- a/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift +++ b/privacycommand/Sources/privacycommandCore/Analysis/GhidraDecompiler.swift @@ -162,6 +162,35 @@ public actor GhidraDecompiler { fm.homeDirectoryForCurrentUser.appendingPathComponent("Tools")] } + /// Locate a JDK home to hand Ghidra. Ghidra's `analyzeHeadless` needs a JDK, + /// but a Finder-launched app has no `JAVA_HOME` and no TTY, so Ghidra can't + /// find or prompt for one and dies with "Unable to locate a Java Runtime". + /// We look it up ourselves and set `JAVA_HOME` on the process (see + /// `runHeadless`). Returns `nil` if no JDK is found — Ghidra then fails with + /// its own message, surfaced as a `headlessError`. + nonisolated static func locateJDKHome(fileManager fm: FileManager = .default) -> String? { + // 1. The canonical macOS locator — finds a system / Oracle / Temurin JDK. + let javaHome = ProcessRunner.runSync( + launchPath: "/usr/libexec/java_home", arguments: [], timeout: 5) + if javaHome.exitCode == 0 { + let path = javaHome.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty, fm.isExecutableFile(atPath: path + "/bin/java") { return path } + } + // 2. Homebrew's openjdk is keg-only — not on PATH, invisible to + // java_home — so check its opt prefixes directly, newest first. + for root in ["/opt/homebrew/opt", "/usr/local/opt"] { + guard let entries = try? fm.contentsOfDirectory(atPath: root) else { continue } + let candidates = entries + .filter { $0 == "openjdk" || $0.hasPrefix("openjdk@") } + .sorted().reversed() + for name in candidates { + let home = "\(root)/\(name)/libexec/openjdk.jdk/Contents/Home" + if fm.isExecutableFile(atPath: home + "/bin/java") { return home } + } + } + return nil + } + // MARK: - Output parsing (deterministic — unit-tested) /// Turn the post-script's output file into a `Decompilation` or a typed @@ -231,6 +260,15 @@ public actor GhidraDecompiler { let process = Process() process.executableURL = URL(fileURLWithPath: launchPath) process.arguments = arguments + // Ghidra needs a JDK. If the app wasn't launched with JAVA_HOME set + // (the usual case for a Finder-launched .app), discover one and hand it + // over — otherwise Ghidra can't find Java and can't prompt (no TTY). + if ProcessInfo.processInfo.environment["JAVA_HOME"] == nil, + let jdkHome = locateJDKHome() { + var environment = ProcessInfo.processInfo.environment + environment["JAVA_HOME"] = jdkHome + process.environment = environment + } let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe