Move to Hydra

This commit is contained in:
Teal Bauer 2025-03-29 18:11:19 +01:00
parent bb10207b84
commit 76e8355252
12 changed files with 247 additions and 102 deletions

View File

@ -7,134 +7,184 @@
# /// # ///
import sys import sys
import requests import requests
from typing import Dict
from threading import Lock
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
DEFAULT_GHIDRA_SERVER = "http://127.0.0.1:8080/" # Track active Ghidra instances (port -> url)
ghidra_server_url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_GHIDRA_SERVER active_instances: Dict[int, str] = {}
instances_lock = Lock()
DEFAULT_GHIDRA_PORT = 8192
DEFAULT_GHIDRA_HOST = "localhost"
mcp = FastMCP("ghidra-mcp") mcp = FastMCP("hydra-mcp")
def safe_get(endpoint: str, params: dict = None) -> list: # Get host from command line or use default
""" ghidra_host = DEFAULT_GHIDRA_HOST
Perform a GET request with optional query parameters. if len(sys.argv) > 1:
""" ghidra_host = sys.argv[1]
print(f"Using Ghidra host: {ghidra_host}")
def get_instance_url(port: int) -> str:
"""Get URL for a Ghidra instance by port"""
with instances_lock:
if port in active_instances:
return active_instances[port]
# Auto-register if not found but port is valid
if 8192 <= port <= 65535:
register_instance(port)
return active_instances[port]
return f"http://{ghidra_host}:{port}"
def safe_get(port: int, endpoint: str, params: dict = None) -> list:
"""Perform a GET request to a specific Ghidra instance"""
if params is None: if params is None:
params = {} params = {}
url = f"{ghidra_server_url}/{endpoint}" url = f"{get_instance_url(port)}/{endpoint}"
try: try:
response = requests.get(url, params=params, timeout=5) response = requests.get(url, params=params, timeout=5)
response.encoding = 'utf-8' response.encoding = 'utf-8'
if response.ok: if response.ok:
return response.text.splitlines() return response.text.splitlines()
elif response.status_code == 404:
# Try falling back to default instance if this was a secondary instance
if port != DEFAULT_GHIDRA_PORT:
return safe_get(DEFAULT_GHIDRA_PORT, endpoint, params)
return [f"Error {response.status_code}: {response.text.strip()}"]
else: else:
return [f"Error {response.status_code}: {response.text.strip()}"] return [f"Error {response.status_code}: {response.text.strip()}"]
except requests.exceptions.ConnectionError:
# Instance may be down - try default instance if this was secondary
if port != DEFAULT_GHIDRA_PORT:
return safe_get(DEFAULT_GHIDRA_PORT, endpoint, params)
return ["Error: Failed to connect to Ghidra instance"]
except Exception as e: except Exception as e:
return [f"Request failed: {str(e)}"] return [f"Request failed: {str(e)}"]
def safe_post(endpoint: str, data: dict | str) -> str: def safe_post(port: int, endpoint: str, data: dict | str) -> str:
"""Perform a POST request to a specific Ghidra instance"""
try: try:
url = f"{get_instance_url(port)}/{endpoint}"
if isinstance(data, dict): if isinstance(data, dict):
response = requests.post(f"{ghidra_server_url}/{endpoint}", data=data, timeout=5) response = requests.post(url, data=data, timeout=5)
else: else:
response = requests.post(f"{ghidra_server_url}/{endpoint}", data=data.encode("utf-8"), timeout=5) response = requests.post(url, data=data.encode("utf-8"), timeout=5)
response.encoding = 'utf-8' response.encoding = 'utf-8'
if response.ok: if response.ok:
return response.text.strip() return response.text.strip()
elif response.status_code == 404 and port != DEFAULT_GHIDRA_PORT:
# Try falling back to default instance
return safe_post(DEFAULT_GHIDRA_PORT, endpoint, data)
else: else:
return f"Error {response.status_code}: {response.text.strip()}" return f"Error {response.status_code}: {response.text.strip()}"
except requests.exceptions.ConnectionError:
if port != DEFAULT_GHIDRA_PORT:
return safe_post(DEFAULT_GHIDRA_PORT, endpoint, data)
return "Error: Failed to connect to Ghidra instance"
except Exception as e: except Exception as e:
return f"Request failed: {str(e)}" return f"Request failed: {str(e)}"
# Instance management tools
@mcp.tool() @mcp.tool()
def list_methods(offset: int = 0, limit: int = 100) -> list: def list_instances() -> dict:
""" """List all active Ghidra instances"""
List all function names in the program with pagination. with instances_lock:
""" return {
return safe_get("methods", {"offset": offset, "limit": limit}) "instances": [
{"port": port, "url": url}
for port, url in active_instances.items()
]
}
@mcp.tool() @mcp.tool()
def list_classes(offset: int = 0, limit: int = 100) -> list: def register_instance(port: int, url: str = None) -> str:
""" """Register a new Ghidra instance"""
List all namespace/class names in the program with pagination. if url is None:
""" url = f"http://{ghidra_host}:{port}"
return safe_get("classes", {"offset": offset, "limit": limit})
# Verify instance is reachable before registering
try:
test_url = f"{url}/instances"
response = requests.get(test_url, timeout=2)
if not response.ok:
return f"Error: Instance at {url} is not responding properly"
except Exception as e:
return f"Error: Could not connect to instance at {url}: {str(e)}"
with instances_lock:
active_instances[port] = url
return f"Registered instance on port {port} at {url}"
@mcp.tool() @mcp.tool()
def decompile_function(name: str) -> str: def unregister_instance(port: int) -> str:
""" """Unregister a Ghidra instance"""
Decompile a specific function by name and return the decompiled C code. with instances_lock:
""" if port in active_instances:
return safe_post("decompile", name) del active_instances[port]
return f"Unregistered instance on port {port}"
return f"No instance found on port {port}"
# Updated tool implementations with port parameter
@mcp.tool()
def list_methods(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
return safe_get(port, "methods", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def rename_function(old_name: str, new_name: str) -> str: def list_classes(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
""" return safe_get(port, "classes", {"offset": offset, "limit": limit})
Rename a function by its current name to a new user-defined name.
"""
return safe_post("renameFunction", {"oldName": old_name, "newName": new_name})
@mcp.tool() @mcp.tool()
def rename_data(address: str, new_name: str) -> str: def decompile_function(port: int = DEFAULT_GHIDRA_PORT, name: str = "") -> str:
""" return safe_post(port, "decompile", name)
Rename a data label at the specified address.
"""
return safe_post("renameData", {"address": address, "newName": new_name})
@mcp.tool() @mcp.tool()
def list_segments(offset: int = 0, limit: int = 100) -> list: def rename_function(port: int = DEFAULT_GHIDRA_PORT, old_name: str = "", new_name: str = "") -> str:
""" return safe_post(port, "renameFunction", {"oldName": old_name, "newName": new_name})
List all memory segments in the program with pagination.
"""
return safe_get("segments", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def list_imports(offset: int = 0, limit: int = 100) -> list: def rename_data(port: int = DEFAULT_GHIDRA_PORT, address: str = "", new_name: str = "") -> str:
""" return safe_post(port, "renameData", {"address": address, "newName": new_name})
List imported symbols in the program with pagination.
"""
return safe_get("imports", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def list_exports(offset: int = 0, limit: int = 100) -> list: def list_segments(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
""" return safe_get(port, "segments", {"offset": offset, "limit": limit})
List exported functions/symbols with pagination.
"""
return safe_get("exports", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def list_namespaces(offset: int = 0, limit: int = 100) -> list: def list_imports(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
""" return safe_get(port, "imports", {"offset": offset, "limit": limit})
List all non-global namespaces in the program with pagination.
"""
return safe_get("namespaces", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def list_data_items(offset: int = 0, limit: int = 100) -> list: def list_exports(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
""" return safe_get(port, "exports", {"offset": offset, "limit": limit})
List defined data labels and their values with pagination.
"""
return safe_get("data", {"offset": offset, "limit": limit})
@mcp.tool() @mcp.tool()
def search_functions_by_name(query: str, offset: int = 0, limit: int = 100) -> list: def list_namespaces(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
""" return safe_get(port, "namespaces", {"offset": offset, "limit": limit})
Search for functions whose name contains the given substring.
""" @mcp.tool()
def list_data_items(port: int = DEFAULT_GHIDRA_PORT, offset: int = 0, limit: int = 100) -> list:
return safe_get(port, "data", {"offset": offset, "limit": limit})
@mcp.tool()
def search_functions_by_name(port: int = DEFAULT_GHIDRA_PORT, query: str = "", offset: int = 0, limit: int = 100) -> list:
if not query: if not query:
return ["Error: query string is required"] return ["Error: query string is required"]
return safe_get("searchFunctions", {"query": query, "offset": offset, "limit": limit}) return safe_get(port, "searchFunctions", {"query": query, "offset": offset, "limit": limit})
# Handle graceful shutdown
import signal import signal
import os import os
def handle_sigint(signum, frame): def handle_sigint(signum, frame):
os._exit(0) os._exit(0)
if __name__ == "__main__": if __name__ == "__main__":
# Auto-register default instance
register_instance(DEFAULT_GHIDRA_PORT, f"http://{ghidra_host}:{DEFAULT_GHIDRA_PORT}")
signal.signal(signal.SIGINT, handle_sigint) signal.signal(signal.SIGINT, handle_sigint)
mcp.run() mcp.run()

BIN
lib/Base.jar Executable file

Binary file not shown.

BIN
lib/Decompiler.jar Executable file

Binary file not shown.

BIN
lib/Docking.jar Executable file

Binary file not shown.

BIN
lib/Generic.jar Executable file

Binary file not shown.

BIN
lib/Project.jar Executable file

Binary file not shown.

BIN
lib/SoftwareModeling.jar Executable file

Binary file not shown.

BIN
lib/Utility.jar Executable file

Binary file not shown.

35
pom.xml
View File

@ -4,11 +4,11 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.lauriewired</groupId> <groupId>com.lauriewired</groupId>
<artifactId>GhidraMCP</artifactId> <artifactId>HydraMCP</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
<version>1.0-SNAPSHOT</version> <version>1.1</version>
<name>GhidraMCP</name> <name>HydraMCP</name>
<url>http://maven.apache.org</url> <url>https://github.com/LaurieWired/GhidraMCP</url>
<dependencies> <dependencies>
<!-- Ghidra JARs as system-scoped dependencies --> <!-- Ghidra JARs as system-scoped dependencies -->
@ -71,18 +71,29 @@
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<!-- Use custom MANIFEST.MF --> <!-- Set Java version -->
<plugin> <plugin>
<artifactId>maven-jar-plugin</artifactId> <groupId>org.apache.maven.plugins</groupId>
<version>3.2.2</version> <artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
</configuration>
</plugin>
<!-- Use custom MANIFEST.MF -->
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.2</version>
<configuration> <configuration>
<archive> <archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile> <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive> </archive>
<!-- Set a fixed name for the JAR without version --> <!-- Set a fixed name for the JAR without version -->
<finalName>GhidraMCP</finalName> <finalName>HydraMCP</finalName>
<!-- Exclude the App class --> <!-- Exclude the App class -->
<excludes> <excludes>
<exclude>**/App.class</exclude> <exclude>**/App.class</exclude>
@ -104,7 +115,7 @@
</descriptors> </descriptors>
<!-- The name of the final zip --> <!-- The name of the final zip -->
<finalName>GhidraMCP-${project.version}</finalName> <finalName>HydraMCP-${project.version}</finalName>
<!-- Don't append the assembly ID --> <!-- Don't append the assembly ID -->
<appendAssemblyId>false</appendAssemblyId> <appendAssemblyId>false</appendAssemblyId>

View File

@ -24,17 +24,17 @@
<include>extension.properties</include> <include>extension.properties</include>
<include>Module.manifest</include> <include>Module.manifest</include>
</includes> </includes>
<outputDirectory>GhidraMCP</outputDirectory> <outputDirectory>HydraMCP</outputDirectory>
</fileSet> </fileSet>
<!-- 2) Copy your built plugin JAR into GhidraMCP/lib --> <!-- 2) Copy your built plugin JAR into HydraMCP/lib -->
<fileSet> <fileSet>
<directory>${project.build.directory}</directory> <directory>${project.build.directory}</directory>
<includes> <includes>
<!-- Use the finalized JAR name from the maven-jar-plugin --> <!-- Use the finalized JAR name from the maven-jar-plugin -->
<include>GhidraMCP.jar</include> <include>HydraMCP.jar</include>
</includes> </includes>
<outputDirectory>GhidraMCP/lib</outputDirectory> <outputDirectory>HydraMCP/lib</outputDirectory>
</fileSet> </fileSet>
</fileSets> </fileSets>
</assembly> </assembly>

View File

@ -1,7 +1,8 @@
package com.lauriewired; package com.lauriewired;
import ghidra.framework.plugintool.Plugin; import ghidra.framework.plugintool.*;
import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.util.*;
import ghidra.framework.main.ApplicationLevelPlugin;
import ghidra.program.model.address.Address; import ghidra.program.model.address.Address;
import ghidra.program.model.address.GlobalNamespace; import ghidra.program.model.address.GlobalNamespace;
import ghidra.program.model.listing.*; import ghidra.program.model.listing.*;
@ -24,9 +25,11 @@ import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
@PluginInfo( @PluginInfo(
status = PluginStatus.RELEASED, status = PluginStatus.RELEASED,
@ -35,23 +38,47 @@ import java.util.concurrent.atomic.AtomicBoolean;
shortDescription = "HTTP server plugin", shortDescription = "HTTP server plugin",
description = "Starts an embedded HTTP server to expose program data." description = "Starts an embedded HTTP server to expose program data."
) )
public class GhidraMCPPlugin extends Plugin { public class HydraMCPPlugin extends Plugin implements ApplicationLevelPlugin {
private static final Map<Integer, HydraMCPPlugin> activeInstances = new ConcurrentHashMap<>();
private static final AtomicInteger nextPort = new AtomicInteger(8192);
private static final Object baseInstanceLock = new Object();
private HttpServer server; private HttpServer server;
private int port;
private boolean isBaseInstance = false;
public GhidraMCPPlugin(PluginTool tool) { public HydraMCPPlugin(PluginTool tool) {
super(tool); super(tool);
Msg.info(this, "GhidraMCPPlugin loaded!");
// Find available port
this.port = findAvailablePort();
activeInstances.put(port, this);
// Check if we should be base instance
synchronized (baseInstanceLock) {
if (port == 8192 || activeInstances.get(8192) == null) {
this.isBaseInstance = true;
Msg.info(this, "Starting as base instance on port " + port);
}
}
// Log to both console and log file
Msg.info(this, "HydraMCPPlugin loaded on port " + port);
System.out.println("[HydraMCP] Plugin loaded on port " + port);
try { try {
startServer(); startServer();
} } catch (IOException e) {
catch (IOException e) { Msg.error(this, "Failed to start HTTP server on port " + port, e);
Msg.error(this, "Failed to start HTTP server", e); if (e.getMessage().contains("Address already in use")) {
Msg.showError(this, null, "Port Conflict",
"Port " + port + " is already in use. Please specify a different port with -Dghidra.mcp.port=NEW_PORT");
}
} }
} }
private void startServer() throws IOException { private void startServer() throws IOException {
int port = 8080;
server = HttpServer.create(new InetSocketAddress(port), 0); server = HttpServer.create(new InetSocketAddress(port), 0);
// Each listing endpoint uses offset & limit from query params: // Each listing endpoint uses offset & limit from query params:
@ -130,11 +157,44 @@ public class GhidraMCPPlugin extends Plugin {
sendResponse(exchange, searchFunctionsByName(searchTerm, offset, limit)); sendResponse(exchange, searchFunctionsByName(searchTerm, offset, limit));
}); });
// Instance management endpoints
server.createContext("/instances", exchange -> {
StringBuilder sb = new StringBuilder();
for (Map.Entry<Integer, HydraMCPPlugin> entry : activeInstances.entrySet()) {
sb.append(entry.getKey()).append(": ")
.append(entry.getValue().isBaseInstance ? "base" : "secondary")
.append("\n");
}
sendResponse(exchange, sb.toString());
});
server.createContext("/registerInstance", exchange -> {
Map<String, String> params = parsePostParams(exchange);
int port = parseIntOrDefault(params.get("port"), 0);
if (port > 0) {
sendResponse(exchange, "Instance registered on port " + port);
} else {
sendResponse(exchange, "Invalid port number");
}
});
server.createContext("/unregisterInstance", exchange -> {
Map<String, String> params = parsePostParams(exchange);
int port = parseIntOrDefault(params.get("port"), 0);
if (port > 0 && activeInstances.containsKey(port)) {
activeInstances.remove(port);
sendResponse(exchange, "Unregistered instance on port " + port);
} else {
sendResponse(exchange, "No instance found on port " + port);
}
});
server.setExecutor(null); server.setExecutor(null);
new Thread(() -> { new Thread(() -> {
server.start(); server.start();
Msg.info(this, "GhidraMCP HTTP server started on port " + port); Msg.info(this, "HydraMCP HTTP server started on port " + port);
}, "GhidraMCP-HTTP-Server").start(); System.out.println("[HydraMCP] HTTP server started on port " + port);
}, "HydraMCP-HTTP-Server").start();
} }
// ---------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------
@ -278,19 +338,24 @@ public class GhidraMCPPlugin extends Plugin {
Program program = getCurrentProgram(); Program program = getCurrentProgram();
if (program == null) return "No program loaded"; if (program == null) return "No program loaded";
DecompInterface decomp = new DecompInterface(); DecompInterface decomp = new DecompInterface();
decomp.openProgram(program); try {
if (!decomp.openProgram(program)) {
return "Failed to initialize decompiler";
}
for (Function func : program.getFunctionManager().getFunctions(true)) { for (Function func : program.getFunctionManager().getFunctions(true)) {
if (func.getName().equals(name)) { if (func.getName().equals(name)) {
DecompileResults result = DecompileResults result =
decomp.decompileFunction(func, 30, new ConsoleTaskMonitor()); decomp.decompileFunction(func, 30, new ConsoleTaskMonitor());
if (result != null && result.decompileCompleted()) { if (result != null && result.decompileCompleted()) {
return result.getDecompiledFunction().getC(); return result.getDecompiledFunction().getC();
} else {
return "Decompilation failed";
} }
return "Decompilation failed";
} }
} }
return "Function not found"; return "Function not found";
} finally {
decomp.dispose();
}
} }
private boolean renameFunction(String oldName, String newName) { private boolean renameFunction(String oldName, String newName) {
@ -455,12 +520,31 @@ public class GhidraMCPPlugin extends Plugin {
} }
} }
private int findAvailablePort() {
int basePort = 8192;
int maxAttempts = 10;
for (int attempt = 0; attempt < maxAttempts; attempt++) {
int candidate = basePort + attempt;
if (!activeInstances.containsKey(candidate)) {
try (ServerSocket s = new ServerSocket(candidate)) {
return candidate;
} catch (IOException e) {
continue;
}
}
}
throw new RuntimeException("Could not find available port after " + maxAttempts + " attempts");
}
@Override @Override
public void dispose() { public void dispose() {
if (server != null) { if (server != null) {
server.stop(0); server.stop(0);
Msg.info(this, "HTTP server stopped."); Msg.info(this, "HTTP server stopped on port " + port);
System.out.println("[HydraMCP] HTTP server stopped on port " + port);
} }
activeInstances.remove(port);
super.dispose(); super.dispose();
} }
} }

View File

@ -1,4 +1,4 @@
name=GhidraMCP name=HydraMCP
description=A plugin that runs an embedded HTTP server to expose program data. description=A plugin that runs an embedded HTTP server to expose program data.
author=LaurieWired author=LaurieWired
createdOn=2025-03-22 createdOn=2025-03-22