ghidra-decomp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Bulk-decompile binaries via Ghidra into a browsable source tree."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m ghidra_decomp."""
2
+
3
+ from .cli import main
4
+
5
+ main()
ghidra_decomp/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """CLI entry point for ghidra-decomp."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+
6
+ from .decompiler import decompile_binary, list_languages
7
+
8
+
9
+ @click.command()
10
+ @click.argument(
11
+ "binary",
12
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
13
+ required=False,
14
+ )
15
+ @click.option(
16
+ "-o",
17
+ "--output",
18
+ type=click.Path(path_type=Path),
19
+ default=None,
20
+ help="Output directory. Defaults to <binary_name>_decomp/",
21
+ )
22
+ @click.option(
23
+ "--timeout",
24
+ type=int,
25
+ default=60,
26
+ show_default=True,
27
+ help="Decompilation timeout per function (seconds).",
28
+ )
29
+ @click.option(
30
+ "--ghidra-path",
31
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
32
+ envvar="GHIDRA_INSTALL_DIR",
33
+ default=None,
34
+ help="Path to Ghidra installation. Falls back to GHIDRA_INSTALL_DIR env var.",
35
+ )
36
+ @click.option(
37
+ "--base-addr",
38
+ type=str,
39
+ default=None,
40
+ help="Rebase the binary to this address before analysis (e.g. 0x80000000).",
41
+ )
42
+ @click.option(
43
+ "--entry",
44
+ type=str,
45
+ default=None,
46
+ help="Mark this address as the program entry point and disassemble from it "
47
+ "before analysis (e.g. 0x31000). Useful for raw binaries.",
48
+ )
49
+ @click.option(
50
+ "--language",
51
+ type=str,
52
+ default=None,
53
+ help="Force Ghidra language/processor ID (e.g. ARM:LE:32:v7). "
54
+ "Use when auto-detect fails. See --list-languages.",
55
+ )
56
+ @click.option(
57
+ "--compiler",
58
+ type=str,
59
+ default=None,
60
+ help="Force compiler spec ID (e.g. gcc, default, windows). "
61
+ "Requires --language. See --list-languages.",
62
+ )
63
+ @click.option(
64
+ "--list-languages",
65
+ "list_langs",
66
+ is_flag=True,
67
+ default=False,
68
+ help="List all available Ghidra language IDs and their compiler specs, then exit.",
69
+ )
70
+ def main(
71
+ binary: Path | None,
72
+ output: Path | None,
73
+ timeout: int,
74
+ ghidra_path: Path | None,
75
+ base_addr: str | None,
76
+ entry: str | None,
77
+ language: str | None,
78
+ compiler: str | None,
79
+ list_langs: bool,
80
+ ):
81
+ """Decompile BINARY into a browsable source tree.
82
+
83
+ Produces one .c file per function, plus JSON indexes for strings,
84
+ call graph, imports, and exports.
85
+ """
86
+ if list_langs:
87
+ list_languages(ghidra_path=ghidra_path)
88
+ return
89
+
90
+ if binary is None:
91
+ raise click.UsageError("Missing argument 'BINARY'.")
92
+
93
+ if compiler is not None and language is None:
94
+ raise click.UsageError("--compiler requires --language.")
95
+
96
+ binary = binary.resolve()
97
+ if output is None:
98
+ output = Path.cwd() / f"{binary.stem}_decomp"
99
+
100
+ output = output.resolve()
101
+
102
+ click.echo(f"Binary: {binary}")
103
+ click.echo(f"Output: {output}")
104
+ if base_addr is not None:
105
+ click.echo(f"Base: {base_addr}")
106
+ if entry is not None:
107
+ click.echo(f"Entry: {entry}")
108
+ if language is not None:
109
+ click.echo(f"Lang: {language}")
110
+ if compiler is not None:
111
+ click.echo(f"CSpec: {compiler}")
112
+
113
+ decompile_binary(
114
+ binary,
115
+ output,
116
+ timeout=timeout,
117
+ ghidra_path=ghidra_path,
118
+ base_addr=base_addr,
119
+ entry=entry,
120
+ language=language,
121
+ compiler=compiler,
122
+ )
123
+
124
+ click.echo(f"\nDone. Output at: {output}")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,781 @@
1
+ """Core decompilation logic using pyghidra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from contextlib import ExitStack
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+
14
+ def _sanitize_name(name: str) -> str:
15
+ """Turn a function name into a safe filename component."""
16
+ return re.sub(r"[^\w\-.]", "_", name)
17
+
18
+
19
+ def _func_header_comment(func) -> str:
20
+ """Build a metadata header comment for a decompiled function."""
21
+ addr = func.getEntryPoint().toString()
22
+ name = func.getName()
23
+ body = func.getBody()
24
+ size = body.getNumAddresses()
25
+ convention = func.getCallingConventionName() or "unknown"
26
+ param_count = func.getParameterCount()
27
+ is_thunk = func.isThunk()
28
+
29
+ lines = [
30
+ f"// Function: {name}",
31
+ f"// Address: {addr}",
32
+ f"// Size: {size} bytes",
33
+ f"// Calling: {convention}",
34
+ f"// Params: {param_count}",
35
+ ]
36
+ if is_thunk:
37
+ thunked = func.getThunkedFunction(True)
38
+ thunk_name = thunked.getName() if thunked else "unknown"
39
+ lines.append(f"// Thunk: -> {thunk_name}")
40
+ lines.append("")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def _start_pyghidra(ghidra_path: Path | None):
45
+ """Import and start pyghidra, returning the module."""
46
+ try:
47
+ import pyghidra
48
+ except ImportError:
49
+ raise click.ClickException(
50
+ "pyghidra is not installed. Install it with: pip install pyghidra\n"
51
+ "pyghidra is bundled with Ghidra 11+."
52
+ )
53
+
54
+ if not pyghidra.started():
55
+ install_dir = str(ghidra_path) if ghidra_path else None
56
+ try:
57
+ pyghidra.start(install_dir=install_dir) if install_dir else pyghidra.start()
58
+ except Exception as e:
59
+ hint = ""
60
+ if not ghidra_path:
61
+ hint = (
62
+ "\nSet GHIDRA_INSTALL_DIR or pass --ghidra-path "
63
+ "to your Ghidra installation."
64
+ )
65
+ raise click.ClickException(f"Failed to start Ghidra: {e}{hint}")
66
+
67
+ return pyghidra
68
+
69
+
70
+ def list_languages(*, ghidra_path: Path | None = None) -> None:
71
+ """Print all Ghidra language IDs with their available compiler specs."""
72
+ _start_pyghidra(ghidra_path)
73
+
74
+ from ghidra.program.util import DefaultLanguageService
75
+
76
+ svc = DefaultLanguageService.getLanguageService()
77
+ descs = list(svc.getLanguageDescriptions(False))
78
+ descs.sort(key=lambda d: str(d.getLanguageID()))
79
+
80
+ for d in descs:
81
+ lang_id = str(d.getLanguageID())
82
+ processor = str(d.getProcessor())
83
+ endian = str(d.getEndian())
84
+ size = d.getSize()
85
+ cspecs = [
86
+ str(c.getCompilerSpecID())
87
+ for c in d.getCompatibleCompilerSpecDescriptions()
88
+ ]
89
+ click.echo(
90
+ f"{lang_id} [{processor} {endian} {size}] cspec: {', '.join(cspecs) or '-'}"
91
+ )
92
+
93
+ click.echo(f"\n{len(descs)} language(s).")
94
+
95
+
96
+ def decompile_binary(
97
+ binary: Path,
98
+ output: Path,
99
+ *,
100
+ timeout: int = 60,
101
+ ghidra_path: Path | None = None,
102
+ base_addr: str | None = None,
103
+ language: str | None = None,
104
+ compiler: str | None = None,
105
+ entry: str | None = None,
106
+ ) -> None:
107
+ """Decompile a binary and write structured output."""
108
+ pyghidra = _start_pyghidra(ghidra_path)
109
+
110
+ open_kwargs: dict[str, object] = {"analyze": False}
111
+ if language is not None:
112
+ open_kwargs["language"] = language
113
+ if compiler is not None:
114
+ open_kwargs["compiler"] = compiler
115
+
116
+ stack = ExitStack()
117
+ try:
118
+ flat_api = stack.enter_context(
119
+ pyghidra.open_program(str(binary), **open_kwargs)
120
+ )
121
+ except Exception as e:
122
+ msg = str(e)
123
+ if "No load spec found" in msg:
124
+ hint = (
125
+ f"Ghidra could not identify {binary.name}. Likely a raw or unknown "
126
+ "format.\nPass --language (and optionally --compiler) to force a "
127
+ "spec. Run with --list-languages to discover IDs."
128
+ )
129
+ elif language is not None:
130
+ hint = (
131
+ f"Failed to load with --language {language}"
132
+ + (f" --compiler {compiler}" if compiler else "")
133
+ + f": {msg}"
134
+ )
135
+ else:
136
+ hint = f"Failed to open program: {msg}"
137
+ raise click.ClickException(hint)
138
+
139
+ with stack:
140
+ program = flat_api.getCurrentProgram()
141
+
142
+ # Grab the project directory so we can clean it up after closing
143
+ project_loc = program.getDomainFile().getProjectLocator()
144
+ project_dir = Path(str(project_loc.getProjectDir())) if project_loc else None
145
+
146
+ if base_addr is not None:
147
+ _rebase_program(program, base_addr)
148
+
149
+ if entry is not None:
150
+ _mark_entry(program, flat_api, entry)
151
+
152
+ _run_analysis(program, flat_api)
153
+
154
+ _export_all(program, output, timeout)
155
+
156
+ # Clean up Ghidra project cache so runs are always atomic
157
+ if project_dir and project_dir.is_dir():
158
+ try:
159
+ shutil.rmtree(project_dir)
160
+ except OSError as e:
161
+ click.echo(f"Warning: could not clean up Ghidra cache: {e}", err=True)
162
+ # Also clean siblings (.gpr file, or .rep directory)
163
+ if project_dir:
164
+ if project_dir.name.endswith(".rep"):
165
+ # Clean the .gpr project file
166
+ gpr_file = project_dir.parent / (project_dir.name[:-4] + ".gpr")
167
+ if gpr_file.is_file():
168
+ try:
169
+ gpr_file.unlink()
170
+ except OSError:
171
+ pass
172
+ else:
173
+ # Clean the .rep repository directory
174
+ rep_dir = project_dir.parent / (project_dir.name + ".rep")
175
+ if rep_dir.is_dir() and rep_dir != project_dir:
176
+ try:
177
+ shutil.rmtree(rep_dir)
178
+ except OSError:
179
+ pass
180
+
181
+
182
+ def _rebase_program(program, base_addr: str) -> None:
183
+ """Rebase the program to a new image base address."""
184
+ try:
185
+ addr_value = int(base_addr, 0) # supports 0x hex, decimal, 0o octal
186
+ except ValueError:
187
+ raise click.BadParameter(
188
+ f"Invalid base address: {base_addr!r}. "
189
+ "Use hex (0x80000000), decimal, or octal (0o...) format.",
190
+ param_hint="'--base-addr'",
191
+ )
192
+ addr_space = program.getAddressFactory().getDefaultAddressSpace()
193
+ new_base = addr_space.getAddress(addr_value)
194
+
195
+ old_base = program.getImageBase()
196
+ click.echo(f"Rebasing: {old_base} -> {new_base}")
197
+
198
+ success = False
199
+ tid = program.startTransaction("Rebase image")
200
+ try:
201
+ program.setImageBase(new_base, True)
202
+ success = True
203
+ finally:
204
+ program.endTransaction(tid, success)
205
+
206
+
207
+ def _mark_entry(program, flat_api, entry: str) -> None:
208
+ """Disassemble from entry, create a function there, mark as program entry."""
209
+ try:
210
+ addr_value = int(entry, 0)
211
+ except ValueError:
212
+ raise click.BadParameter(
213
+ f"Invalid entry address: {entry!r}. "
214
+ "Use hex (0x31000), decimal, or octal (0o...) format.",
215
+ param_hint="'--entry'",
216
+ )
217
+ addr_space = program.getAddressFactory().getDefaultAddressSpace()
218
+ entry_addr = addr_space.getAddress(addr_value)
219
+ click.echo(f"Entry: {entry_addr}")
220
+
221
+ success = False
222
+ tid = program.startTransaction("Mark entry point")
223
+ try:
224
+ program.getSymbolTable().addExternalEntryPoint(entry_addr)
225
+ flat_api.disassemble(entry_addr)
226
+ if flat_api.getFunctionAt(entry_addr) is None:
227
+ flat_api.createFunction(entry_addr, "entry")
228
+ success = True
229
+ finally:
230
+ program.endTransaction(tid, success)
231
+
232
+
233
+ def _run_analysis(program, flat_api) -> None:
234
+ """Run Ghidra's auto-analysis on the program."""
235
+ click.echo("Running auto-analysis...")
236
+ success = False
237
+ tid = program.startTransaction("Auto-analysis")
238
+ try:
239
+ flat_api.analyzeAll(program)
240
+ success = True
241
+ finally:
242
+ program.endTransaction(tid, success)
243
+
244
+
245
+ def _export_all(program, output: Path, timeout: int) -> None:
246
+ """Run all exports against an open Ghidra program."""
247
+ from ghidra.app.decompiler import DecompInterface
248
+ from ghidra.util.task import ConsoleTaskMonitor
249
+
250
+ monitor = ConsoleTaskMonitor()
251
+
252
+ # Set up decompiler
253
+ decomp = DecompInterface()
254
+ decomp.openProgram(program)
255
+
256
+ func_dir = output / "functions"
257
+ if func_dir.exists():
258
+ shutil.rmtree(func_dir)
259
+ func_dir.mkdir(parents=True, exist_ok=True)
260
+
261
+ fm = program.getFunctionManager()
262
+ total = fm.getFunctionCount()
263
+ functions = list(fm.getFunctions(True))
264
+
265
+ click.echo(f"Functions: {total}")
266
+
267
+ # ---- Decompile all functions ----
268
+ callgraph: dict[str, dict] = {}
269
+ failed: list[tuple] = [] # (func, key, filename) for retry
270
+ results_map: dict[
271
+ str, str | None
272
+ ] = {} # key -> content, preserves order for combined file
273
+
274
+ try:
275
+ with click.progressbar(functions, label="Decompiling", show_pos=True) as bar:
276
+ for func in bar:
277
+ addr = func.getEntryPoint().toString()
278
+ name = func.getName()
279
+ key = f"{addr}_{name}"
280
+ safe_name = _sanitize_name(name)
281
+ filename = f"{addr}_{safe_name}.c"
282
+
283
+ try:
284
+ header = _func_header_comment(func)
285
+
286
+ # Decompile
287
+ result = decomp.decompileFunction(func, timeout, monitor)
288
+ if result and result.decompileCompleted():
289
+ decomp_func = result.getDecompiledFunction()
290
+ c_code = decomp_func.getC() if decomp_func else None
291
+ if c_code is not None:
292
+ content = header + c_code
293
+ (func_dir / filename).write_text(content, encoding="utf-8")
294
+ results_map[key] = content
295
+ else:
296
+ failed.append((func, key, filename))
297
+ results_map[key] = None
298
+ else:
299
+ failed.append((func, key, filename))
300
+ results_map[key] = None
301
+ except Exception as e:
302
+ click.echo(
303
+ f"\nWarning: error decompiling {name} at {addr}: {e}", err=True
304
+ )
305
+ results_map[key] = None
306
+ if (func, key, filename) not in failed:
307
+ failed.append((func, key, filename))
308
+
309
+ # Call graph entry (independent of decompilation success)
310
+ try:
311
+ called_funcs = []
312
+ for called in func.getCalledFunctions(None):
313
+ called_funcs.append(
314
+ {
315
+ "address": called.getEntryPoint().toString(),
316
+ "name": called.getName(),
317
+ }
318
+ )
319
+
320
+ calling_funcs = []
321
+ for caller in func.getCallingFunctions(None):
322
+ calling_funcs.append(
323
+ {
324
+ "address": caller.getEntryPoint().toString(),
325
+ "name": caller.getName(),
326
+ }
327
+ )
328
+
329
+ callgraph[addr] = {
330
+ "name": name,
331
+ "address": addr,
332
+ "calls": called_funcs,
333
+ "called_by": calling_funcs,
334
+ }
335
+ except Exception as e:
336
+ click.echo(
337
+ f"\nWarning: error extracting call graph for {name} at {addr}: {e}",
338
+ err=True,
339
+ )
340
+
341
+ # ---- Retry failed functions with escalated timeout ----
342
+ if failed:
343
+ retry_timeout = timeout * 3
344
+ click.echo(
345
+ f"Retrying {len(failed)} failed functions "
346
+ f"with {retry_timeout}s timeout..."
347
+ )
348
+ still_failed = []
349
+ with click.progressbar(failed, label="Retrying", show_pos=True) as bar:
350
+ for func, key, filename in bar:
351
+ try:
352
+ header = _func_header_comment(func)
353
+ result = decomp.decompileFunction(func, retry_timeout, monitor)
354
+ decomp_func = (
355
+ result.getDecompiledFunction()
356
+ if result and result.decompileCompleted()
357
+ else None
358
+ )
359
+ c_code = decomp_func.getC() if decomp_func else None
360
+ if c_code is not None:
361
+ content = header + c_code
362
+ else:
363
+ still_failed.append(key)
364
+ error_msg = (
365
+ result.getErrorMessage() if result else None
366
+ ) or "unknown error"
367
+ asm = _disassemble_function(program, func)
368
+ content = (
369
+ header + f"// DECOMPILATION FAILED: {error_msg}\n" + asm
370
+ )
371
+ (func_dir / filename).write_text(content, encoding="utf-8")
372
+ results_map[key] = content
373
+ except Exception as e:
374
+ click.echo(f"\nWarning: error retrying {key}: {e}", err=True)
375
+ still_failed.append(key)
376
+ # Write a minimal fallback file
377
+ fallback = (
378
+ f"// DECOMPILATION FAILED: exception during retry: {e}\n"
379
+ )
380
+ (func_dir / filename).write_text(fallback, encoding="utf-8")
381
+ results_map[key] = fallback
382
+ failed = still_failed
383
+ finally:
384
+ decomp.dispose()
385
+
386
+ # ---- Combined file ----
387
+ all_chunks = [c for c in results_map.values() if c is not None]
388
+ combined = output / "all_functions.c"
389
+ separator = "\n" + ("/" * 80) + "\n\n"
390
+ combined.write_text(separator.join(all_chunks), encoding="utf-8")
391
+
392
+ if failed:
393
+ click.echo(f"Failed to decompile {len(failed)} functions")
394
+
395
+ # ---- Strings ----
396
+ click.echo("Extracting strings...")
397
+ strings = _extract_strings(program)
398
+
399
+ # ---- Imports / Exports ----
400
+ click.echo("Extracting imports & exports...")
401
+ imports = _extract_imports(program)
402
+ exports = _extract_exports(program)
403
+
404
+ # ---- Symbols (globals, labels, data) ----
405
+ click.echo("Extracting symbols...")
406
+ symbols = _extract_symbols(program)
407
+
408
+ # ---- Types ----
409
+ click.echo("Extracting types...")
410
+ types = _extract_types(program)
411
+
412
+ # ---- Function index ----
413
+ click.echo("Writing function index...")
414
+ func_index = _extract_functions(functions)
415
+
416
+ # ---- Sections ----
417
+ click.echo("Extracting sections...")
418
+ sections = _extract_sections(program)
419
+
420
+ # ---- Metadata ----
421
+ lang = program.getLanguage()
422
+ metadata = {
423
+ "binary": program.getExecutablePath(),
424
+ "format": program.getExecutableFormat(),
425
+ "architecture": lang.getProcessor().toString(),
426
+ "endian": lang.isBigEndian() and "big" or "little",
427
+ "address_size": lang.getDefaultSpace().getSize(),
428
+ "image_base": program.getImageBase().toString(),
429
+ "num_functions": total,
430
+ "num_failed": len(failed),
431
+ "failed_functions": failed,
432
+ }
433
+
434
+ # ---- Write JSON files ----
435
+ _write_json(output / "types.json", types)
436
+ _write_json(output / "functions.json", func_index)
437
+ _write_json(output / "callgraph.json", callgraph)
438
+ _write_json(output / "strings.json", strings)
439
+ _write_json(output / "imports.json", imports)
440
+ _write_json(output / "exports.json", exports)
441
+ _write_json(output / "symbols.json", symbols)
442
+ _write_json(output / "sections.json", sections)
443
+ _write_json(output / "metadata.json", metadata)
444
+
445
+ click.echo(
446
+ f"Wrote {total - len(failed)} functions, "
447
+ f"{len(strings)} strings, "
448
+ f"{len(imports)} imports, "
449
+ f"{len(exports)} exports, "
450
+ f"{len(symbols)} symbols, "
451
+ f"{len(sections)} sections"
452
+ )
453
+
454
+
455
+ def _disassemble_function(program, func) -> str:
456
+ """Return the assembly listing for a function as commented C lines."""
457
+ listing = program.getListing()
458
+ lines = ["// Assembly listing:\n"]
459
+ for instr in listing.getInstructions(func.getBody(), True):
460
+ addr = instr.getAddress()
461
+ mnemonic = instr.getMnemonicString()
462
+ num_ops = instr.getNumOperands()
463
+ operands = ", ".join(
464
+ instr.getDefaultOperandRepresentation(i) for i in range(num_ops)
465
+ )
466
+ lines.append(f"// {addr} {mnemonic} {operands}\n")
467
+ return "".join(lines)
468
+
469
+
470
+ def _extract_strings(program) -> list[dict]:
471
+ """Extract all defined strings with their xrefs."""
472
+ from ghidra.program.model.data import StringDataType
473
+
474
+ ref_manager = program.getReferenceManager()
475
+ fm = program.getFunctionManager()
476
+ listing = program.getListing()
477
+ strings = []
478
+
479
+ for data in listing.getDefinedData(True):
480
+ dt = data.getDataType()
481
+ if not isinstance(dt, StringDataType) and "string" not in dt.getName().lower():
482
+ continue
483
+
484
+ addr = data.getAddress()
485
+ value = data.getValue()
486
+ if value is None:
487
+ continue
488
+ if isinstance(value, (bytes, bytearray)):
489
+ try:
490
+ value = value.decode("utf-8", errors="replace")
491
+ except Exception:
492
+ value = repr(value)
493
+ else:
494
+ value = str(value)
495
+
496
+ # Find xrefs to this string
497
+ xrefs = []
498
+ for ref in ref_manager.getReferencesTo(addr):
499
+ from_addr = ref.getFromAddress()
500
+ func = fm.getFunctionContaining(from_addr)
501
+ xrefs.append(
502
+ {
503
+ "from_address": from_addr.toString(),
504
+ "function": func.getName() if func else None,
505
+ "function_address": func.getEntryPoint().toString()
506
+ if func
507
+ else None,
508
+ }
509
+ )
510
+
511
+ strings.append(
512
+ {
513
+ "address": addr.toString(),
514
+ "value": value,
515
+ "length": data.getLength(),
516
+ "xrefs": xrefs,
517
+ }
518
+ )
519
+
520
+ return strings
521
+
522
+
523
+ def _extract_imports(program) -> list[dict]:
524
+ """Extract imported (external) functions."""
525
+ fm = program.getFunctionManager()
526
+ imports = []
527
+
528
+ for func in fm.getExternalFunctions():
529
+ ext_loc = func.getExternalLocation()
530
+ imports.append(
531
+ {
532
+ "name": func.getName(),
533
+ "library": ext_loc.getLibraryName() if ext_loc else None,
534
+ "address": func.getEntryPoint().toString(),
535
+ }
536
+ )
537
+
538
+ return imports
539
+
540
+
541
+ def _extract_exports(program) -> list[dict]:
542
+ """Extract exported symbols (entry points)."""
543
+ symbol_table = program.getSymbolTable()
544
+ exports = []
545
+
546
+ for symbol in symbol_table.getAllSymbols(True):
547
+ if symbol.isExternalEntryPoint():
548
+ exports.append(
549
+ {
550
+ "name": symbol.getName(),
551
+ "address": symbol.getAddress().toString(),
552
+ }
553
+ )
554
+
555
+ return exports
556
+
557
+
558
+ def _extract_symbols(program) -> list[dict]:
559
+ """Extract non-function symbols: globals, labels, data."""
560
+ from ghidra.program.model.symbol import SymbolType
561
+
562
+ symbol_table = program.getSymbolTable()
563
+ listing = program.getListing()
564
+ skip_types = {SymbolType.FUNCTION, SymbolType.PARAMETER, SymbolType.LOCAL_VAR}
565
+ symbols = []
566
+
567
+ for symbol in symbol_table.getAllSymbols(True):
568
+ sym_type = symbol.getSymbolType()
569
+ if sym_type in skip_types:
570
+ continue
571
+ if symbol.isExternal():
572
+ continue
573
+
574
+ addr = symbol.getAddress()
575
+ entry = {
576
+ "name": symbol.getName(),
577
+ "address": addr.toString(),
578
+ "type": sym_type.toString(),
579
+ "namespace": symbol.getParentNamespace().getName(True),
580
+ "is_primary": symbol.isPrimary(),
581
+ }
582
+
583
+ # Try to get data type and size for data symbols
584
+ data = listing.getDataAt(addr)
585
+ if data is not None:
586
+ dt = data.getDataType()
587
+ entry["data_type"] = dt.getName() if dt else None
588
+ entry["size"] = data.getLength()
589
+
590
+ symbols.append(entry)
591
+
592
+ return symbols
593
+
594
+
595
+ def _extract_types(program) -> list[dict]:
596
+ """Extract all recovered types (structs, enums, typedefs, unions, function pointers)."""
597
+ dtm = program.getDataTypeManager()
598
+
599
+ from ghidra.program.model.data import (
600
+ Structure,
601
+ Union,
602
+ Enum,
603
+ TypeDef,
604
+ FunctionDefinition,
605
+ )
606
+
607
+ BUILTIN = {
608
+ "byte",
609
+ "word",
610
+ "dword",
611
+ "qword",
612
+ "void",
613
+ "bool",
614
+ "char",
615
+ "short",
616
+ "int",
617
+ "long",
618
+ "float",
619
+ "double",
620
+ "uchar",
621
+ "ushort",
622
+ "uint",
623
+ "ulong",
624
+ "undefined",
625
+ "undefined1",
626
+ "undefined2",
627
+ "undefined4",
628
+ "undefined8",
629
+ "pointer",
630
+ "string",
631
+ }
632
+
633
+ result = []
634
+
635
+ for dt in dtm.getAllDataTypes():
636
+ if dt.getName() in BUILTIN:
637
+ continue
638
+
639
+ if isinstance(dt, Structure):
640
+ fields = []
641
+ for comp in dt.getComponents():
642
+ fields.append(
643
+ {
644
+ "name": comp.getFieldName() or f"field_0x{comp.getOffset():x}",
645
+ "type": comp.getDataType().getName(),
646
+ "offset": comp.getOffset(),
647
+ "size": comp.getLength(),
648
+ }
649
+ )
650
+ result.append(
651
+ {
652
+ "kind": "struct",
653
+ "name": dt.getName(),
654
+ "size": dt.getLength(),
655
+ "fields": fields,
656
+ }
657
+ )
658
+
659
+ elif isinstance(dt, Union):
660
+ fields = []
661
+ for comp in dt.getComponents():
662
+ fields.append(
663
+ {
664
+ "name": comp.getFieldName() or f"field_{comp.getOrdinal()}",
665
+ "type": comp.getDataType().getName(),
666
+ "size": comp.getLength(),
667
+ }
668
+ )
669
+ result.append(
670
+ {
671
+ "kind": "union",
672
+ "name": dt.getName(),
673
+ "size": dt.getLength(),
674
+ "fields": fields,
675
+ }
676
+ )
677
+
678
+ elif isinstance(dt, Enum):
679
+ members = {}
680
+ for name in dt.getNames():
681
+ members[name] = dt.getValue(name)
682
+ result.append(
683
+ {
684
+ "kind": "enum",
685
+ "name": dt.getName(),
686
+ "size": dt.getLength(),
687
+ "members": members,
688
+ }
689
+ )
690
+
691
+ elif isinstance(dt, TypeDef):
692
+ base = dt.getBaseDataType()
693
+ result.append(
694
+ {
695
+ "kind": "typedef",
696
+ "name": dt.getName(),
697
+ "base_type": base.getName() if base else "unknown",
698
+ }
699
+ )
700
+
701
+ elif isinstance(dt, FunctionDefinition):
702
+ params = []
703
+ for arg in dt.getArguments():
704
+ params.append(
705
+ {"type": arg.getDataType().getName(), "name": arg.getName()}
706
+ )
707
+ ret = dt.getReturnType()
708
+ result.append(
709
+ {
710
+ "kind": "function_pointer",
711
+ "name": dt.getName(),
712
+ "return_type": ret.getName() if ret else "void",
713
+ "params": params,
714
+ }
715
+ )
716
+
717
+ return result
718
+
719
+
720
+ def _extract_functions(functions) -> list[dict]:
721
+ """Extract function index with address ranges and signatures."""
722
+ result = []
723
+ for func in functions:
724
+ entry = func.getEntryPoint()
725
+ body = func.getBody()
726
+ size = body.getNumAddresses()
727
+ ret_type = func.getReturnType().getName() if func.getReturnType() else "void"
728
+
729
+ params = []
730
+ for p in func.getParameters():
731
+ dt = p.getDataType().getName() if p.getDataType() else "undefined"
732
+ pname = p.getName() or f"param_{p.getOrdinal()}"
733
+ params.append({"type": dt, "name": pname})
734
+
735
+ result.append(
736
+ {
737
+ "name": func.getName(),
738
+ "address": entry.toString(),
739
+ "end_address": body.getMaxAddress().toString(),
740
+ "size": size,
741
+ "return_type": ret_type,
742
+ "params": params,
743
+ "calling_convention": func.getCallingConventionName() or "unknown",
744
+ "is_thunk": func.isThunk(),
745
+ }
746
+ )
747
+ return result
748
+
749
+
750
+ def _extract_sections(program) -> list[dict]:
751
+ """Extract memory sections/segments with permissions."""
752
+ memory = program.getMemory()
753
+ sections = []
754
+
755
+ for block in memory.getBlocks():
756
+ sections.append(
757
+ {
758
+ "name": block.getName(),
759
+ "start": block.getStart().toString(),
760
+ "end": block.getEnd().toString(),
761
+ "size": block.getSize(),
762
+ "readable": block.isRead(),
763
+ "writable": block.isWrite(),
764
+ "executable": block.isExecute(),
765
+ "volatile": block.isVolatile(),
766
+ "initialized": block.isInitialized(),
767
+ "type": block.getType().toString() if block.getType() else "DEFAULT",
768
+ "source": block.getSourceName(),
769
+ }
770
+ )
771
+
772
+ return sections
773
+
774
+
775
+ def _write_json(path: Path, data) -> None:
776
+ """Write data as formatted JSON."""
777
+ path.parent.mkdir(parents=True, exist_ok=True)
778
+ path.write_text(
779
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
780
+ encoding="utf-8",
781
+ )
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghidra-decomp
3
+ Version: 0.2.0
4
+ Summary: Bulk-decompile binaries via Ghidra into a browsable source tree
5
+ Project-URL: Homepage, https://github.com/totekuh/ghidra-decomp
6
+ Project-URL: Repository, https://github.com/totekuh/ghidra-decomp
7
+ Project-URL: Issues, https://github.com/totekuh/ghidra-decomp/issues
8
+ Author-email: totekuh <totekuh@protonmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Disassemblers
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: click
23
+ Requires-Dist: pyghidra
24
+ Description-Content-Type: text/markdown
25
+
26
+ # ghidra-decomp
27
+
28
+ Bulk-decompile binaries into browsable source trees using Ghidra.
29
+
30
+ Takes a binary, runs Ghidra's decompiler on every function, and produces a directory of `.c` files and JSON indexes — ready for grep, code review, or AI-assisted analysis.
31
+
32
+ ## Why
33
+
34
+ Reverse engineering through Ghidra's GUI (or MCP) means looking at one function at a time. This tool dumps everything upfront so you can treat the binary like a normal codebase: grep for patterns, read call graphs, search strings — all without waiting for decompilation round-trips.
35
+
36
+ ## Install
37
+
38
+ Requires [Ghidra](https://ghidra-sre.org/) 11.0+ (which bundles pyghidra).
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ Set `GHIDRA_INSTALL_DIR` to your Ghidra installation, or pass `--ghidra-path`.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ ghidra-decomp ./firmware.bin -o ./firmware_decomp
50
+ ```
51
+
52
+ Options:
53
+
54
+ | Flag | Description | Default |
55
+ |------|-------------|---------|
56
+ | `-o, --output` | Output directory | `<binary>_decomp/` |
57
+ | `--timeout` | Per-function decompilation timeout (seconds) | 60 |
58
+ | `--ghidra-path` | Path to Ghidra install | `$GHIDRA_INSTALL_DIR` |
59
+ | `--base-addr` | Rebase binary to this address before analysis (e.g. `0x80000000`) | none |
60
+ | `--entry` | Mark this address as the entry point and disassemble from it before analysis (e.g. `0x31000`). Useful for raw binaries. | none |
61
+ | `--language` | Force Ghidra language ID (e.g. `ARM:LE:32:v7`). Use when auto-detect fails. | auto |
62
+ | `--compiler` | Force compiler spec ID (e.g. `gcc`, `default`, `windows`). Requires `--language`. | auto |
63
+ | `--list-languages` | List all available language IDs and compiler specs, then exit. | |
64
+
65
+ For raw binaries or unknown formats, Ghidra will refuse to auto-load. Discover the right spec then pass it:
66
+
67
+ ```bash
68
+ ghidra-decomp --list-languages | grep -i arm
69
+ ghidra-decomp ./firmware.bin --language ARM:LE:32:v7 --compiler default
70
+ ```
71
+
72
+ Raw binaries typically also need a known image base and an entry point for auto-analysis to reach every function. Both are applied *before* analysis runs, so function boundaries, xrefs, and switch-table recovery all happen at the real addresses:
73
+
74
+ ```bash
75
+ ghidra-decomp ./dal_ivm.mod \
76
+ --language x86:LE:32:default \
77
+ --compiler gcc \
78
+ --base-addr 0x00031000 \
79
+ --entry 0x00031000
80
+ ```
81
+
82
+ ## Output
83
+
84
+ ```
85
+ firmware_decomp/
86
+ ├── functions/
87
+ │ ├── 00010000_main.c
88
+ │ ├── 00010234_parse_config.c
89
+ │ └── ...
90
+ ├── all_functions.c # everything in one file
91
+ ├── types.json # recovered structs, enums, unions, typedefs
92
+ ├── functions.json # function index with address ranges + signatures
93
+ ├── callgraph.json # who calls who
94
+ ├── strings.json # strings + xrefs to functions
95
+ ├── imports.json # external library functions
96
+ ├── exports.json # exported entry points
97
+ ├── symbols.json # globals, labels, data
98
+ ├── sections.json # memory map with r/w/x permissions
99
+ └── metadata.json # binary info + stats
100
+ ```
101
+
102
+ Each `.c` file includes a metadata header:
103
+
104
+ ```c
105
+ // Function: parse_config
106
+ // Address: 00010234
107
+ // Size: 284 bytes
108
+ // Calling: __stdcall
109
+ // Params: 3
110
+
111
+ void parse_config(char *param_1, int param_2, int param_3) {
112
+ ...
113
+ }
114
+ ```
115
+
116
+ ## Intended workflow
117
+
118
+ 1. **Dump** the binary with `ghidra-decomp`
119
+ 2. **Analyze** the output like source code — grep, glob, read
120
+ 3. **Write back** renames/annotations to Ghidra via MCP (separate tool)
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,9 @@
1
+ ghidra_decomp/__init__.py,sha256=GY7s0TTDI0QuEI3FqyixM9dSKWK6fN7fF1W_BwdHNH0,94
2
+ ghidra_decomp/__main__.py,sha256=ORfTZTRWfazNhQAu0w83dgyRfagD-hvz6-W9x87jz8g,79
3
+ ghidra_decomp/cli.py,sha256=pfy_qO78wxIPZwOujTBD3QtrunzE9tWunJVRrXZv1Ak,3237
4
+ ghidra_decomp/decompiler.py,sha256=5wcNupuRq9pDOmMevVYj1PLkYD8E8hzXFmMu_KhVNRI,26321
5
+ ghidra_decomp-0.2.0.dist-info/METADATA,sha256=pt9ElJWB2G18NaOrPsz54-L_U3bBl--_UBUwsUI9-fs,4480
6
+ ghidra_decomp-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ ghidra_decomp-0.2.0.dist-info/entry_points.txt,sha256=AG0OZjiozVjipC0oNLjeW_J-lsnvwq0qbNmVQW8zMVg,57
8
+ ghidra_decomp-0.2.0.dist-info/licenses/LICENSE,sha256=mm2GcMdlOZDcLYghakgPeQWX9Tz7EdvpxAFTOQogt-E,1064
9
+ ghidra_decomp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghidra-decomp = ghidra_decomp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 totekuh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.