SQL Slammer was a very famous worm hat took over the internet around 9 minutes. Microsoft posted a patch --half a year-- before the worm. With this Agent you should be able to find the error they corrected within a few minutes (not half a year).
# 1. Start the environment
docker compose up -d
# 2. Enter the agent workspace
docker compose exec agent-workspace bash
# 3. Place before/after binaries
cp /path/to/vuln_binary binaries/old.bin
cp /path/to/patched_binary binaries/new.bin
# 4. Run the agent
opencode
# 5. In OpenCode, invoke:
@manager use prompts/manager_init.txt \
--unpatched /app/binaries/old.bin \
--patched /app/binaries/new.bin \
--output-dir /app/outputThe agent autonomously:
- Global Analyzer diffs the binaries via BinDiff, finding every changed function
- Local Analyzer decompiles each changed function via Ghidra, writes analysis to chapter files
- Manager synthesizes everything into
/app/output/writeup.md
┌─────────────────────────────────────────────────────────┐
│ docker-compose.yml │
│ │
│ ┌─ agent-workspace ──────────────────────────────────┐ │
│ │ OpenCode CLI ─── @manager agent .md │ │
│ │ │ │ │
│ │ │ task @global-analyzer │ │
│ │ │ │ bash: npx tsx /app/tools/bindiff.ts │ │
│ │ │ │ task @local-analyzer <address> │ │
│ │ │ │ bash: npx tsx /app/tools/ghidra_*.ts │ │
│ │ │ │ bash: python3 /app/harness/write_* │ │
│ │ │ │ │
│ │ │ HTTP POST http://tools:8000 │ │
│ │ │ ▼ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ TS Wrappers │────►│ FastAPI │ │ │
│ │ │ (path translate, │ │ job_server.py │ │ │
│ │ │ /app/ → /work/) │◄────│ (cache, semaphore)│ │ │
│ │ └──────────────────┘ └────────┬─────────┘ │ │
│ └────────────────────────────────────┼───────────────┘ │
│ │ │
│ ┌─ tools container ──────────────────┼───────────────┐ │
│ │ FastAPI spawns: │ │ │
│ │ ├─ /bindiff ──► bindiff.py ──► BinDiff CLI │ │
│ │ ├─ /disasm ──► ghidra_disasm.py ──► Ghidra │ │
│ │ ├─ /decompile──► ghidra_decompile.py──► Ghidra │ │
│ │ ├─ /string ──► string_analyzer.py──► strings │ │
│ │ └─ /symbol ──► symbol_analyzer.py──► nm/obj │ │
│ │ │ │
│ │ Ghidra 11.0.3 headless (semaphore=1) │ │
│ │ └─ ghidra_scripts/ │ │
│ │ ├─ disassemble.py (Jython) │ │
│ │ ├─ decompile.py (Jython) │ │
│ │ └─ export_binexport.py (BinExport) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
The agent and analysis tools run in separate Docker containers communicating via HTTP. The agent never directly touches Ghidra, BinDiff, or system tools. This means a malicious binary (which you're analyzing, after all) cannot compromise the agent runtime — the worst it can do is crash a subprocess in the tools container. Different base images also allow independent rebuild cycles and scaling.
Each TypeScript wrapper translates paths (/app/ → /work/) and POSTs JSON to the FastAPI backend. This gives clean process isolation, network transparency (you can curl any endpoint to debug), statelessness, and consistent error handling via the ToolResult interface. A shared volume would require both containers to agree on filesystem layout; subprocess calls would chain agent availability to tool availability.
Every Ghidra decompile/disasm result is cached by SHA256(binary)_filename_function_address. The filename is included because test harnesses often create dummy binaries with identical content but different names — without it, caches would collide silently.
def cache_key_for_function(binary_path, function_address, function_names):
s = sha256_file(binary_path)
base_name = os.path.basename(binary_path)
return f"{base_name}_{s}_{function_address or function_names or 'all'}"Ghidra headless consumes 4–8 GB RAM per invocation. Running multiple instances simultaneously causes OOM kills. The asyncio.Semaphore serializes all Ghidra calls. This is acceptable because the LLM (agent thinking time) is the bottleneck, not Ghidra throughput.
ghidra_sem = asyncio.Semaphore(MAX_GHIDRA) # MAX_GHIDRA = 1
async with ghidra_sem:
cmd = ["python3", "tools/ghidra/ghidra_decompile.py", ...]
code, out, err = await run_subproc(cmd, timeout=req.timeout)If Ghidra crashes or a binary is malformed, the error response is written to stdout but not saved to the cache. This prevents a transient failure from blacklisting a function analysis permanently.
# NEVER cache error responses
if "error" not in parsed and parsed.get("status") != "failed":
with open(cache_file, "w") as f:
json.dump(parsed, f)The Local Analyzer writes raw decompilation to numbered chapter files (harness/db/chapter_{id}.json) instead of returning megabytes of C code in the LLM response. The Global Analyzer reads these chapters on demand. This prevents LLM context overflow when analyzing large binaries with hundreds of functions.
The agent workspace mounts the project at /app/, but the tools container resolves binaries relative to /work/. Every TypeScript wrapper performs this translation before sending the request:
const payload = {
binary: config.binary.replace(/^\/app\//, '/work/'),
// ...
};BinExport is the Ghidra plugin that exports binaries to the .BinExport format BinDiff requires. It's designed for the Ghidra GUI. Getting it to work headless required:
- Unwrapping a nested zip (the "Inception Zip Trap" — a zip inside a zip)
- Replacing Ghidra's protobuf-java (the "Protobuf Collision" — the bundled version conflicts with BinExport's required version)
- Injecting jars into
/patch/to bypass Ghidra's GUI-enablement check
This fix is documented in changelog.txt and was the single most critical infrastructure challenge.
BinDiff returns addresses that may be raw file offsets rather than Ghidra's virtual addresses. Both Jython scripts (disassemble.py, decompile.py) automatically detect this and add the Image Base offset:
# From ghidra_scripts/decompile.py (conceptual)
if function_address < currentProgram.getImageBase().getOffset():
function_address += currentProgram.getImageBase().getOffset()The tools container runs as user patch (non-root), limiting the blast radius if a crafted binary exploits a Ghidra or Python vulnerability.
Every tool returns a standardized JSON envelope so the agent can parse results programmatically:
{
status: 'success' | 'error',
tool_name: string,
data: { ... },
summary: string,
metadata: { ai_next_step: string }
}All tool execution flows through a single run_subproc with timeout handling:
async def run_subproc(cmd, timeout=300):
proc = await asyncio.create_subprocess_exec(*cmd, stdout=PIPE, stderr=PIPE)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
raise HTTPException(status_code=504, detail="Tool timeout")
return proc.returncode, stdout, stderrThe core diffing logic queries the BinDiff SQLite database for functions with similarity below threshold:
cursor.execute('''
SELECT address1, address2, similarity, confidence, name2
FROM function
WHERE similarity < ? AND address2 IS NOT NULL
ORDER BY similarity ASC
''', (self.threshold,))Functions with low similarity are the ones that changed — these are the patch-relevant functions that get sent to the Local Analyzer for deep decompilation.
PatchForge bridges the gap between "a patch exists" and "we understand what the patch actually fixes" and "We can create an exploit." By automating the four-step pipeline — diff, identify, decompile, explain — it turns a manual, days-long analysis into an autonomous process. The architecture prioritizes defense (separate containers, no exploit tooling), reproducibility (SHA256 caching, chapter system), and debuggability (HTTP endpoints, ToolResult envelope).