Self-registering IDA plugin (auto port allocation, heartbeat-based instance registry) plus an MCP server exposing ~40 tools for decompilation, xrefs, type/struct editing, byte-level search and patching, Objective-C class recovery, and cross-instance comparison.
1506 lines
56 KiB
Python
1506 lines
56 KiB
Python
"""
|
|
IDA MCP Plugin.
|
|
|
|
Each instance finds a free TCP port on its own, starts an HTTP server on it,
|
|
and every 10s sends a heartbeat registration to the MCP server (IDA_MCP_REGISTRY_URL,
|
|
defaults to http://127.0.0.1:8888). Open as many IDA instances as you want -- each
|
|
registers itself under its own id (filename_pid); the MCP server sees all of them
|
|
through ida_list_instances. No hardcoded ports, no manual setup per instance.
|
|
|
|
Env vars:
|
|
IDA_MCP_PORT force a specific port (default: auto-pick)
|
|
IDA_MCP_BASE_PORT where auto-pick starts from (default 7777)
|
|
IDA_MCP_HOST bind address (default 127.0.0.1; 0.0.0.0 for remote/SSH)
|
|
IDA_MCP_ADVERTISE_HOST address to report back to the MCP server (defaults to
|
|
IDA_MCP_HOST unless that's 0.0.0.0, then 127.0.0.1 --
|
|
set explicitly for a remote machine)
|
|
IDA_MCP_REGISTRY_URL MCP server base URL for self-registration (default
|
|
http://127.0.0.1:8888; empty disables registration)
|
|
IDA_MCP_TOKEN shared secret, checked on incoming requests here and sent
|
|
as Authorization: Bearer when registering with the MCP
|
|
server -- set the same value on both sides
|
|
|
|
See README.md for the full endpoint list.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import parse_qs, urlparse
|
|
from urllib.request import Request as URLRequest, urlopen
|
|
|
|
import idaapi
|
|
import idautils
|
|
import idc
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Config / self-registration
|
|
# ─────────────────────────────────────────────
|
|
|
|
BASE_PORT = int(os.environ.get("IDA_MCP_BASE_PORT", 7777))
|
|
BIND_HOST = os.environ.get("IDA_MCP_HOST", "127.0.0.1")
|
|
ADVERTISE_HOST = os.environ.get(
|
|
"IDA_MCP_ADVERTISE_HOST", BIND_HOST if BIND_HOST != "0.0.0.0" else "127.0.0.1"
|
|
)
|
|
REGISTRY_URL = os.environ.get("IDA_MCP_REGISTRY_URL", "http://127.0.0.1:8888")
|
|
TOKEN = os.environ.get("IDA_MCP_TOKEN")
|
|
HEARTBEAT_INTERVAL = 10
|
|
|
|
PORT = None # set in _start()
|
|
INSTANCE_ID = None # set in _start()
|
|
|
|
|
|
def _find_free_port(start: int, host: str, tries: int = 50) -> int:
|
|
for p in range(start, start + tries):
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
s.bind((host if host != "0.0.0.0" else "", p))
|
|
return p
|
|
except OSError:
|
|
continue
|
|
finally:
|
|
s.close()
|
|
raise RuntimeError(f"No free port found in range {start}-{start + tries}")
|
|
|
|
|
|
def _make_instance_id() -> str:
|
|
try:
|
|
base = os.path.splitext(os.path.basename(idc.get_input_file_path() or "ida"))[0]
|
|
base = base or "ida"
|
|
except Exception:
|
|
base = "ida"
|
|
return f"{base}_{os.getpid()}"
|
|
|
|
|
|
def _registry_call(path: str, payload: dict):
|
|
if not REGISTRY_URL:
|
|
return
|
|
try:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = URLRequest(
|
|
f"{REGISTRY_URL}{path}",
|
|
data=data,
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
if TOKEN:
|
|
req.add_header("Authorization", f"Bearer {TOKEN}")
|
|
urlopen(req, timeout=5).read()
|
|
except Exception as e:
|
|
print(f"[IDA MCP:{PORT}] registry {path} failed: {e}")
|
|
|
|
|
|
def _heartbeat_loop(stop_event: threading.Event):
|
|
while not stop_event.is_set():
|
|
try:
|
|
path = idc.get_input_file_path()
|
|
except Exception:
|
|
path = "unknown"
|
|
_registry_call(
|
|
"/instances/register",
|
|
{
|
|
"id": INSTANCE_ID,
|
|
"host": ADVERTISE_HOST,
|
|
"port": PORT,
|
|
"file": path,
|
|
"pid": os.getpid(),
|
|
},
|
|
)
|
|
stop_event.wait(HEARTBEAT_INTERVAL)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Auth
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _check_auth(handler) -> bool:
|
|
if not TOKEN:
|
|
return True
|
|
return handler.headers.get("Authorization", "") == f"Bearer {TOKEN}"
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Name cache (str_* and other non-standard symbols)
|
|
# ─────────────────────────────────────────────
|
|
|
|
_name_cache = {} # { name_str -> ea }
|
|
_name_cache_built = False
|
|
_name_cache_lock = threading.Lock()
|
|
|
|
|
|
def _build_name_cache():
|
|
"""Builds the full name cache via idautils.Names(). Called once."""
|
|
global _name_cache, _name_cache_built
|
|
cache = {}
|
|
for ea, name in idautils.Names():
|
|
cache[name] = ea
|
|
with _name_cache_lock:
|
|
_name_cache = cache
|
|
_name_cache_built = True
|
|
print(f"[IDA MCP:{PORT}] Name cache built: {len(cache)} entries")
|
|
|
|
|
|
def _ensure_cache():
|
|
global _name_cache_built
|
|
if not _name_cache_built:
|
|
_build_name_cache()
|
|
|
|
|
|
def _lookup_name(name: str) -> int:
|
|
if name.startswith("0x") or name.startswith("0X"):
|
|
return int(name, 16)
|
|
|
|
ea = idc.get_name_ea_simple(name)
|
|
if ea != idc.BADADDR:
|
|
return ea
|
|
|
|
# The decompiler shows str_ instead of .str. -- try both forms
|
|
alt_name = None
|
|
if name.startswith("str_"):
|
|
alt_name = ".str." + name[4:]
|
|
elif name.startswith("str"):
|
|
alt_name = ".str" + name[3:]
|
|
|
|
if alt_name:
|
|
ea = idc.get_name_ea_simple(alt_name)
|
|
if ea != idc.BADADDR:
|
|
return ea
|
|
|
|
_ensure_cache()
|
|
with _name_cache_lock:
|
|
ea = _name_cache.get(name, idc.BADADDR)
|
|
if ea == idc.BADADDR and alt_name:
|
|
ea = _name_cache.get(alt_name, idc.BADADDR)
|
|
return ea
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Thread safety
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def run_in_main_thread(fn):
|
|
"""Runs fn on the IDA main thread synchronously, returns its result."""
|
|
result = [None]
|
|
error = [None]
|
|
event = threading.Event()
|
|
|
|
def callback():
|
|
try:
|
|
result[0] = fn()
|
|
except Exception as e:
|
|
error[0] = e
|
|
finally:
|
|
event.set()
|
|
return 0
|
|
|
|
idaapi.execute_sync(callback, idaapi.MFF_FAST)
|
|
if not event.wait(timeout=90):
|
|
raise TimeoutError(
|
|
"IDA main thread did not respond in time (busy with another operation?)"
|
|
)
|
|
|
|
if error[0]:
|
|
raise error[0]
|
|
return result[0]
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Resolve helper
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
class ApiError(ValueError):
|
|
"""ValueError with a machine-readable code, so callers can branch on it
|
|
instead of parsing the error text."""
|
|
|
|
def __init__(self, message: str, code: str = "bad_request"):
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
def _resolve_inner(name: str) -> int:
|
|
if name.startswith("0x") or name.startswith("0X"):
|
|
return int(name, 16)
|
|
ea = idc.get_name_ea_simple(name)
|
|
if ea == idc.BADADDR:
|
|
raise ApiError(f"Symbol not found: {name}", code="not_found")
|
|
return ea
|
|
|
|
|
|
def _require(body: dict, fields):
|
|
missing = [f for f in fields if f not in body or body[f] is None]
|
|
if missing:
|
|
raise ApiError(f"Missing required field(s): {', '.join(missing)}", code="bad_request")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# IDA operations
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _ping():
|
|
def _():
|
|
try:
|
|
path = idc.get_input_file_path()
|
|
except Exception:
|
|
path = "unknown"
|
|
try:
|
|
import ida_hexrays
|
|
|
|
hexrays_ok = ida_hexrays.init_hexrays_plugin()
|
|
except Exception:
|
|
hexrays_ok = False
|
|
try:
|
|
analysis_complete = idaapi.auto_is_ok()
|
|
except Exception:
|
|
analysis_complete = None
|
|
return {
|
|
"status": "ok",
|
|
"id": INSTANCE_ID,
|
|
"host": ADVERTISE_HOST,
|
|
"port": PORT,
|
|
"file": path,
|
|
"hexrays_available": bool(hexrays_ok),
|
|
"analysis_complete": analysis_complete,
|
|
"name_cache": len(_name_cache) if _name_cache_built else "not built",
|
|
"registry_url": REGISTRY_URL or None,
|
|
}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _list_functions(filter_str=None, limit=200, offset=0):
|
|
def _():
|
|
import ida_funcs
|
|
|
|
funcs = []
|
|
for ea in idautils.Functions():
|
|
name = idc.get_func_name(ea)
|
|
if filter_str and filter_str.lower() not in name.lower():
|
|
continue
|
|
f = ida_funcs.get_func(ea)
|
|
if f:
|
|
funcs.append({"ea": hex(f.start_ea), "name": name, "size": f.size()})
|
|
total = len(funcs)
|
|
return {
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"functions": funcs[offset : offset + limit],
|
|
}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _search_functions(q: str, limit=100):
|
|
def _():
|
|
results = []
|
|
q_lower = q.lower()
|
|
for ea in idautils.Functions():
|
|
name = idc.get_func_name(ea)
|
|
if q_lower in name.lower():
|
|
results.append({"ea": hex(ea), "name": name})
|
|
if len(results) >= limit:
|
|
break
|
|
return {"count": len(results), "functions": results}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _search_strings(q: str, limit=50):
|
|
def _():
|
|
results = []
|
|
q_lower = q.lower()
|
|
for s in idautils.Strings():
|
|
try:
|
|
val = str(s)
|
|
if q_lower in val.lower():
|
|
results.append({"ea": hex(s.ea), "value": val})
|
|
if len(results) >= limit:
|
|
break
|
|
except Exception:
|
|
pass
|
|
return {"count": len(results), "strings": results}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _decompile_one(target: str):
|
|
"""Returns a dict with the code, or an explicit 'error' field (never embedded in the code text)."""
|
|
import ida_funcs
|
|
import ida_hexrays
|
|
|
|
ea = _resolve_inner(target)
|
|
name = idc.get_func_name(ea)
|
|
f = ida_funcs.get_func(ea)
|
|
try:
|
|
cfunc = ida_hexrays.decompile(ea)
|
|
except Exception as e:
|
|
return {"target": target, "ea": hex(ea), "name": name, "error": f"decompile exception: {e}"}
|
|
if not cfunc:
|
|
return {
|
|
"target": target,
|
|
"ea": hex(ea),
|
|
"name": name,
|
|
"error": "decompile failed (no Hex-Rays for this arch, or not a function?)",
|
|
}
|
|
return {
|
|
"target": target,
|
|
"ea": hex(ea),
|
|
"name": name,
|
|
"size": f.size() if f else 0,
|
|
"code": str(cfunc),
|
|
}
|
|
|
|
|
|
def _decompile(target: str):
|
|
def _():
|
|
return _decompile_one(target)
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _decompile_many(targets):
|
|
def _():
|
|
results = []
|
|
for t in targets:
|
|
try:
|
|
results.append(_decompile_one(t))
|
|
except Exception as e:
|
|
results.append({"target": t, "error": str(e)})
|
|
return {"count": len(results), "results": results}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_disasm(target: str, count: int = 30, offset: int = 0):
|
|
def _():
|
|
import ida_lines
|
|
import ida_funcs
|
|
|
|
ea = _resolve_inner(target)
|
|
f = ida_funcs.get_func(ea)
|
|
end = f.end_ea if f else None
|
|
|
|
# Known instruction count up front, instead of guessing from what's returned.
|
|
total = len(list(idautils.FuncItems(ea))) if f else None
|
|
|
|
lines = []
|
|
cur = ea
|
|
n = 0
|
|
skipped = 0
|
|
while cur != idc.BADADDR:
|
|
if end is not None and cur >= end:
|
|
break
|
|
if skipped < offset:
|
|
skipped += 1
|
|
cur = idc.next_head(cur, end if end is not None else cur + 0x2000)
|
|
continue
|
|
if n >= count:
|
|
break
|
|
raw = idc.generate_disasm_line(cur, 0) or ""
|
|
lines.append({"ea": hex(cur), "text": ida_lines.tag_remove(raw)})
|
|
cur = idc.next_head(cur, end if end is not None else cur + 0x2000)
|
|
n += 1
|
|
|
|
truncated = cur != idc.BADADDR and (end is None or cur < end)
|
|
return {
|
|
"ea": hex(ea),
|
|
"func_end": hex(end) if end is not None else None,
|
|
"total_instructions": total,
|
|
"offset": offset,
|
|
"returned": len(lines),
|
|
"truncated": truncated,
|
|
"lines": lines,
|
|
}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _xrefs_to(target: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
results = []
|
|
for xref in idautils.XrefsTo(ea, 0):
|
|
from_ea = xref.frm
|
|
func = idaapi.get_func(from_ea)
|
|
results.append(
|
|
{
|
|
"from_ea": hex(from_ea),
|
|
"from_func": idc.get_func_name(from_ea) or "",
|
|
"func_ea": hex(func.start_ea) if func else hex(from_ea),
|
|
}
|
|
)
|
|
return results
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _xrefs_to_many(targets):
|
|
def _():
|
|
result = {}
|
|
for t in targets:
|
|
try:
|
|
ea = _resolve_inner(t)
|
|
entries = []
|
|
for xref in idautils.XrefsTo(ea, 0):
|
|
from_ea = xref.frm
|
|
func = idaapi.get_func(from_ea)
|
|
entries.append(
|
|
{
|
|
"from_ea": hex(from_ea),
|
|
"from_func": idc.get_func_name(from_ea) or "",
|
|
"func_ea": hex(func.start_ea) if func else hex(from_ea),
|
|
}
|
|
)
|
|
result[t] = entries
|
|
except Exception as e:
|
|
result[t] = {"error": str(e)}
|
|
return result
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _xrefs_from(target: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
return [
|
|
{"to_ea": hex(r), "to_func": idc.get_func_name(r)}
|
|
for r in idautils.CodeRefsFrom(ea, 0)
|
|
]
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_imports(filter_str=None, limit=500):
|
|
def _():
|
|
# IDA 9.x moved these from idaapi to ida_nalt and renamed _count -> _qty;
|
|
# fall back to the old idaapi names for older IDA versions.
|
|
try:
|
|
import ida_nalt as _imp
|
|
|
|
qty = _imp.get_import_module_qty()
|
|
except (ImportError, AttributeError):
|
|
_imp = idaapi
|
|
qty = idaapi.get_import_module_count()
|
|
|
|
imports = []
|
|
for i in range(qty):
|
|
mod = _imp.get_import_module_name(i)
|
|
|
|
def cb(ea, name, ord, mod=mod):
|
|
if not filter_str or filter_str.lower() in (name or "").lower():
|
|
imports.append(
|
|
{"module": mod, "name": name or f"ord_{ord}", "ea": hex(ea)}
|
|
)
|
|
return True
|
|
|
|
_imp.enum_import_names(i, cb)
|
|
return imports[:limit]
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_segments():
|
|
def _():
|
|
import ida_segment
|
|
|
|
segs = []
|
|
for ea in idautils.Segments():
|
|
seg = ida_segment.getseg(ea)
|
|
segs.append(
|
|
{
|
|
"name": idc.get_segm_name(ea),
|
|
"start": hex(seg.start_ea),
|
|
"end": hex(seg.end_ea),
|
|
"size": seg.end_ea - seg.start_ea,
|
|
"perm": {
|
|
"r": bool(seg.perm & ida_segment.SEGPERM_READ),
|
|
"w": bool(seg.perm & ida_segment.SEGPERM_WRITE),
|
|
"x": bool(seg.perm & ida_segment.SEGPERM_EXEC),
|
|
},
|
|
}
|
|
)
|
|
return segs
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_entry_points():
|
|
def _():
|
|
import ida_entry
|
|
|
|
result = []
|
|
for i in range(ida_entry.get_entry_qty()):
|
|
ordinal = ida_entry.get_entry_ordinal(i)
|
|
ea = ida_entry.get_entry(ordinal)
|
|
name = ida_entry.get_entry_name(ordinal)
|
|
result.append({"ordinal": ordinal, "ea": hex(ea), "name": name})
|
|
return result
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_prototype(target: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
return {"ea": hex(ea), "name": idc.get_func_name(ea), "prototype": idc.get_type(ea) or ""}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _set_prototype(target: str, prototype: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
decl = prototype if prototype.rstrip().endswith(";") else prototype + ";"
|
|
ok = idc.set_type(ea, decl)
|
|
if not ok:
|
|
raise ValueError(f"IDA rejected prototype: {prototype}")
|
|
return {"ok": True, "ea": hex(ea), "prototype": idc.get_type(ea)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _set_lvar_type(function: str, lvar_name: str, type_str: str):
|
|
"""Experimental: the Hex-Rays API for changing a local variable's type can
|
|
differ slightly across IDA versions. Errors are surfaced as-is so it's
|
|
clear what to fix."""
|
|
|
|
def _():
|
|
import ida_hexrays
|
|
import ida_typeinf
|
|
|
|
ea = _resolve_inner(function)
|
|
cfunc = ida_hexrays.decompile(ea)
|
|
if not cfunc:
|
|
raise ValueError("Cannot decompile function")
|
|
|
|
tif = ida_typeinf.tinfo_t()
|
|
decl = type_str.strip()
|
|
parseable = decl if decl.endswith(";") else decl + " x;"
|
|
if not ida_typeinf.parse_decl(tif, None, parseable, ida_typeinf.PT_SIL) and not tif.get_named_type(
|
|
None, type_str
|
|
):
|
|
raise ValueError(f"Cannot parse type: {type_str}")
|
|
|
|
for lvar in cfunc.get_lvars():
|
|
if lvar.name == lvar_name:
|
|
if not lvar.set_lvar_type(tif):
|
|
raise ValueError(
|
|
"set_lvar_type() rejected by Hex-Rays (incompatible type or read-only var)"
|
|
)
|
|
cfunc.save_user_lvar_settings()
|
|
return {"ok": True, "function": idc.get_func_name(ea), "lvar": lvar_name, "type": type_str}
|
|
raise ValueError(f"Local variable '{lvar_name}' not found")
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _rename_function(target: str, new_name: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
old_name = idc.get_func_name(ea)
|
|
ok = idc.set_name(ea, new_name, idc.SN_NOWARN)
|
|
if not ok:
|
|
raise ValueError(f"IDA rejected name '{new_name}' (duplicate or invalid?)")
|
|
with _name_cache_lock:
|
|
if old_name in _name_cache:
|
|
del _name_cache[old_name]
|
|
_name_cache[new_name] = ea
|
|
return {"ok": True, "ea": hex(ea), "old_name": old_name, "new_name": new_name}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _set_comment(target: str, comment: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
idc.set_func_cmt(ea, comment, 0)
|
|
return {"ok": True}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _rename_local(function: str, old_name: str, new_name: str):
|
|
def _():
|
|
import ida_hexrays
|
|
|
|
ea = _resolve_inner(function)
|
|
cfunc = ida_hexrays.decompile(ea)
|
|
if not cfunc:
|
|
raise ValueError("Cannot decompile function")
|
|
for lvar in cfunc.get_lvars():
|
|
if lvar.name == old_name:
|
|
lvar.name = new_name
|
|
cfunc.save_user_lvar_settings()
|
|
return {"ok": True}
|
|
raise ValueError(f"Local variable '{old_name}' not found")
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_name_value(name: str):
|
|
def _():
|
|
ea = _lookup_name(name)
|
|
|
|
if ea == idc.BADADDR:
|
|
raise ValueError(f"Not found: {name}")
|
|
|
|
s = idc.get_strlit_contents(ea, -1, idc.STRTYPE_C)
|
|
if s is not None:
|
|
return {
|
|
"ea": hex(ea),
|
|
"type": "string",
|
|
"value": s.decode("utf-8", errors="replace"),
|
|
}
|
|
|
|
val = idc.get_wide_dword(ea)
|
|
return {"ea": hex(ea), "type": "dword", "value": hex(val)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _rebuild_name_cache():
|
|
def _():
|
|
global _name_cache_built
|
|
_name_cache_built = False
|
|
_build_name_cache()
|
|
return {"ok": True, "entries": len(_name_cache)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _reload_plugin_code():
|
|
"""Re-reads this .py file from disk and swaps in the new handler functions
|
|
without restarting IDA. This works because do_GET/do_POST look up
|
|
_decompile/_get_imports/etc. by name in the module's global namespace on
|
|
every request -- after reload, that lookup finds the new code.
|
|
|
|
Limitation: PORT/INSTANCE_ID are restored manually (reload would otherwise
|
|
reset them), and the heartbeat thread/HTTPServer keep running with their
|
|
old code until an actual IDA restart, since they were started with a
|
|
direct function reference rather than a by-name lookup."""
|
|
|
|
def _():
|
|
import importlib
|
|
import sys
|
|
|
|
modname = __name__
|
|
mod = sys.modules.get(modname)
|
|
if mod is None:
|
|
raise ValueError(f"Cannot find own module in sys.modules as '{modname}'")
|
|
|
|
saved_port, saved_id = PORT, INSTANCE_ID
|
|
try:
|
|
importlib.reload(mod)
|
|
except Exception:
|
|
# IDA-loaded plugin modules often have no usable importlib spec
|
|
# (loaded via IDA's own mechanism, not a normal import) — fall back
|
|
# to exec'ing the file straight into the module's own namespace.
|
|
try:
|
|
with open(mod.__file__, "r", encoding="utf-8") as f:
|
|
src = f.read()
|
|
exec(compile(src, mod.__file__, "exec"), mod.__dict__)
|
|
except Exception as e:
|
|
raise ValueError(f"Reload failed (syntax error in the file?): {e}")
|
|
|
|
mod.PORT = saved_port
|
|
mod.INSTANCE_ID = saved_id
|
|
mod._name_cache_built = False
|
|
threading.Thread(target=lambda: run_in_main_thread(mod._build_name_cache), daemon=True).start()
|
|
|
|
return {
|
|
"ok": True,
|
|
"port": mod.PORT,
|
|
"id": mod.INSTANCE_ID,
|
|
"note": "Endpoint handlers reloaded. Heartbeat thread keeps its old code until a full IDA restart.",
|
|
}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Notes (netnode-backed -- persist across sessions and tools)
|
|
# ─────────────────────────────────────────────
|
|
|
|
_NOTES_NODE_NAME = "$ ida_mcp_notes"
|
|
|
|
|
|
def _notes_node():
|
|
import ida_netnode
|
|
|
|
return ida_netnode.netnode(_NOTES_NODE_NAME, 0, True)
|
|
|
|
|
|
def _get_notes(target: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
val = _notes_node().supval(ea)
|
|
text = val.decode("utf-8", errors="replace") if val else ""
|
|
return {"ea": hex(ea), "note": text}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _set_notes(target: str, note: str):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
nn = _notes_node()
|
|
if note:
|
|
nn.supset(ea, note.encode("utf-8"))
|
|
else:
|
|
nn.supdel(ea)
|
|
return {"ok": True, "ea": hex(ea)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Bytes / binary search
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _get_bytes(target: str, size: int = 64):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
data = idc.get_bytes(ea, size)
|
|
if data is None:
|
|
raise ValueError(f"Cannot read {size} bytes at {hex(ea)} (unmapped?)")
|
|
return {"ea": hex(ea), "size": size, "hex": data.hex()}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _find_pattern(pattern: str, max_results: int = 50):
|
|
"""Searches for an IDA-style hex pattern ("48 8B ?? ??") across the whole image.
|
|
Experimental: the bin_search API has changed across IDA versions; falls
|
|
back to the legacy idc.find_binary."""
|
|
|
|
def _():
|
|
results = []
|
|
modern_ok = False
|
|
modern_err = None
|
|
try:
|
|
import ida_bytes
|
|
|
|
patterns = ida_bytes.compiled_binpat_vec_t()
|
|
if not ida_bytes.parse_binpat_str(patterns, 0, pattern, 16):
|
|
modern_err = "parse_binpat_str() returned False (bad pattern syntax?)"
|
|
else:
|
|
modern_ok = True
|
|
ea = 0
|
|
while len(results) < max_results:
|
|
found = ida_bytes.bin_search(ea, idaapi.BADADDR, patterns, ida_bytes.BIN_SEARCH_FORWARD)
|
|
found_ea = found[0] if isinstance(found, tuple) else found
|
|
if found_ea is None or found_ea == idaapi.BADADDR:
|
|
break
|
|
results.append(hex(found_ea))
|
|
ea = found_ea + 1
|
|
except Exception as e:
|
|
modern_ok = False
|
|
modern_err = f"{type(e).__name__}: {e}"
|
|
results = []
|
|
|
|
if not modern_ok:
|
|
try:
|
|
ea = idc.find_binary(0, idc.SEARCH_DOWN, pattern)
|
|
while ea != idc.BADADDR and len(results) < max_results:
|
|
results.append(hex(ea))
|
|
ea = idc.find_binary(ea + 1, idc.SEARCH_DOWN, pattern)
|
|
except Exception as e:
|
|
raise ValueError(
|
|
f"bin_search path failed ({modern_err}); legacy find_binary also failed: {type(e).__name__}: {e}"
|
|
)
|
|
|
|
return {"pattern": pattern, "count": len(results), "matches": results, "used_legacy_find_binary": not modern_ok}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _patch_bytes(target: str, hex_bytes: str):
|
|
def _():
|
|
import ida_bytes
|
|
|
|
ea = _resolve_inner(target)
|
|
data = bytes.fromhex(hex_bytes.replace(" ", ""))
|
|
for i, b in enumerate(data):
|
|
ida_bytes.patch_byte(ea + i, b)
|
|
return {"ok": True, "ea": hex(ea), "patched_bytes": len(data)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _make_data(target: str, data_type: str, struct_name: str = None):
|
|
"""Experimental: some struct-related idc functions are legacy and may have moved in IDA 9.x."""
|
|
|
|
def _():
|
|
import ida_bytes
|
|
|
|
ea = _resolve_inner(target)
|
|
sizes = {"byte": (ida_bytes.FF_BYTE, 1), "word": (ida_bytes.FF_WORD, 2), "dword": (ida_bytes.FF_DWORD, 4), "qword": (ida_bytes.FF_QWORD, 8)}
|
|
if data_type in sizes:
|
|
flag, size = sizes[data_type]
|
|
ok = ida_bytes.create_data(ea, flag, size, idaapi.BADADDR)
|
|
elif data_type == "struct":
|
|
if not struct_name:
|
|
raise ValueError("struct_name is required for data_type='struct'")
|
|
tid = idc.get_struc_id(struct_name)
|
|
if tid == idc.BADADDR:
|
|
raise ValueError(f"Struct not found: {struct_name}")
|
|
size = idc.get_struc_size(tid)
|
|
ok = ida_bytes.create_struct(ea, size, tid)
|
|
else:
|
|
raise ValueError(f"Unknown data_type: {data_type} (use byte/word/dword/qword/struct)")
|
|
if not ok:
|
|
raise ValueError("Failed to create data (already defined at this address, or bad location?)")
|
|
return {"ok": True, "ea": hex(ea), "type": data_type}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Comments at a specific address (set_comment is whole-function)
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _get_line_comment(target: str, repeatable: bool = False):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
return {"ea": hex(ea), "comment": idc.get_cmt(ea, repeatable) or ""}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _set_line_comment(target: str, comment: str, repeatable: bool = False):
|
|
def _():
|
|
ea = _resolve_inner(target)
|
|
idc.set_cmt(ea, comment, repeatable)
|
|
return {"ok": True, "ea": hex(ea)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Triage: top functions, call graph
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _top_functions(by: str = "xrefs", limit: int = 20):
|
|
def _():
|
|
import ida_funcs
|
|
|
|
stats = []
|
|
for ea in idautils.Functions():
|
|
f = ida_funcs.get_func(ea)
|
|
if not f:
|
|
continue
|
|
if by == "size":
|
|
score = f.size()
|
|
else:
|
|
score = sum(1 for _ in idautils.XrefsTo(ea, 0))
|
|
stats.append({"ea": hex(ea), "name": idc.get_func_name(ea), by: score})
|
|
stats.sort(key=lambda x: x[by], reverse=True)
|
|
return stats[:limit]
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _call_graph(target: str, depth: int = 2, direction: str = "callees", limit_per_node: int = 30):
|
|
def _():
|
|
import ida_funcs
|
|
|
|
start = _resolve_inner(target)
|
|
|
|
def neighbors(ea):
|
|
if direction == "callers":
|
|
found = set()
|
|
for x in idautils.XrefsTo(ea, 0):
|
|
func = idaapi.get_func(x.frm)
|
|
if func:
|
|
found.add(func.start_ea)
|
|
return list(found)[:limit_per_node]
|
|
f = ida_funcs.get_func(ea)
|
|
if not f:
|
|
return []
|
|
found = set()
|
|
for head in idautils.FuncItems(ea):
|
|
for r in idautils.CodeRefsFrom(head, 0):
|
|
func = idaapi.get_func(r)
|
|
if func and func.start_ea != ea:
|
|
found.add(func.start_ea)
|
|
return list(found)[:limit_per_node]
|
|
|
|
nodes = {start: 0}
|
|
edges = []
|
|
frontier = [start]
|
|
for d in range(depth):
|
|
next_frontier = []
|
|
for node in frontier:
|
|
for n in neighbors(node):
|
|
edges.append({"from": hex(node), "to": hex(n)})
|
|
if n not in nodes:
|
|
nodes[n] = d + 1
|
|
next_frontier.append(n)
|
|
frontier = next_frontier
|
|
|
|
return {
|
|
"direction": direction,
|
|
"nodes": [{"ea": hex(ea), "name": idc.get_func_name(ea), "depth": d} for ea, d in nodes.items()],
|
|
"edges": edges,
|
|
}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Functions: create/undefine
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _make_function(target: str):
|
|
def _():
|
|
import ida_funcs
|
|
|
|
ea = _resolve_inner(target)
|
|
if not ida_funcs.add_func(ea):
|
|
raise ValueError(f"add_func failed at {hex(ea)} (already a function, or not valid code?)")
|
|
return {"ok": True, "ea": hex(ea), "name": idc.get_func_name(ea)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _undefine(target: str):
|
|
def _():
|
|
import ida_funcs
|
|
|
|
ea = _resolve_inner(target)
|
|
f = idaapi.get_func(ea)
|
|
if f:
|
|
ida_funcs.del_func(f.start_ea)
|
|
idc.del_items(ea, idc.DELIT_SIMPLE, 1)
|
|
return {"ok": True, "ea": hex(ea)}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Local types (structs/enums)
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
def _list_local_types(limit: int = 200):
|
|
def _():
|
|
import ida_typeinf
|
|
|
|
til = ida_typeinf.get_idati()
|
|
|
|
qty = None
|
|
for getter in (
|
|
lambda: ida_typeinf.get_ordinal_qty(til),
|
|
lambda: til.get_ordinal_qty(),
|
|
lambda: ida_typeinf.get_ordinal_count(til),
|
|
):
|
|
try:
|
|
qty = getter()
|
|
break
|
|
except Exception:
|
|
continue
|
|
if qty is None:
|
|
raise ValueError("Cannot determine local-types count on this IDA version (API changed)")
|
|
|
|
results = []
|
|
for ordinal in range(1, qty + 1):
|
|
tif = ida_typeinf.tinfo_t()
|
|
if tif.get_numbered_type(til, ordinal):
|
|
results.append(
|
|
{
|
|
"ordinal": ordinal,
|
|
"name": tif.get_type_name() or "",
|
|
"size": tif.get_size() if tif.get_size() != idaapi.BADSIZE else None,
|
|
"is_struct": tif.is_struct(),
|
|
"is_enum": tif.is_enum(),
|
|
}
|
|
)
|
|
if len(results) >= limit:
|
|
break
|
|
return results
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _create_struct(name: str, fields):
|
|
"""fields: [{"name": "field1", "type": "int"}, ...] -- adds a struct to Local Types."""
|
|
|
|
def _():
|
|
decl = f"struct {name} {{\n"
|
|
for f in fields:
|
|
decl += f" {f['type']} {f['name']};\n"
|
|
decl += "};"
|
|
errors = idc.parse_decls(decl, False)
|
|
if errors != 0:
|
|
raise ValueError(f"parse_decls reported {errors} error(s) for:\n{decl}")
|
|
return {"ok": True, "name": name, "decl": decl}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _create_local_type(decl: str):
|
|
"""Generalized version of _create_struct -- takes a raw C declaration
|
|
(struct/enum/union/typedef), not just a struct built from a field list."""
|
|
|
|
def _():
|
|
errors = idc.parse_decls(decl, False)
|
|
if errors != 0:
|
|
raise ValueError(f"parse_decls reported {errors} error(s) for:\n{decl}")
|
|
return {"ok": True, "decl": decl}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Objective-C: classes/methods by naming convention (+[Class sel:], -[Class sel:])
|
|
# ─────────────────────────────────────────────
|
|
|
|
_OBJC_METHOD_RE = None # compiled lazily
|
|
|
|
|
|
def _find_immediate(value: str, limit: int = 100):
|
|
"""Searches all instructions for an immediate operand matching the given value."""
|
|
|
|
def _():
|
|
try:
|
|
target = int(value, 0)
|
|
except ValueError:
|
|
raise ValueError(f"Cannot parse value: {value} (use decimal or 0x-hex)")
|
|
|
|
results = []
|
|
for func_ea in idautils.Functions():
|
|
for ea in idautils.FuncItems(func_ea):
|
|
for n in range(6):
|
|
try:
|
|
optype = idc.get_operand_type(ea, n)
|
|
except Exception:
|
|
break
|
|
if optype == idc.o_void:
|
|
break
|
|
if optype == idc.o_imm and idc.get_operand_value(ea, n) == target:
|
|
results.append({"ea": hex(ea), "func": idc.get_func_name(ea), "operand": n})
|
|
if len(results) >= limit:
|
|
return {"value": hex(target), "count": len(results), "matches": results}
|
|
return {"value": hex(target), "count": len(results), "matches": results}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _find_vtables(segment_filter: str = None, min_entries: int = 2, limit: int = 50):
|
|
"""Heuristic, experimental: looks for runs of consecutive pointers in
|
|
non-executable segments where each one points to the start of a real
|
|
function -- the classic vtable signature. Can be slow on large binaries
|
|
and gives false positives; pass segment_filter (e.g. "__const"/"__data")
|
|
to narrow the scan."""
|
|
|
|
def _():
|
|
import ida_segment
|
|
import ida_funcs
|
|
|
|
ptr_size = 8 if idaapi.get_inf_structure().is_64bit() else 4
|
|
get_ptr = idc.get_qword if ptr_size == 8 else idc.get_wide_dword
|
|
|
|
def is_func_start(val):
|
|
if not val or val == idc.BADADDR:
|
|
return False
|
|
f = ida_funcs.get_func(val)
|
|
return bool(f and f.start_ea == val)
|
|
|
|
candidates = []
|
|
for seg_ea in idautils.Segments():
|
|
seg = ida_segment.getseg(seg_ea)
|
|
if seg.perm & ida_segment.SEGPERM_EXEC:
|
|
continue
|
|
name = idc.get_segm_name(seg_ea)
|
|
if segment_filter and segment_filter.lower() not in name.lower():
|
|
continue
|
|
|
|
ea = seg.start_ea
|
|
while ea + ptr_size <= seg.end_ea and len(candidates) < limit:
|
|
if not is_func_start(get_ptr(ea)):
|
|
ea += ptr_size
|
|
continue
|
|
run_start = ea
|
|
entries = []
|
|
cur = ea
|
|
while cur + ptr_size <= seg.end_ea:
|
|
ptr = get_ptr(cur)
|
|
if not is_func_start(ptr):
|
|
break
|
|
entries.append({"ea": hex(cur), "func_ea": hex(ptr), "func_name": idc.get_func_name(ptr)})
|
|
cur += ptr_size
|
|
if len(entries) >= min_entries:
|
|
candidates.append({"ea": hex(run_start), "segment": name, "entries": entries})
|
|
ea = cur if cur > run_start else run_start + ptr_size
|
|
if len(candidates) >= limit:
|
|
break
|
|
|
|
return {"count": len(candidates), "vtables": candidates}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
def _get_objc_classes(filter_str: str = None, limit: int = 200):
|
|
def _():
|
|
import re
|
|
|
|
global _OBJC_METHOD_RE
|
|
if _OBJC_METHOD_RE is None:
|
|
_OBJC_METHOD_RE = re.compile(r"^([+-])\[([\w]+)(\([\w]+\))?\s+([^\]]+)\]$")
|
|
|
|
_ensure_cache()
|
|
classes = {}
|
|
|
|
# 1) class objects themselves, from runtime symbols
|
|
with _name_cache_lock:
|
|
names = list(_name_cache.items())
|
|
for name, ea in names:
|
|
if name.startswith("_OBJC_CLASS_$_"):
|
|
cls = name[len("_OBJC_CLASS_$_") :]
|
|
classes.setdefault(cls, {"name": cls, "ea": hex(ea), "methods": []})
|
|
|
|
# 2) methods, from functions named +[Class sel] / -[Class sel]
|
|
for ea in idautils.Functions():
|
|
fname = idc.get_func_name(ea)
|
|
m = _OBJC_METHOD_RE.match(fname)
|
|
if not m:
|
|
continue
|
|
kind, cls, category, selector = m.groups()
|
|
entry = classes.setdefault(cls, {"name": cls, "ea": None, "methods": []})
|
|
entry["methods"].append(
|
|
{
|
|
"kind": "class" if kind == "+" else "instance",
|
|
"selector": selector,
|
|
"category": category.strip("()") if category else None,
|
|
"ea": hex(ea),
|
|
}
|
|
)
|
|
|
|
result = list(classes.values())
|
|
if filter_str:
|
|
fl = filter_str.lower()
|
|
result = [c for c in result if fl in c["name"].lower()]
|
|
return {"count": len(result), "classes": result[:limit]}
|
|
|
|
return run_in_main_thread(_)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# HTTP handler
|
|
# ─────────────────────────────────────────────
|
|
|
|
HELP_ROUTES = [
|
|
{"method": "GET", "path": "/ping", "desc": "Instance status, Hex-Rays availability"},
|
|
{"method": "GET", "path": "/help", "desc": "This list"},
|
|
{"method": "GET", "path": "/functions?filter=&limit=&offset=", "desc": "List functions"},
|
|
{"method": "GET", "path": "/functions/search?q=&limit=", "desc": "Search functions by substring"},
|
|
{"method": "GET", "path": "/strings/search?q=&limit=", "desc": "Search strings"},
|
|
{"method": "GET", "path": "/name/<name_or_0xADDR>", "desc": "Value at a named symbol"},
|
|
{"method": "GET", "path": "/name_cache/rebuild", "desc": "Rebuild the name cache"},
|
|
{"method": "GET", "path": "/imports?filter=", "desc": "Import table"},
|
|
{"method": "GET", "path": "/segments", "desc": "Segments with permissions"},
|
|
{"method": "GET", "path": "/entry_points", "desc": "Entry points"},
|
|
{"method": "GET", "path": "/decompile/<target>", "desc": "Decompile a function"},
|
|
{"method": "POST", "path": "/decompile_batch", "desc": '{"targets": [...]} -- decompile in one call'},
|
|
{"method": "GET", "path": "/disasm/<target>?count=", "desc": "Disassemble a function"},
|
|
{"method": "GET", "path": "/xrefs_to/<target>", "desc": "Who references this address"},
|
|
{"method": "GET", "path": "/xrefs_from/<target>", "desc": "What this function references"},
|
|
{"method": "GET", "path": "/prototype/<target>", "desc": "Function prototype"},
|
|
{"method": "POST", "path": "/set_prototype", "desc": '{"target","prototype"}'},
|
|
{"method": "POST", "path": "/set_lvar_type", "desc": '{"function","lvar_name","type"} -- experimental'},
|
|
{"method": "GET", "path": "/notes/<target>", "desc": "Note stored at an address"},
|
|
{"method": "POST", "path": "/notes", "desc": '{"target","note"} -- note="" deletes it'},
|
|
{"method": "POST", "path": "/rename_function", "desc": '{"target","new_name"}'},
|
|
{"method": "POST", "path": "/rename_local", "desc": '{"function","old_name","new_name"}'},
|
|
{"method": "POST", "path": "/set_comment", "desc": '{"target","comment"}'},
|
|
{"method": "GET", "path": "/bytes/<target>?size=", "desc": "Raw bytes at an address (hex)"},
|
|
{"method": "GET", "path": "/find?pattern=&limit=", "desc": 'Search a hex pattern, e.g. "48 8B ?? ??" -- experimental'},
|
|
{"method": "POST", "path": "/patch_bytes", "desc": '{"target","hex_bytes"} -- writes to the binary!'},
|
|
{"method": "POST", "path": "/make_data", "desc": '{"target","data_type":"byte|word|dword|qword|struct","struct_name"} -- experimental'},
|
|
{"method": "GET", "path": "/line_comment/<target>?repeatable=", "desc": "Comment at a specific address (not the whole function)"},
|
|
{"method": "POST", "path": "/line_comment", "desc": '{"target","comment","repeatable"}'},
|
|
{"method": "GET", "path": "/top_functions?by=xrefs|size&limit=", "desc": "Triage: most-referenced/largest functions"},
|
|
{"method": "GET", "path": "/call_graph/<target>?depth=&direction=callees|callers", "desc": "Call graph N levels deep"},
|
|
{"method": "POST", "path": "/make_function", "desc": '{"target"} -- create a function at an address'},
|
|
{"method": "POST", "path": "/undefine", "desc": '{"target"} -- remove function/data definition at an address'},
|
|
{"method": "GET", "path": "/local_types?limit=", "desc": "List structs/enums from Local Types"},
|
|
{"method": "POST", "path": "/create_struct", "desc": '{"name","fields":[{"name","type"},...]}'},
|
|
{"method": "GET", "path": "/objc_classes?filter=&limit=", "desc": "Objective-C classes/methods by naming convention"},
|
|
{"method": "GET", "path": "/find_immediate?value=&limit=", "desc": "Search for an immediate operand with a given value"},
|
|
{"method": "GET", "path": "/find_vtables?segment=&min_entries=&limit=", "desc": "Heuristic vtable-like pointer array search -- experimental"},
|
|
{"method": "POST", "path": "/create_type", "desc": '{"decl"} -- raw C declaration for struct/enum/union/typedef in Local Types'},
|
|
{"method": "POST", "path": "/xrefs_to_batch", "desc": '{"targets": [...]} -- xrefs_to in one call'},
|
|
{"method": "GET", "path": "/reload", "desc": "Reload the plugin's code from disk without restarting IDA"},
|
|
]
|
|
|
|
|
|
class IDAHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
def send_json(self, data, code=200):
|
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def read_body(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
return json.loads(self.rfile.read(length)) if length else {}
|
|
|
|
def parse_path(self):
|
|
parsed = urlparse(self.path)
|
|
qs = parse_qs(parsed.query)
|
|
return parsed.path, {k: v[0] for k, v in qs.items()}
|
|
|
|
def do_GET(self):
|
|
try:
|
|
if not _check_auth(self):
|
|
self.send_json({"error": "unauthorized", "code": "unauthorized"}, 401)
|
|
return
|
|
|
|
path, qs = self.parse_path()
|
|
|
|
if path == "/ping":
|
|
self.send_json(_ping())
|
|
elif path == "/help":
|
|
self.send_json({"instance": INSTANCE_ID, "port": PORT, "routes": HELP_ROUTES})
|
|
elif path == "/functions":
|
|
self.send_json(
|
|
_list_functions(
|
|
filter_str=qs.get("filter"),
|
|
limit=int(qs.get("limit", 200)),
|
|
offset=int(qs.get("offset", 0)),
|
|
)
|
|
)
|
|
elif path == "/functions/search":
|
|
self.send_json(_search_functions(q=qs.get("q", ""), limit=int(qs.get("limit", 100))))
|
|
elif path == "/strings/search":
|
|
self.send_json(_search_strings(q=qs.get("q", ""), limit=int(qs.get("limit", 50))))
|
|
elif path == "/imports":
|
|
self.send_json(_get_imports(filter_str=qs.get("filter")))
|
|
elif path == "/segments":
|
|
self.send_json(_get_segments())
|
|
elif path == "/entry_points":
|
|
self.send_json(_get_entry_points())
|
|
elif path.startswith("/name/"):
|
|
self.send_json(_get_name_value(path[len("/name/") :]))
|
|
elif path == "/name_cache/rebuild":
|
|
self.send_json(_rebuild_name_cache())
|
|
elif path.startswith("/decompile/"):
|
|
self.send_json(_decompile(path[len("/decompile/") :]))
|
|
elif path.startswith("/disasm/"):
|
|
self.send_json(
|
|
_get_disasm(
|
|
path[len("/disasm/") :], count=int(qs.get("count", 30)), offset=int(qs.get("offset", 0))
|
|
)
|
|
)
|
|
elif path.startswith("/xrefs_to/"):
|
|
self.send_json(_xrefs_to(path[len("/xrefs_to/") :]))
|
|
elif path.startswith("/xrefs_from/"):
|
|
self.send_json(_xrefs_from(path[len("/xrefs_from/") :]))
|
|
elif path.startswith("/prototype/"):
|
|
self.send_json(_get_prototype(path[len("/prototype/") :]))
|
|
elif path.startswith("/notes/"):
|
|
self.send_json(_get_notes(path[len("/notes/") :]))
|
|
elif path.startswith("/bytes/"):
|
|
self.send_json(_get_bytes(path[len("/bytes/") :], size=int(qs.get("size", 64))))
|
|
elif path == "/find":
|
|
self.send_json(_find_pattern(qs.get("pattern", ""), max_results=int(qs.get("limit", 50))))
|
|
elif path.startswith("/line_comment/"):
|
|
self.send_json(
|
|
_get_line_comment(path[len("/line_comment/") :], repeatable=qs.get("repeatable") == "1")
|
|
)
|
|
elif path == "/top_functions":
|
|
self.send_json(_top_functions(by=qs.get("by", "xrefs"), limit=int(qs.get("limit", 20))))
|
|
elif path.startswith("/call_graph/"):
|
|
self.send_json(
|
|
_call_graph(
|
|
path[len("/call_graph/") :],
|
|
depth=int(qs.get("depth", 2)),
|
|
direction=qs.get("direction", "callees"),
|
|
)
|
|
)
|
|
elif path == "/local_types":
|
|
self.send_json(_list_local_types(limit=int(qs.get("limit", 200))))
|
|
elif path == "/objc_classes":
|
|
self.send_json(_get_objc_classes(filter_str=qs.get("filter"), limit=int(qs.get("limit", 200))))
|
|
elif path == "/find_immediate":
|
|
self.send_json(_find_immediate(qs.get("value", "0"), limit=int(qs.get("limit", 100))))
|
|
elif path == "/find_vtables":
|
|
self.send_json(
|
|
_find_vtables(
|
|
segment_filter=qs.get("segment"),
|
|
min_entries=int(qs.get("min_entries", 2)),
|
|
limit=int(qs.get("limit", 50)),
|
|
)
|
|
)
|
|
elif path == "/reload":
|
|
self.send_json(_reload_plugin_code())
|
|
else:
|
|
self.send_json({"error": "Unknown endpoint", "code": "not_found"}, 404)
|
|
|
|
except ApiError as e:
|
|
self.send_json({"error": str(e), "code": e.code}, 400)
|
|
except ValueError as e:
|
|
self.send_json({"error": str(e), "code": "bad_request"}, 400)
|
|
except Exception as e:
|
|
self.send_json({"error": str(e), "code": "internal_error"}, 500)
|
|
|
|
def do_POST(self):
|
|
try:
|
|
if not _check_auth(self):
|
|
self.send_json({"error": "unauthorized", "code": "unauthorized"}, 401)
|
|
return
|
|
|
|
body = self.read_body()
|
|
path, _ = self.parse_path()
|
|
|
|
if path == "/rename_function":
|
|
_require(body, ["target", "new_name"])
|
|
self.send_json(_rename_function(body["target"], body["new_name"]))
|
|
elif path == "/rename_local":
|
|
_require(body, ["function", "old_name", "new_name"])
|
|
self.send_json(_rename_local(body["function"], body["old_name"], body["new_name"]))
|
|
elif path == "/set_comment":
|
|
_require(body, ["target", "comment"])
|
|
self.send_json(_set_comment(body["target"], body["comment"]))
|
|
elif path == "/notes":
|
|
_require(body, ["target", "note"])
|
|
self.send_json(_set_notes(body["target"], body["note"]))
|
|
elif path == "/set_prototype":
|
|
_require(body, ["target", "prototype"])
|
|
self.send_json(_set_prototype(body["target"], body["prototype"]))
|
|
elif path == "/set_lvar_type":
|
|
_require(body, ["function", "lvar_name", "type"])
|
|
self.send_json(_set_lvar_type(body["function"], body["lvar_name"], body["type"]))
|
|
elif path == "/decompile_batch":
|
|
_require(body, ["targets"])
|
|
self.send_json(_decompile_many(body["targets"]))
|
|
elif path == "/patch_bytes":
|
|
_require(body, ["target", "hex_bytes"])
|
|
self.send_json(_patch_bytes(body["target"], body["hex_bytes"]))
|
|
elif path == "/make_data":
|
|
_require(body, ["target", "data_type"])
|
|
self.send_json(_make_data(body["target"], body["data_type"], body.get("struct_name")))
|
|
elif path == "/line_comment":
|
|
_require(body, ["target", "comment"])
|
|
self.send_json(_set_line_comment(body["target"], body["comment"], bool(body.get("repeatable", False))))
|
|
elif path == "/make_function":
|
|
_require(body, ["target"])
|
|
self.send_json(_make_function(body["target"]))
|
|
elif path == "/undefine":
|
|
_require(body, ["target"])
|
|
self.send_json(_undefine(body["target"]))
|
|
elif path == "/create_struct":
|
|
_require(body, ["name", "fields"])
|
|
self.send_json(_create_struct(body["name"], body["fields"]))
|
|
elif path == "/create_type":
|
|
_require(body, ["decl"])
|
|
self.send_json(_create_local_type(body["decl"]))
|
|
elif path == "/xrefs_to_batch":
|
|
_require(body, ["targets"])
|
|
self.send_json(_xrefs_to_many(body["targets"]))
|
|
else:
|
|
self.send_json({"error": "Unknown endpoint", "code": "not_found"}, 404)
|
|
|
|
except ApiError as e:
|
|
self.send_json({"error": str(e), "code": e.code}, 400)
|
|
except ValueError as e:
|
|
self.send_json({"error": str(e), "code": "bad_request"}, 400)
|
|
except Exception as e:
|
|
self.send_json({"error": str(e), "code": "internal_error"}, 500)
|
|
|
|
|
|
class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# IDA Plugin
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
class IdaMcpPlugin(idaapi.plugin_t):
|
|
flags = idaapi.PLUGIN_KEEP
|
|
comment = "IDA MCP REST server (auto-port + self-registration)"
|
|
help = "Exposes IDA Pro via HTTP for Claude/any MCP client"
|
|
wanted_name = "IDA MCP"
|
|
wanted_hotkey = "Ctrl-Shift-M"
|
|
|
|
def init(self):
|
|
self.server = None
|
|
self.thread = None
|
|
self.heartbeat_stop = None
|
|
self.heartbeat_thread = None
|
|
self._start()
|
|
threading.Thread(target=self._build_cache_bg, daemon=True).start()
|
|
return idaapi.PLUGIN_KEEP
|
|
|
|
def _build_cache_bg(self):
|
|
time.sleep(3) # give IDA time to finish loading the database
|
|
try:
|
|
run_in_main_thread(_build_name_cache)
|
|
except Exception as e:
|
|
print(f"[IDA MCP:{PORT}] Cache build error: {e}")
|
|
|
|
def run(self, arg):
|
|
if self.server:
|
|
self._stop()
|
|
else:
|
|
self._start()
|
|
|
|
def _start(self):
|
|
global PORT, INSTANCE_ID
|
|
try:
|
|
forced = os.environ.get("IDA_MCP_PORT")
|
|
PORT = int(forced) if forced else _find_free_port(BASE_PORT, BIND_HOST)
|
|
INSTANCE_ID = _make_instance_id()
|
|
|
|
self.server = ThreadingHTTPServer((BIND_HOST, PORT), IDAHandler)
|
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
self.thread.start()
|
|
|
|
self.heartbeat_stop = threading.Event()
|
|
self.heartbeat_thread = threading.Thread(
|
|
target=_heartbeat_loop, args=(self.heartbeat_stop,), daemon=True
|
|
)
|
|
self.heartbeat_thread.start()
|
|
|
|
print(
|
|
f"[IDA MCP] Started: id={INSTANCE_ID} http://{ADVERTISE_HOST}:{PORT} "
|
|
f"(bind {BIND_HOST}:{PORT}), registry={REGISTRY_URL or 'off'}"
|
|
)
|
|
except Exception as e:
|
|
print(f"[IDA MCP] Failed to start: {e}")
|
|
|
|
def _stop(self):
|
|
if self.heartbeat_stop:
|
|
self.heartbeat_stop.set()
|
|
_registry_call("/instances/unregister", {"id": INSTANCE_ID})
|
|
if self.server:
|
|
self.server.shutdown()
|
|
self.server = None
|
|
print(f"[IDA MCP:{PORT}] Stopped.")
|
|
|
|
def term(self):
|
|
self._stop()
|
|
|
|
|
|
def PLUGIN_ENTRY():
|
|
return IdaMcpPlugin()
|