Implementing an LLM-Based Automated Vulnerability Analysis Tool
0. Introduction
In just a few years, AI has reshaped countless industries, and IT might be the one most affected. Recently, AI has been delivering impressive results even in the analysis domain, and it feels like we're entering an era where capability is defined not by how well you analyze something yourself, but by how well you can leverage AI. That's what motivated me to write this post on building an LLM-based vulnerability analysis tool.
1. Why LLM-Based Vulnerability Analysis?
Over the past few years, the security industry has felt a tangible shift — the sense that "AI can actually find vulnerabilities now." DARPA's AIxCC ran its semifinals in 2024 and finals in 2025, demonstrating the potential of automated vulnerability detection and patching. On top of that wave, frontier labs have been pushing out result after result, and the pace is genuinely striking.
Here are a few notable examples:
1. Big Sleep (Google, first announced Nov 2024)
Google started this work as Project Naptime in June 2024 and rebranded it to Big Sleep in November of the same year, when they disclosed the first real memory-safety vulnerability discovered by an LLM agent in SQLite. In July 2025, they announced they had blocked an SQLite zero-day (CVE-2025-6965) right before exploitation — a vulnerability only the attackers had been aware of. In August they reported 20 additional new vulnerabilities in FFmpeg and ImageMagick.
2. CodeMender (Google DeepMind, Oct 2025)
An automated patching agent based on Gemini Deep Think. The key point is that it goes beyond detection and actually generates patches. At launch, they reported 72 security patches upstreamed to open-source projects over a six-month period.
3. Anthropic Claude Code Security (Feb 2026)
Anthropic's frontier red team used Claude Opus 4.6 to discover over 500 vulnerabilities in production open-source codebases — many of which had survived years of expert review. Building on these results, Claude Code Security shipped as a Research Preview for Enterprise and Team users, operating at the GitHub PR level.
4. Anthropic Claude Mythos Preview (Apr 2026)
And finally, the big one — Mythos Preview. Anthropic fed 100 Linux kernel CVEs into Mythos, which produced 40 candidate exploit paths. For more than half of those, the model autonomously wrote working privilege-escalation exploits. One full exploit chain — starting from nothing more than a CVE ID and a git commit hash — was completed within a day for under $2,000. The model uncovered a 27-year-old DoS in OpenBSD's TCP SACK, a 16-year-old bug in FFmpeg's H.264 codec, and a 17-year-old RCE in FreeBSD's NFS server (CVE-2026-4747).
At the same time, Anthropic launched the Project Glasswing consortium with JPMorgan, Google, and others, granting Mythos access to maintainers of critical open-source projects. According to Anthropic, Mythos Preview has already uncovered thousands of high-severity vulnerabilities across all major operating systems and web browsers. This was the moment the security industry collectively sat up again — and it's also what pushed me to actually try building something with LLMs and start training my own intuition for using AI in this space.
But shortly after Mythos was announced, AISLE — a security startup founded in October 2025 by an Anthropic AI researcher and a top-tier CISO — published a counterpoint.
The vulnerabilities Mythos showcased — including the FreeBSD NFS server bug — were also detected by smaller, cheaper open-weight models, some with as few as 3.6B active parameters. AISLE's argument is that what matters isn't which model you use, but how you use it. If that's true, even local open-weight models can produce real results given a well-designed analysis pipeline. Of course, even GOAT models like Claude hallucinate — a local LLM will hallucinate even more — but the goal of this post is to build hands-on intuition for designing the harness and prompts that fit a real vulnerability-analysis workflow.
2. Categorizing LLM-Based Analysis Tools
Depending on how the LLM is integrated, analysis tools roughly fall into three categories.
1. One-shot Prompt
The simplest form: dump a code snippet or decompiled output into a single prompt and ask "find the vulnerability." Most early GPT-based scripts worked this way, and it's still useful for analyzing a single function during a CTF.
Examples:
- GhidraChatGPT — An early Ghidra plugin that ships decompiled output to ChatGPT for explanation and vulnerability analysis.
- Gepetto — The IDA Pro counterpart. Asks the LLM about a function once and prints the result.
2. Agentic / Tool-calling
The LLM directly drives the decompiler, debugger, and even containers. The prompt is typically just "find a vulnerability in this program," and the model autonomously reads code, forms hypotheses, and verifies them by calling tools. The key is that the model interacts with the environment, and most of the headline-grabbing tools today work this way.
Examples:
- Anthropic Mythos / Claude Code Security — Drops Claude Code into a sandboxed container and lets it analyze the entire codebase autonomously. The very tools we discussed in section 1.
- Google Big Sleep — A Project Zero × DeepMind collaboration. The LLM agent calls tools for code navigation, sandboxed script execution, and debugger interaction, and it has discovered real 0-days in SQLite, FFmpeg, and others.
- pyghidra-mcp — The leading option in the local camp. Wraps Ghidra Headless behind an MCP server, exposing decompilation, xref, and symbol search as tools the LLM can call.
3. RAG-based
Embed a large codebase into a vector DB and retrieve only the relevant chunks to pass to the LLM. It's a practical way to work around context-length limits when dealing with large codebases, and it's often combined with the agentic approach (where one of the agent's "tools" is a RAG search).
Examples:
- Vul-RAG — Embeds a knowledge base of known CVEs and retrieves semantically similar vulnerability cases to pass to the LLM alongside the target code. Significantly improves CWE-category accuracy.
- CodeQL + LLM pipeline (GitHub) — CodeQL narrows down candidates via static analysis, and the LLM uses that as RAG context for the final judgment. A hybrid of rule-based and LLM-based analysis.
In this post, I'll build something using approach #2 (Agentic / tool-calling) with pyghidra-mcp. The results will fall well short of what Mythos or Big Sleep can pull off, but the goal isn't to compete — it's to reproduce that workflow on a local machine, get a feel for how each piece moves, and build the muscle to use these tools more effectively down the road.
3. Tool Architecture
As mentioned in section 2, the implementation will use pyghidra-mcp. Here are the components.
1. Ollama
A local LLM runtime. One command pulls a model, and an OpenAI-compatible API server comes up automatically — no external API needed, fast to iterate locally, and free.
2. Ghidra Headless
The CLI mode of Ghidra, the SRE framework released by the NSA. It lets you automate binary analysis without the GUI, which makes it ideal for hooking into an LLM pipeline so the model can call decompilation, xrefs, and symbol search programmatically. Also free.
3. Qwen2.5 3B
One of the smallest tiers in Alibaba's open-weight Qwen lineup. Small enough to run on modest hardware. The Qwen family is known for being solid at code analysis and — more importantly for us — stable at tool-calling, which makes it a good fit for an agentic pipeline. Also free.
The architecture using these pieces looks like this:
1. Agent — The control tower for the entire analysis. Runs the tool-calling loop, accumulates findings, and emits the final report.
- 1) LLMClient — A wrapper that sends messages to the Ollama API and receives responses.
- 2) MCPClient — Connects to
pyghidra-mcp over stdio. Actually executes the tools the LLM requests.
2. Ollama — The local LLM runtime hosting Qwen2.5 3B. The "brain" of the analysis.
3. pyghidra-mcp — The translator that exposes Ghidra functionality to the LLM as callable tools.
4. Ghidra Headless — The engine that decompiles and analyzes the binary.
4. Implementation
Now let's get to the implementation. Before we start, the environment I'm working in is Ubuntu 20.04 with Python 3.10.
Ghidra requires Python 3.10 or higher, so anything below that won't work.
Setup commands for Ghidra and the LLM
Setup the LLM
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:3b
ollama serve &
Setup the Ghidra
wget https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.0.4_build/ghidra_12.0.4_PUBLIC_20260303.zip
unzip ghidra_12.0.4_PUBLIC_20260303.zip
mv ghidra_12.0.4_PUBLIC ghidra
echo 'export GHIDRA_INSTALL_DIR=$HOME/ghidra' >> ~/.bashrc
echo 'export PATH=$PATH:$GHIDRA_INSTALL_DIR/support' >> ~/.bashrc
source ~/.bashrc
analyzeHeadless
The implementation code below was put together with help from Claude.
main.py
import argparse
import asyncio
import sys
from pathlib import Path
from agent import VulnAnalysisAgent
from report import render_report
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("binary")
parser.add_argument("--output", "-o", default="report.md")
parser.add_argument("--max-functions", type=int, default=10)
parser.add_argument("--model", default="qwen2.5:3b")
parser.add_argument("--ollama-url", default="http://localhost:11434/v1")
return parser.parse_args()
async def run():
args = parse_args()
binary_path = Path(args.binary).resolve()
if not binary_path.exists():
print(f"[!] binary not found: {binary_path}", file=sys.stderr)
sys.exit(1)
print(f"[*] target: {binary_path}")
print(f"[*] model: {args.model}")
print(f"[*] max functions: {args.max_functions}\n")
agent = VulnAnalysisAgent(
binary_path=str(binary_path),
model=args.model,
ollama_url=args.ollama_url,
max_functions=args.max_functions,
)
findings = await agent.run()
report_md = render_report(binary_path.name, findings)
Path(args.output).write_text(report_md, encoding="utf-8")
print(f"\n[+] report saved: {args.output}")
print(f"[+] findings: {len(findings)}")
if __name__ == "__main__":
asyncio.run(run())
agent.py
import json
import re
from pathlib import Path
from typing import Any
from llm_client import LLMClient
from mcp_client import MCPClient
from prompts import SYSTEM_PROMPT, USER_TASK_TEMPLATE
MAX_ITERATIONS = 30
MAX_TOOL_CALLS = 8
TOOL_RESULT_MAX_LEN = 3000
class VulnAnalysisAgent:
def __init__(
self,
binary_path: str,
model: str,
ollama_url: str,
max_functions: int = 10,
):
self.binary_path = binary_path
self.binary_name = Path(binary_path).name
self.max_functions = max_functions
self.llm = LLMClient(base_url=ollama_url, model=model)
@staticmethod
def _extract_identifier(val: str) -> str:
val = val.strip().strip("'\"")
if re.fullmatch(r"0x[0-9a-fA-F]+", val):
return val
if re.fullmatch(r"[0-9a-fA-F]{6,16}", val):
return "0x" + val
if "::" in val:
val = val.split("::")[-1]
m = re.search(r"[A-Za-z_][A-Za-z0-9_]*", val)
if m:
return m.group(0)
return val
def _normalize_args(self, arguments: dict[str, Any]) -> dict[str, Any]:
if "name_or_address" in arguments:
val = str(arguments["name_or_address"]).strip()
arguments["name_or_address"] = self._extract_identifier(val)
if "binary_name" in arguments:
val = str(arguments["binary_name"]).strip()
val = val.lstrip("/\\").strip("'\"")
arguments["binary_name"] = val
return arguments
async def _resolve_binary_name(self, mcp: MCPClient) -> str:
result = await mcp.call_tool("list_project_binaries", {})
print("[*] list_project_binaries raw output:")
print(result)
print()
base = self.binary_name
candidates = re.findall(r"[A-Za-z0-9_.\-]{3,}", result)
prefixed = [c for c in candidates if c.startswith(base + "-") or c.startswith(base + "_")]
if prefixed:
chosen = prefixed[0].lstrip("/")
print(f"[*] matched (prefix+suffix): {chosen}")
return chosen
exact = [c for c in candidates if c == base]
if exact:
print(f"[*] matched (exact): {base}")
return base
contained = [c for c in candidates if base in c and len(c) > len(base)]
if contained:
chosen = contained[0].lstrip("/")
print(f"[*] matched (contains): {chosen}")
return chosen
print(f"[!] no match found in candidates: {candidates[:5]}")
return base
async def run(self) -> list[dict[str, Any]]:
async with MCPClient(self.binary_path) as mcp:
tools = await mcp.list_tools_as_openai_spec()
print(f"[*] tools exposed: {len(tools)}")
for t in tools:
print(f" - {t['function']['name']}")
print()
resolved_name = await self._resolve_binary_name(mcp)
print(f"[*] resolved binary name: {resolved_name}\n")
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": USER_TASK_TEMPLATE.format(
binary_name=resolved_name,
max_functions=self.max_functions,
),
},
]
tool_call_count = 0
forced_stop = False
for step in range(MAX_ITERATIONS):
print(f"[step {step + 1}] calling LLM... (tool calls: {tool_call_count})")
if tool_call_count >= MAX_TOOL_CALLS and not forced_stop:
messages.append({
"role": "user",
"content": "Stop calling tools. Output the final findings as a JSON array in a fenced json code block now.",
})
forced_stop = True
assistant_msg = await self.llm.chat(
messages,
tools=tools if not forced_stop else None,
)
messages.append(assistant_msg)
tool_calls = assistant_msg.get("tool_calls")
if not tool_calls:
final_text = assistant_msg.get("content", "")
print(f"[step {step + 1}] final response.")
return self._extract_findings(final_text)
for tc in tool_calls:
tool_call_count += 1
name = tc["function"]["name"]
raw_args = tc["function"]["arguments"]
try:
arguments = json.loads(raw_args) if raw_args else {}
except json.JSONDecodeError:
arguments = {}
arguments = self._normalize_args(arguments)
print(f" -> {name}({arguments})")
result_text = await mcp.call_tool(name, arguments)
if len(result_text) > TOOL_RESULT_MAX_LEN:
result_text = result_text[:TOOL_RESULT_MAX_LEN] + "\n... (truncated)"
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result_text,
})
print("[!] MAX_ITERATIONS reached.")
return []
def _extract_findings(self, text: str) -> list[dict[str, Any]]:
m = re.search(r"```json\s*(\[.*?\])\s*```", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
m = re.search(r"```\s*(\[.*?\])\s*```", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
start = text.find("[")
end = text.rfind("]")
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
pass
print("[!] failed to parse findings JSON. raw response:")
print(text[:1000])
return []
llm_client.py
from typing import Any
from openai import AsyncOpenAI
class LLMClient:
def __init__(self, base_url: str, model: str):
self.client = AsyncOpenAI(base_url=base_url, api_key="ollama")
self.model = model
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
kwargs = {
"model": self.model,
"messages": messages,
"temperature": 0.2,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
resp = await self.client.chat.completions.create(**kwargs)
msg = resp.choices[0].message
result: dict[str, Any] = {
"role": "assistant",
"content": msg.content or "",
}
if msg.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
return result
mcp_client.py
import os
from typing import Any
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class MCPClient:
def __init__(self, binary_path: str):
self.binary_path = binary_path
self.session: ClientSession | None = None
self._stdio_ctx = None
self._session_ctx = None
async def __aenter__(self):
params = StdioServerParameters(
command="pyghidra-mcp",
args=[self.binary_path],
env=os.environ.copy(),
)
self._stdio_ctx = stdio_client(params)
read, write = await self._stdio_ctx.__aenter__()
self._session_ctx = ClientSession(read, write)
self.session = await self._session_ctx.__aenter__()
await self.session.initialize()
return self
async def __aexit__(self, exc_type, exc, tb):
if self._session_ctx:
await self._session_ctx.__aexit__(exc_type, exc, tb)
if self._stdio_ctx:
await self._stdio_ctx.__aexit__(exc_type, exc, tb)
async def list_tools_as_openai_spec(self) -> list[dict[str, Any]]:
assert self.session is not None
tools_resp = await self.session.list_tools()
result = []
for t in tools_resp.tools:
result.append({
"type": "function",
"function": {
"name": t.name,
"description": t.description or "",
"parameters": t.inputSchema or {"type": "object", "properties": {}},
},
})
return result
async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
assert self.session is not None
try:
result = await self.session.call_tool(name, arguments)
chunks = []
for c in result.content:
if hasattr(c, "text"):
chunks.append(c.text)
else:
chunks.append(str(c))
return "\n".join(chunks) if chunks else "(empty)"
except Exception as e:
return (
f"[tool error] {type(e).__name__}: {e}\n"
f"This call failed. Try a different function name or move on to the next dangerous function."
)
report.py
from datetime import datetime
from typing import Any
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def render_report(binary_name: str, findings: list[dict[str, Any]]) -> str:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
findings_sorted = sorted(
findings,
key=lambda f: SEVERITY_ORDER.get(str(f.get("severity", "low")).lower(), 9),
)
lines = [
f"# Vulnerability report: {binary_name}",
"",
f"- generated: {now}",
f"- findings: {len(findings_sorted)}",
"",
]
if not findings_sorted:
lines += [
"## Result",
"",
"No clear vulnerabilities were found in this run.",
"",
]
return "\n".join(lines)
lines += [
"## Summary",
"",
"| # | Function | Severity | Category |",
"|---|----------|----------|----------|",
]
for i, f in enumerate(findings_sorted, 1):
lines.append(
f"| {i} | `{f.get('function', '?')}` | "
f"{f.get('severity', '?')} | {f.get('category', '?')} |"
)
lines.append("")
lines += ["## Details", ""]
for i, f in enumerate(findings_sorted, 1):
lines += [
f"### {i}. `{f.get('function', '?')}` — {str(f.get('severity', '?')).upper()}",
"",
f"**Category**: {f.get('category', '-')}",
"",
f"**Description**: {f.get('description', '-')}",
"",
"**Evidence**:",
"",
"```c",
str(f.get("evidence", "(none)")),
"```",
"",
f"**Reproduction**: {f.get('reproduction', '-')}",
"",
"---",
"",
]
return "\n".join(lines)
prompts.py
SYSTEM_PROMPT = """\
You are a security auditor analyzing decompiled C code from Ghidra.
Find real, exploitable vulnerabilities by calling the available tools.
Only report a finding if you can quote the exact vulnerable line from the decompiled code.
Do not flag a function just because it imports a dangerous symbol.
"""
USER_TASK_TEMPLATE = """\
Target: {binary_name}
Workflow (follow in this exact order):
1. Call list_imports(binary_name="{binary_name}"). Do NOT use search_symbols, search_strings, or any other discovery tool first.
2. From the list_imports result, pick the dangerous functions (gets, strcpy, strcat, sprintf, scanf, system, popen, printf, fprintf).
3. For each dangerous function, call list_cross_references(binary_name="{binary_name}", name_or_address="<func_name>") with a SINGLE function name (not a regex, not a comma list).
4. For each caller name returned, call decompile_function(binary_name="{binary_name}", name_or_address="<caller>") and read the code.
5. Stop after at most {max_functions} decompilations and output the final JSON.
Quick rules to avoid false positives:
- buffer_overflow: user input copied into a fixed buffer with no length check (gets, strcpy, sprintf without bound).
- format_string: user input passed as the FORMAT argument. printf("%s", x) is safe; printf(x) is vulnerable.
- injection: user input concatenated into a command string passed to system/popen/exec. system("/bin/sh") with a literal is NOT injection.
If a tool returns an error, try the next dangerous function. Do not give up.
Output a JSON array in a ```json fenced block:
[
{{
"function": "<caller name>",
"severity": "high|medium|low",
"category": "buffer_overflow|format_string|injection",
"description": "<one sentence>",
"evidence": "<exact line from decompilation>",
"reproduction": "<how to trigger>"
}}
]
If nothing matches, output [].
"""
main.py The entry point. Parses CLI arguments, runs the Agent, and saves the final report.
agent.py The core controller. Runs the tool-calling loop between the LLM and MCP, normalizes malformed arguments, and extracts the final findings.
llm_client.py The module that talks to Ollama. Points the OpenAI standard library at the local Ollama endpoint and sends messages to the Qwen model.
mcp_client.py The module that talks to the pyghidra-mcp server. Spawns the server over stdio and converts the list of available analysis tools into a format the LLM can consume.
report.py Renders the list of vulnerabilities found by the LLM as a report.
prompts.py (the important one) The system instructions and workflow guide for the LLM. Contains the rules for preventing hallucinations and the per-category criteria for filtering out false positives.
5. Results
I built a binary with a stack buffer overflow vulnerability and ran the analysis on it.
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void secret_function(void) {
printf("You should not be here!\n");
system("/bin/sh");
}
void greet(char *name) {
char buffer[64];
strcpy(buffer, name);
printf("Hello, %s!\n", buffer);
}
void process_input(void) {
char input[128];
printf("Enter your name: ");
gets(input);
greet(input);
}
int main(int argc, char *argv[]) {
if (argc > 1) {
greet(argv[1]);
} else {
process_input();
}
return 0;
}
The result looks like this:
# Vulnerability report: vuln
- generated: 2026-04-26 21:13:31
- findings: 1
## Summary
| # | Function | Severity | Category |
|---|----------|----------|----------|
| 1 | `strcpy` | high | buffer_overflow |
## Details
### 1. `strcpy` — HIGH
**Category**: buffer_overflow
**Description**: Potential buffer overflow vulnerability in strcpy function.
**Evidence**:
"""c
char *dest = (char *)malloc(strlen(src) + 1);
strcpy(dest, src);
free(src);
"""
**Reproduction**: Pass user input to a variable that is then passed to strcpy without bounds check.
---
At first there were way too many hallucinations, so I had to rewrite the prompt in prompts.py quite a bit. In the end, after I added explicit vulnerability criteria into the prompt, detection got surprisingly solid.
It seems like the quality of the prompt has a major impact on how well the tool performs — and obviously, a better model would give even better results.
6. Wrapping Up
What this tool reaches is roughly: "an LLM uses Ghidra to judge whether code is vulnerable." But there's clearly more to add, and the next step is harness.
This time, I wrote out the analysis procedure step by step in prompts.py as natural language and handed it to the LLM. That's also a kind of harness, but because it's in natural language, the model didn't always do tool-calling properly, and I ended up rewriting the prompt many times. Next time, with a better model, I'd like to write a more solid harness in actual Python code and push the tool further.