LLM 기반 취약점 자동 분석 도구 구현# 0. 포스팅을 시작하며 불과 몇 년 사이에 AI 발달로 인해 무수히 많은 변화가 있었는데, 제일 영향을 받은 직종 중 하나가 IT 직종인 것 같습니다. 그리고 최근 AI가 분석 영역에서도 좋은 성과를 내는 것을 보면 이제는 직접 분석을 하는 것보다 AI를 얼마나 잘 다루는지에 따라 역량이 결정되는 시대가 된 것 같아 LLM기반 취약점 분석 도구 구현에 대해 포스팅하게 되었습니다. # 1. 왜 LLM 기반 취약점 분석인가 최근 몇 년 사이 보안 업계에서 "AI가 진짜로 취약점을 찾는다"는 체감이 확 올라왔습니다. [DARPA의 AIxCC](https://www.darpa.mil/research/programs/ai-cyber)가 2024년 준결승, 2025년 결승을 거치며 자동 취약점 탐지·패치의 가능성을 보여주는 등 굵직한 이벤트들이 이어졌고, 그 흐름 위에서 프런티어 랩들의 성과 발표가 쏟아져 나오고 있는데, 내용들을 보면 엄청나게 체감이 되고있습니다. 그 외에도 몇 가지 눈에 띄는 사례를 보겠습니다. **1. [Big Sleep](https://blog.google/technology/safety-security/cybersecurity-updates-summer-2025/) (Google, 2024.11 첫 공개)** Google은 2024년 6월 Project Naptime이라는 이름으로 시작해 같은 해 11월 Big Sleep으로 리브랜딩하며, SQLite의 실제 메모리 안전성 취약점을 LLM 에이전트가 처음으로 찾아낸 사례를 공개했습니다. 2025년 7월에는 공격자만 알고 있던 SQLite 제로데이(CVE-2025-6965)를 익스플로잇 직전에 차단한 사례까지 발표했고, 같은 해 8월에는 FFmpeg과 ImageMagick에서 20개의 신규 취약점을 추가로 보고했습니다. **2. [CodeMender](https://deepmind.google/discover/blog/introducing-codemender-an-ai-agent-for-code-security/) (Google DeepMind, 2025.10 공개)** Gemini Deep Think 기반의 자동 패치 에이전트입니다. 단순 탐지를 넘어 실제 패치까지 자동으로 만들어내는 것이 핵심이고, 공개 시점 기준 6개월간 오픈소스 프로젝트에 72개의 보안 패치를 업스트림했다고 밝혔습니다. **3. [Anthropic Claude Code Security](https://www.anthropic.com/news/claude-code-security) (2026.02 공개)** Claude Opus 4.6로 Anthropic 프런티어 레드팀이 프로덕션 오픈소스 코드베이스에서 500개 이상의 취약점을 발견했고, 그중 상당수는 수년간의 전문가 리뷰에도 발견되지 않았던 것들입니다. 이 성과를 바탕으로 GitHub PR 단위에서 동작하는 Claude Code Security가 Enterprise·Team 대상 Reaserch Preview로 풀렸습니다. **4. [Anthropic Claude Mythos Preview](https://red.anthropic.com/2026/mythos-preview/) (2026.04 공개)** 그리고 결정적으로 Mythos Preview입니다. Anthropic은 Linux 커널 CVE 100개를 Mythos Preview에 던져 40개의 잠재 익스플로잇 후보를 추려내게 했고, 그중 절반 이상에서 권한 상승 익스플로잇을 모델이 완전 자율로 작성해냈다고 공개했습니다. CVE 식별자와 git 커밋 해시만으로 시작된 한 익스플로잇 체인은 하루 이내에, 2,000달러 미만의 비용으로 완성됐습니다. OpenBSD TCP SACK의 27년 된 DoS 취약점, FFmpeg H.264 코덱의 16년 된 버그, FreeBSD NFS 서버의 17년 된 RCE(CVE-2026-4747)까지 모두 모델이 발견했습니다. 같은 시점에 Anthropic은 JPMorgan, Google 등과 함께 [Project Glasswing](https://www.anthropic.com/glasswing) 컨소시엄을 런칭해 오픈소스 핵심 프로젝트에 Mythos 접근권을 제공하기 시작했고, Mythos Preview는 이미 모든 주요 OS와 웹 브라우저에서 수천 건의 고위험 취약점을 찾아냈다는 게 Anthropic의 공개 수치입니다. 이는 보안 업계 전체가 한 번 더 들썩였던 사건이고, LLM 기반으로 도구를 구현해보고, AI 사용 역량을 끓어 올리기 위한 학습을 하게된 계기를 준 이슈입니다. 그러나 2025년 10월에 Anthropic의 AI 연구원 및 손에 꼽는 CISO가 창업한 보안 스타트업 ASILE이라는 회사에서 Mythos 발표 직후 Mythos에서 낸 성과에 대한 [반박 글](https://aisle.com/blog/ai-cybersecurity-after-mythos-the-jagged-frontier)을 하나 올렸습니다. Mythos가 발표 자료에서 자기 모델의 강점을 보여주려고 전면에 내세운 FreeBSD NFS 서버의 취약점 등 들이 작고 저렴한 모델에서도 탐지가 되었고, 그 중에는 파라미터가 3.6B인 모델도 포함이 되어있다고 합니다. 이는 어떤 모델을 쓰냐가 아닌 모델을 어떻게 사용하냐가 중요하다고 AISLE이 주장하는 건데, 이 말이 사실이라면 충분히 로컬 모델에서도 분석 파이프라인을 잘 구축하면 취약점 분석에 큰 도움이 될거라고 생각이 들었습니다. 물론 Claude같은 goat llm도 헛소리를 하는데 로컬 LLM은 더 하겠지만 분석 워크플로우에 맞는 harness와 프롬프트를 직접 짜보는 감각을 익히는데 초점을 맞춰 작성하게 되었습니다. # 2. LLM 분석 도구의 분류 분석에서 LLM을 어떻게 사용하는지에 따라 섞어서 크게 3가지 방식으로 나눌 수 있다고 합니다. #### 1. One-shot 프롬프트 방식 코드 스니펫이나 디컴파일 결과를 통째로 프롬프트에 넣고 "취약점 찾아줘"라고 한 번에 던지는 방식입니다. 가장 단순하고 직관적이라 초창기 GPT 기반 스크립트들이 대부분 여기 속했고, CTF에서 개별 함수 하나 빠르게 분석할 때는 지금도 충분히 유용합니다. 대표 예시: - [**GhidraChatGPT**](https://github.com/evyatar9/GptHidra) — Ghidra의 디컴파일 결과를 ChatGPT에 던져 함수 설명·취약점 분석을 받는 가장 초기형 플러그인 입니다. - [**GepetoIDA**](https://github.com/JusticeRage/Gepetto) — IDA Pro용 동급 플러그인. 함수 단위로 LLM에 한 번 물어보고 결과를 출력합니다. #### 2. Agentic / Tool-calling 방식 LLM이 디컴파일러, 디버거, 심지어 컨테이너를 직접 조작합니다. 프롬프트는 "이 프로그램에서 보안 취약점을 찾아달라" 정도의 간단한 지시로 시작하고, 모델이 스스로 코드를 읽고 가설을 세우고 도구를 호출해 검증하는 흐름입니다. 핵심은 **모델이 환경과 상호작용**한다는 점이고, 현재 핫한 도구들이 Agentic/Tool-calling 방식으로 분석을 합니다. 대표 예시: - [**Anthropic Mythos / Claude Code Security**](https://www.anthropic.com/news/claude-code-security) — 격리 컨테이너에 Claude Code를 띄우고 코드베이스 전체를 자율적으로 분석하게 하는 방식. 글 1장에서 다룬 그 도구들입니다. - [**Google Big Sleep**](https://googleprojectzero.blogspot.com/2024/10/from-naptime-to-big-sleep.html) — Project Zero와 DeepMind의 합작. LLM 에이전트가 코드 네비게이션·샌드박스 스크립트 실행·디버거 조작을 도구로 호출하며 SQLite·FFmpeg 등에서 실제 0day를 발굴. - [**pyghidra-mcp**](https://github.com/clearbluejar/pyghidra-mcp) — 로컬 진영의 대표주자. Ghidra headless 위에 MCP 서버를 띄워서 LLM이 디컴파일·xref·심볼 검색을 tool로 호출하게 합니다. - #### 3. RAG 기반 방식 대규모 코드베이스를 임베딩 DB에 넣어두고, 분석에 필요한 조각만 검색해서 LLM에 넘기는 방식입니다. 컨텍스트 길이 제한을 우회하면서 큰 코드베이스를 다루기 위한 실용적인 절충안이고, 종종 위 Agentic 방식과 결합돼서도 쓰입니다(에이전트의 "도구" 중 하나가 RAG 검색). 대표 예시: - [**Vul-RAG**](https://arxiv.org/abs/2406.11147) — 알려진 CVE 지식 베이스를 임베딩하고, 분석 대상 코드와 의미적으로 유사한 취약점 사례를 retrieve해서 LLM에 같이 넘기는 방식. CWE 카테고리 기준 정확도를 크게 끌어올린 연구입니다. - [**CodeQL + LLM 파이프라인**](https://github.blog/security/vulnerability-research/codeql-team-uses-ai-to-power-vulnerability-detection-in-code/) (GitHub) — CodeQL이 정적 분석으로 후보를 좁혀주고, 그 결과를 LLM이 RAG처럼 참조해 최종 판정. 룰 기반과 LLM의 하이브리드 입니다. 여기서는 가장 핫한 2번 항목인 **Agentic/tool-calling** 방식의 pyghidra-mcp를 이용해 직접 구축해보려고 합니다. 기대에 한참 못미치는 결과가 나오겠지만, Mythos·Big Sleep이 보여준 흐름을 로컬 PC에서 재현하며 도구의 흐름을 이해하고 추후에 좀 더 효율적으로 도구를 사용할 수 있는 역량을 쌓는것이 목표입니다. # 3. LLM 기반 취약점 분석 도구 구조 2장에서 언급한것처럼, 여기서는 pyghidra-mcp를 이용해 도구를 구현해보려고 하는데, 아래와 같은 도구들을 사용하려고 합니다. #### 1. Ollama 로컬에서 LLM을 구동하는 런타임입니다. 명령어 한 줄로 모델을 받고 OpenAI 호환 API 서버가 바로 뜨기 때문에, 외부 API 없이 로컬 환경에서 빠르게 실험을 돌릴 수 있어 골랐습니다.(일단 무료 입니다.) #### 2. Ghidra Headless NSA가 공개한 SRE 프레임워크 Ghidra의 CLI 모드입니다. GUI 없이 바이너리 분석을 자동화할 수 있어서, LLM 파이프라인에 붙여 디컴파일·xref·심볼 검색을 프로그램적으로 호출하기에 적합합니다. (얘도 일단 무료입니다.) #### 3. Qwen2.5/3B Alibaba가 공개한 오픈웨이트 LLM 중 가장 작은 축에 속하는 모델입니다. 사양이 넉넉하지 않은 환경에서도 돌릴 수 있습니다. 그리고 Qwen 계열이 코드 분석에 용이할 뿐 아니라 tool-calling이 안정적이라 Agentic 파이프라인에 적합해서 골랐습니다. (얘도 일단 무료입니다.) 위의 도구들을 이용해 구현을 진행해보려고 하는데 구조는 아래와 같습니다. **1. Agent** — 분석 전체를 제어하는 컨트롤 타워. tool-calling 루프를 돌리고 finding을 누적해 리포트로 출력. - **1) LLMClient** — Ollama API에 메시지를 보내고 응답을 받는 wrapper. - **2) MCPClient** — pyghidra-mcp에 stdio로 연결. LLM이 요청한 tool을 실제로 실행. **2. Ollama** — Qwen2.5 3B를 호스팅하는 로컬 LLM 런타임. 분석의 두뇌. **3. pyghidra-mcp** — Ghidra 기능을 LLM이 호출할 수 있는 tool로 노출하는 통역기. **4. Ghidra Headless** — 바이너리를 디컴파일·분석하는 엔진. ### 흐름 ``` Agent → LLMClient → Ollama (Qwen이 tool 호출 결정) ↓ Agent ← LLMClient ← Ollama (tool_call 응답) ↓ Agent → MCPClient → pyghidra-mcp → Ghidra Headless (실제 분석) ↓ Agent ← MCPClient ← pyghidra-mcp ← Ghidra Headless (결과 반환) ↓ Agent → LLMClient → Ollama (결과 전달, 다음 판단) ↓ (반복 후 finding 누적 → 리포트 출력) ``` # 4. 구현 그럼 이제 구현을 시작할건데, 구현하기에 앞서 환경은 우분투 20.04이고 파이썬은 3.10입니다. Ghidra가 3.10밑으로는 동작이 안되기 때문에 3.10이상으로 진행이 되어야 합니다. **Ghidra 및 LLM 구축 명령어** ``` LLM 구축 curl -fsSL https://ollama.com/install.sh | sh ollama pull qwen2.5:3b ollama serve & 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 ``` 그리고 구현 코드는 claude의 도움을 받았고, 아래와 같습니다. [main.py](http://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](http://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](http://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](http://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="") 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="") 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": "", "severity": "high|medium|low", "category": "buffer_overflow|format_string|injection", "description": "", "evidence": "", "reproduction": "" }} ] If nothing matches, output []. """ ``` [main.py](http://main.py) 진입점. CLI 인자를 받아 Agent를 실행하고 결과 리포트를 저장합니다. [**agent.py**](http://agent.py) 핵심 컨트롤러. LLM과 MCP 사이의 tool-calling 루프를 돌리고, 잘못된 인자를 정규화하며, 최종 finding을 추출합니다. #### **llm_client.py** Ollama와 통신하는 모듈. OpenAI 표준 라이브러리를 로컬 Ollama 주소로 연결해서 Qwen 모델에 메시지를 보냅니다. #### **mcp_client.py** pyghidra-mcp 서버와 통신하는 모듈. 서버를 표준 입출력 방식으로 띄우고, 사용 가능한 분석 도구 목록을 LLM이 이해할 수 있는 형태로 변환해 전달합니다. [**report.py**](http://report.py) LLM이 발견한 취약점 목록을 리포트로 작성합니다. [prompts.py](http://prompts.py) **(핵심)** LLM에게 줄 시스템 지시문과 분석 절차 안내. 환각을 막는 규칙과 카테고리별 오탐 차단 기준을 담고 있습니다. # 5. 결과 아래와 같이 bof취약점이 존재하는 파일을 생성하고, 분석을 진행했습니다. ```c #include #include #include 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; } ``` 결과는 아래와 같습니다. ``` # 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. --- ``` 처음에 할루시네이션이 너무너무 많아서 [prompts.py](http://prompts.py)에 있는 프롬프트를 좀 많이 수정 했는데, 마지막에 취약점 정보를 추가해서 프롬프트를 보내줬더니 생각보다 잘 탐지해서 놀랐습니다. 아마 프롬프트쪽을 얼마나 잘 작성했는지가 도구에 영향을 주는 것 같고, 확실히 모델이 좋을 수록 좋은 결과가 있을것으로 보입니다. # 6. 마치며 이번에 만든 도구는 "LLM이 Ghidra를 이용해 취약점을 판정한다" 수준까지입니다. 하지만 확실히 더 추가할 방법이 있는데 바로 harness입니다. 이번에는 prompts.py에 분석 절차를 하나하나 텍스트로 적어서 LLM에 넘겨주는 방식으로 만들었습니다. 이것도 일종의 harness이지만 자연어로 되어있다보니 tool-calling을 제대로 안해서 프롬프트를 여러번 수정했는데, 다음번엔 좋은 모델과 함께 파이썬 코드로 좀 더 단단하게 분석 절차를 만들어 좀 더 고도화를 시켜볼 생각입니다.