An AI-friendly debugger library that drives rr replay through GDB/MI and
turns a recorded crash into one structured, English-narrated analysis.
$ python -m raconteur.agent_tool ./uaf
1. Continued forward; program received SIGSEGV at main (uaf.c:25)
2. Examined stack (1 frame); top user frame is main (uaf.c:25)
3. Listed locals in frame 0: p=0x7c9a3b8ff010 <error: Cannot access memory at address 0x7c9a3b8ff010>
4. Inserted breakpoint #1 at free if $rdi == 0x7c9a3b8ff010
5. Reverse-continued; hit breakpoint #1 at __GI___libc_free
6. Examined stack (3 frames); top user frame is freer (uaf.c:18)
7. Deleted breakpoint #1
8. Probed target for known allocators; resolved 4 symbol(s): malloc=[malloc]; calloc=[calloc]; realloc=[realloc]; free=[free].
9. Captured heap timeline: 1 allocations (1 freed, 0 live).
10. Conclusion: Use-after-free on 0x7c9a3b8ff010 (reached via `p`). Freed at
uaf.c:18, used at uaf.c:25. Dangling chunk was the 1st of 1 allocation
from uaf.c:22 (1.0 MiB); held for 20 ticks before use. No allocations
reclaimed the freed region during the gap (textbook UAF crash on
unmapped or sentinel-poisoned memory).
JSON: {"bug": "use-after-free", "summary": "...", "sites": {"freed_at": "uaf.c:18", "used_at": "uaf.c:25"}, "evidence": {...}, "heap_context": {...}}
LLM agents driving raw GDB hallucinate command output — the
EnIGMA paper documents this
empirically — and burn many turns reassembling crashes from scattered
register dumps, backtraces, and locals. raconteur compresses that
multi-turn dialog into one tool call whose output is structured
(AnalysisReport dataclass) and English-narrated, leaving the model
much less surface area to misread.
For agent integration the package exposes record_and_narrate(binary, args) as the headline entry point; for direct library use it exposes
tier-1 GDB/MI primitives plus a pluggable investigator registry.
- Replay-only. Drives
rr replayover an already-recorded trace. Live attach, core dumps, and ptrace-only backends are out of scope. - Bug classes. Four investigators ship in the default registry:
use-after-free(dangling pointer + reachablefree()of the faulting address; for any heap-aware allocator, including glibc, libstdc++, ASAN, MSAN, tcmalloc, jemalloc, mimalloc).stack-buffer-overflow(stack-protector canary fire; identifies the corrupted function and the most-recent unsafe sink — strcpy, sprintf, memcpy, gets, etc.).double-free(glibc's heap-corruption abort path; reverse-walks to both the second-free site that triggered abort and the prior first-free site).format-string(SIGSEGV inside libc's printf-family processing; identifies the calling source line for aprintf(argv[N])-style vulnerability). New bug classes plug in viaregister_investigator(matcher, fn)— no dispatcher edit required. Seeexamples/custom_investigator/for a runnable example that adds a fifth (SIGFPE / divide-by-zero) class.
- Heap-aware UAF analysis. Beyond freed/used source lines the UAF
report carries a
HeapContext: allocation-site history for the dangling chunk, free-to-use tick gap, and a free-to-use gap analysis that distinguishes textbook UAF crashes (no reclaim, unmapped page) from heap-spray-shape exploits (the freed region was reclaimed by another allocation while the dangling pointer was live). - Allocator auto-discovery.
Session.suggest_allocators()probes the binary's symbol table and returns specs for every known allocator that resolves, so non-glibc binaries don't need manual configuration.
- Linux x86_64
- rr 5.x or later, with
kernel.perf_event_paranoid <= 1 - gdb 13+ (anything that speaks the mi3 protocol works)
- Python 3.10+ and
pygdbmi >= 0.11
git clone git@github.com:blazer502/debugger-raconteur.git
cd debugger-raconteur
python -m venv .venv && source .venv/bin/activate
pip install -e .Record a crashing binary under rr, then narrate:
# In the binary's source directory:
gcc -g -O0 -o uaf uaf.c
rr record -o rr-trace ./uaf || true # SEGV exit ignoredProgrammatic, low-level:
import raconteur as rcr
session = rcr.load("rr-trace/") # tier-1 Session
report = rcr.analyze_crash(rcr.narrate("rr-trace/"))
print(report.bug) # "use-after-free"
print(report.sites) # {"freed_at": "uaf.c:18", "used_at": "uaf.c:25"}
print(report.heap_context.free_to_use_ticks)High-level (records + analyses + narrates in one call, what an agent uses):
import raconteur as rcr
text = rcr.record_and_narrate("./uaf")
# Returns numbered English transcript followed by a single "JSON: {...}"
# line with the full AnalysisReport dict.Importable from the top-level raconteur package:
| Symbol | Purpose |
|---|---|
load(trace_dir) -> Session |
Open an rr replay; raw tier-1 primitives. |
narrate(trace_dir) -> NarratedSession |
Same, plus per-call narration recording. |
analyze_crash(session) -> AnalysisReport |
Run the investigator dispatcher. |
record_and_narrate(binary, args="") -> str |
High-level entry: record + analyse + narrate. |
register_investigator(name, matcher, fn) |
Add a bug-class investigator at import time. |
AnalysisReport |
Stable result dataclass: bug, summary, sites, evidence, heap_context. |
HeapContext |
UAF-only: dangling_chunk, alloc_site_history, free_to_use_ticks, reallocs_spanning_gap. |
AllocEvent, AllocatorSpec |
Heap timeline + allocator-tracking primitives. |
WriteEvent |
Result of find_last_write: expr, loc, tick, frame, new_value. |
GLIBC_ALLOCATORS, CXX_ALLOCATORS, ASAN_ALLOCATORS, MSAN_ALLOCATORS, TCMALLOC_ALLOCATORS, JEMALLOC_ALLOCATORS, MIMALLOC_ALLOCATORS |
Allocator presets to pass to Session.heap_history. |
Session (tier-1 primitives, all symmetric forward/reverse):
cont / rcont step / rstep next / rnext finish / rfinish
run_until(loc) break_(loc, cond=None) watch(expr, kind) delete(bp)
where(depth) locals(frame) eval(expr, frame) recent_calls(n)
find_last_write(expr)
heap_history(allocators=None) suggest_allocators() current_tick()
find_last_write is the deterministic "where was this last written?"
primitive — sets a hardware write-watchpoint on expr, reverse-continues
until it fires, and returns a WriteEvent with the writing instruction's
source location, call frame, and post-write value. Equivalent to the
MVP-criterion step 2 (watch + rcont → last write) in one call.
NarratedSession mirrors these and additionally records a
NarrationEvent per call into .events; multi-step operations
(heap_history, suggest_allocators) emit one summary event each so
the transcript stays readable.
See DESIGN.md for output schemas and the architectural rationale
behind the two-tier tool surface.
The default tracks malloc / calloc / realloc / free (glibc). For
other allocators, pass an explicit spec list — or let
suggest_allocators figure it out:
session = rcr.load("trace/")
# Auto-detect what's in the binary
specs = session.suggest_allocators()
history = session.heap_history(allocators=specs)
# Or pick a family
history = session.heap_history(allocators=rcr.JEMALLOC_ALLOCATORS)
# Or roll your own
history = session.heap_history(allocators=(
rcr.AllocatorSpec("partition_alloc_root_malloc", "malloc"),
rcr.AllocatorSpec("partition_alloc_root_free", "free"),
))record_and_narrate and the UAF investigator use auto-discovery
internally, so the high-level path works on libc, libstdc++, sanitiser
builds, and the three named third-party allocators (tcmalloc, jemalloc,
mimalloc) without configuration.
For MCP-aware clients (Claude Desktop, Cursor, IDE plugins), raconteur
ships a stdio JSON-RPC server at raconteur.mcp_server:
python -m raconteur.mcp_server # speaks MCP over stdin/stdout
# or
make mcp-serveThirty tools are advertised — three one-shot crash narrators plus the full tier-1 primitive surface for interactive triage:
| Tool family | Examples | Use |
|---|---|---|
| One-shot narrators | narrate_run, analyze_trace, suggest_allocators |
Single-call crash report; the original surface. |
| Session lifecycle | rcr_open, rcr_close |
Open a stateful debugging session against a trace dir; returns a session_id. |
| Navigation | rcr_cont/rcr_rcont, rcr_step/rcr_rstep, rcr_next/rcr_rnext, rcr_finish/rcr_rfinish, rcr_run_until |
Forward + reverse execution control. |
| Breakpoints | rcr_break, rcr_watch, rcr_delete |
Conditional breakpoints + watchpoints. |
| Inspection | rcr_where, rcr_locals, rcr_frame_args, rcr_eval, rcr_recent_calls |
Stack + locals + arg + expression queries. |
| Heap / memory | rcr_heap_history, rcr_read_memory, rcr_classify_addr, rcr_mappings, rcr_find_last_write, rcr_find_writes_to |
Allocator-aware heap timeline, raw memory reads, mapping classification, watch-based write tracing, multi-step write-origin chase. |
| Sanitizer logs | parse_sanitizer_output |
Parse ASAN/UBSAN/MSAN/LSAN stderr into a structured SanitizerCrash — useful when you have only a sanitizer log, not a recorded trace. |
| Misc | rcr_suggest_allocators, rcr_current_tick, rcr_narration |
Session-scoped utilities. |
The server has no external dependencies beyond raconteur itself —
JSON-RPC framing is implemented directly so the MCP SDK is not required.
A typical Claude Desktop ~/Library/Application Support/Claude/claude_desktop_config.json
entry:
{
"mcpServers": {
"raconteur": {
"command": "python",
"args": ["-m", "raconteur.mcp_server"]
}
}
}Surveyed five prior systems (full notes in docs/improvement_plan.md).
Crash-analysis layer only — fuzzer / patch-gen / orchestration
intentionally out of scope.
| raconteur | EnIGMA / SWE-agent | HackSynth | NYU CTF / Cybench | Buttercup (AIxCC 2nd) | Atlantis (AIxCC 1st) | |
|---|---|---|---|---|---|---|
| Live debugger | rr + gdb/MI | gdb (raw passthrough) | none | gdb wrappers, agent-driven | none | gdb batch + rr-backtracer Docker |
| Structured crash report | ✅ AnalysisReport |
❌ raw (gdb) text |
❌ free-text prose | ❌ raw stdout | CrashInfo for Redis dedup only |
❌ raw <sanitizer_output> + [RCA] text |
| Allocator-aware heap context | ✅ alloc/free timeline + reclaim detection | ❌ | ❌ | ❌ | ❌ regex-only | ❌ |
| Mapping classification | ✅ heap/stack/.text:libc/unmapped per address | ❌ agent does info proc mappings |
❌ | ❌ | ❌ | ❌ |
| Exploit-craft fields | ✅ per bug class (saved-RIP / canary / fmtstr %N$ / chunk offset / abort-msg + tcache idx) |
❌ agent re-derives | ❌ | ❌ | ❌ | ❌ |
| Write-origin chase | ✅ find_writes_to(addr, n) |
❌ | ❌ | ❌ | ❌ | ✅ via separate rr-backtracer Docker |
| Symbol/source resolution | ✅ DWARF-resolved at frame construction + per-bug evidence sites | ❌ agent does info line *0x... |
❌ | partial (Ghidra) | partial (tree-sitter) | partial (asan_debug.py identifier dump) |
| Sanitizer-log parser | ✅ parse_sanitizer_output (ASAN/UBSAN/MSAN/LSAN/glibc) |
❌ | ❌ | partial | ✅ vendored Clusterfuzz StackParser |
partial regex |
| MCP / agent surface | ✅ 32 tools (one-shots + tier-1 stateful) | IAT bundle (text wire) | none | shell + Ghidra subcommands | task-queue + LLM context only | task-queue + per-stage analyzers |
| Per-event narration cap | ✅ HackSynth-style truncation marker | per-history-window only | hard char cap | n/a | n/a | n/a |
The headline differentiator: across all five surveyed systems, raconteur is the only one with a locked-JSON crash report shape, allocator-aware heap context with reclaim detection, and per-bug-class exploit-craft fields. The capabilities other systems each ship in isolation — Atlantis's write-origin chase, Buttercup's regex-only sanitizer parser, HackSynth's narration cap — are all available in raconteur via the same 32-tool MCP surface.
A ready-to-drop-in bundle lives at examples/swe-agent/narrate.sh. It
defines narrate_run as a SWE-agent IAT and shells out to
python -m raconteur.agent_tool. The bundle's @yaml preamble gives
the agent a tool docstring that includes the (RECOMMENDED FOR MEMORY-SAFETY BUGS) tag and the Use this FIRST guidance.
Order matters. Add config/commands/narrate.sh at the top of
command_files in your SWE-agent CTF config, not appended after
debug.sh. With deepseek-coder-v2 (16B MoE) the empirical effect is:
narrate.sh listed |
hashmap_uaf | uaf_reuse | cxx_uaf |
|---|---|---|---|
| first | 3/3 solved, 2 turns | 3/3 solved, 2 turns | 3/3 solved, 2 turns |
| last | 2/3 solved, 2–6 turns | 3/3 (1/3 wrong sites) | 0/3 — never reached the tool |
Same model, same fixtures, same prompts — only the catalog position
changes. Small open models anchor strongly on the first tool listed;
giving them narrate_run as the obvious first choice is the difference
between "always solves in 2 turns" and "never reaches the right tool".
bench/agent_harness.py is a minimal local-model loop that drives the
agent against the same tool catalog without needing the full SWE-agent
Docker harness. Useful for iterating on prompts. See
examples/swe-agent/README.md for full installation steps.
Nine fixtures live under acceptance/. Each has a Makefile that builds
the binary and records it under rr. Goldens under tests/golden/*.json
capture the normalised expected output (addresses and ticks masked;
source locations / sizes / bug class / heap-context shape locked literal).
| Fixture | Bug | Allocator family |
|---|---|---|
uaf |
use-after-free (1 MiB malloc) | glibc |
hashmap_uaf |
UAF (iterator invalidation after rehash) | glibc |
uaf_reuse |
UAF with tcache reclaim | glibc |
cxx_uaf |
UAF via new/delete |
libstdc++ |
jemalloc_uaf |
UAF via je_malloc/je_free |
jemalloc (prefixed) |
mimalloc_uaf |
UAF via mi_malloc/mi_free |
mimalloc |
double_free |
double-free (tcache detector) | glibc |
fmtstr |
format-string (printf(argv[1])) |
n/a |
stackov |
stack-buffer-overflow (canary fire) | n/a |
make record-all # build + rr-record every required fixture
make record-jemalloc # opt-in: needs a je_-prefixed jemalloc build
make record-mimalloc # opt-in: needs a mimalloc static lib
make acceptance # run golden tests
make acceptance-update # regenerate after an intentional schema changeThe jemalloc and mimalloc fixtures are opt-in because they require
external builds. See acceptance/jemalloc_uaf/Makefile and
acceptance/mimalloc_uaf/Makefile for the one-time build steps.
Exit code 0 = ship; exit code 1 = clean unified diff showing what
changed. Add a new bug class by writing a fixture, recording it, and
calling python tests/test_acceptance.py --update --only <name> to
generate a starting golden.
- Replay-only. No live attach; the trace must already exist. Most cases worth analysing are reproducible enough to record once.
- rr's environment. Requires Intel/AMD with
rdtsc/rdtscpand a perf-event-paranoid sysctl. Doesn't run inside most CI containers without configuration. - C/C++. Rust binaries with DWARF should work but aren't fixture- tested. No JIT'd or interpreted languages.
- One bug per investigation.
analyze_crashruns the first matching investigator; chained or multi-bug crashes need separate passes today. - Allocator layer-mixing was a known issue through commit
a5d3e13but is now fixed (3227b9d); passing overlapping presets likeGLIBC_ALLOCATORS + CXX_ALLOCATORSworks correctly.
DESIGN.md— architecture, tier-1/tier-2 split, output schemas, open risks.docs/landscape.md— 2026 survey of agentic-hacking tools and benchmarks (CyberGym, AIxCC, NYU CTF Bench, EnIGMA, Buttercup, Team Atlanta) and where raconteur fits in that ecosystem.acceptance/— nine end-to-end fixtures, each with a tiny C/C++ source file and Makefile demonstrating the bug class.bench/agent_harness.py— minimal local-model agent loop, used for the A/B measurements that motivated the heap-aware feature. Captured transcripts inbench/results/.examples/swe-agent/— drop-in IAT bundle for SWE-agent v0.7.0.examples/cybergym/— recipe for integrating with CyberGym's EnIGMA reference agent (real-CVE benchmark; 1,507 tasks).examples/custom_investigator/— out-of-tree extension viaregister_investigator; ships a SIGFPE bug class as the example.
Pre-1.0. The result schema (AnalysisReport, HeapContext, AllocEvent,
AllocatorSpec) is locked by tests/golden/; the tier-1 primitive surface
is stable enough to depend on. Investigator coverage is intentionally narrow
(2 bug classes) and will grow as fixtures arrive.