diff options
| author | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-09-23 12:11:04 -0400 |
|---|---|---|
| committer | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-09-23 12:11:04 -0400 |
| commit | 21b8ebc9db1d0ae649d6406087ea22b54e3b5775 (patch) | |
| tree | 192aa5d3c76e0cf3a785936d9902c6848609d37e /userspace/cuda/tools | |
| parent | 631bd20c63a4058775a4fa549b9473915c918020 (diff) | |
| download | open-iov-21b8ebc9db1d0ae649d6406087ea22b54e3b5775.tar.gz open-iov-21b8ebc9db1d0ae649d6406087ea22b54e3b5775.zip | |
userspace/cuda: add CUDA userspace driver analysis, tooling and name maps
Add the analysis of the NVIDIA CUDA userspace driver used by Open-IOV VMs:
* docs/01-08: architecture (layers, object model, threads, sync, memory,
channels, launch), build and execution model, the driver/kernel/GPU
interface contract, virtualization approaches, the 570.144 decompilation
map with the corrected libcuda crash path and the current engine-topology
blocker, tooling, and a version-upgrade playbook.
* tools/: version-agnostic toolchain - stdlib ELF/interface toolkit
(exports, unwind-table function map, wrapper resolution, version delta,
crash address decode), callgraph walker, corpus annotator, and Ghidra
analysis/decompilation/name-map scripts with resumable parallel drivers.
* 570.144/annotations/: our name map (exports + resolved wrappers + verified
internals) and subsystem index, tracked so naming work accumulates.
* READ-ME-FIRST.md: reading guide with the fast lookup recipes.
The full decompiled corpus and annotated corpus stay untracked (vendor-
derived, gitignored under the version run/ tree).
Diffstat (limited to 'userspace/cuda/tools')
| -rwxr-xr-x | userspace/cuda/tools/annotate_corpus.py | 155 | ||||
| -rwxr-xr-x | userspace/cuda/tools/callwalk.py | 133 | ||||
| -rwxr-xr-x | userspace/cuda/tools/cudatool.py | 515 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/ApplyNameMap.java | 86 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/BatchDecompile.java | 141 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/ConfigureAnalysis.java | 84 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/DecompileAt.java | 86 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/ExportFunctionIndex.java | 192 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/PrintProgramInfo.java | 38 | ||||
| -rwxr-xr-x | userspace/cuda/tools/ghidra_batch_decompile.sh | 83 | ||||
| -rwxr-xr-x | userspace/cuda/tools/ghidra_decompile_at.sh | 29 | ||||
| -rwxr-xr-x | userspace/cuda/tools/ghidra_export_index.sh | 27 | ||||
| -rwxr-xr-x | userspace/cuda/tools/ghidra_full_analysis.sh | 49 | ||||
| -rwxr-xr-x | userspace/cuda/tools/ghidra_index_merge.sh | 18 |
14 files changed, 1636 insertions, 0 deletions
diff --git a/userspace/cuda/tools/annotate_corpus.py b/userspace/cuda/tools/annotate_corpus.py new file mode 100755 index 0000000..972f923 --- /dev/null +++ b/userspace/cuda/tools/annotate_corpus.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# annotate_corpus.py - turn the raw decompiled corpus into an annotated, +# human-navigable corpus. +# +# For every function in the decompiled buckets it rewrites the header comment +# with: +# * the recovered name (from a names.tsv map) or the Ghidra FUN_ name; +# * file virtual address and size; +# * direct callers and callees (from callgraph.tsv), labelled with names; +# * a "seen in" marker for functions referenced by the docs (optional). +# It also substitutes recovered names for FUN_<addr> references inside the +# function bodies, so a renamed function is readable everywhere. +# +# Inputs (produced by the toolchain): +# <outdir>/buckets/bucket_*.c raw decompiled C +# <outdir>/decompiled_index.tsv addr file_addr size name file line status +# <indexdir>/callgraph.tsv caller callee caller_file callee_file +# <names.tsv> file_vaddr<TAB>name +# +# Usage: +# annotate_corpus.py <outdir> <indexdir> <names.tsv> <annotated_outdir> +# +# The output directory is gitignored like the raw corpus; this tool is tracked +# and reusable. Addresses are file virtual addresses in all inputs; the corpus +# uses Ghidra program addresses, so the tool translates via the index's +# addr/file_addr columns (no hard-coded image base). + +import os +import re +import sys +from collections import defaultdict + + +def load_index(path): + rows = {} + with open(path) as f: + header = f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 6: + continue + prog = int(p[0], 16) + rows[prog] = dict(file=int(p[1], 16), size=int(p[2]), + name=p[3], file_name=p[4], line=int(p[5])) + return rows + + +def load_callgraph(path): + callees = defaultdict(list) + callers = defaultdict(list) + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + cf, kf = int(p[2], 16), int(p[3], 16) + callees[cf].append(kf) + callers[kf].append(cf) + return callees, callers + + +def load_names(path): + names = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) >= 2: + try: + names[int(p[0], 16)] = p[1] + except ValueError: + pass + return names + + +def label(file_addr, names, index): + name = names.get(file_addr) + if name: + return f"{name} (libcuda+0x{file_addr:x})" + return f"libcuda+0x{file_addr:x}" + + +def main(): + if len(sys.argv) < 5: + print(__doc__) + sys.exit(2) + outdir, indexdir, namemap, out = sys.argv[1:5] + index = load_index(os.path.join(outdir, "decompiled_index.tsv")) + callees, callers = load_callgraph(os.path.join(indexdir, "callgraph.tsv")) + names = load_names(namemap) + # program address -> recovered name, for body substitution + prog_names = {p: names[r["file"]] for p, r in index.items() if r["file"] in names} + + os.makedirs(os.path.join(out, "buckets"), exist_ok=True) + n = 0 + for bucket in sorted(os.listdir(os.path.join(outdir, "buckets"))): + if not bucket.endswith(".c"): + continue + src = os.path.join(outdir, "buckets", bucket) + dst = os.path.join(out, "buckets", bucket) + with open(src) as f: + text = f.read() + + def annotate(m): + nonlocal n + prog = int(m.group("addr"), 16) + file_addr = int(m.group("file"), 16) + size = int(m.group("size")) + rec = index.get(prog, {}) + name = names.get(file_addr, rec.get("name", f"FUN_{prog:08x}")) + cl = sorted(set(callees.get(file_addr, []))) + cr = sorted(set(callers.get(file_addr, []))) + lines = [ + "/* ------------------------------------------------------------------", + f" * {name}", + f" * file_addr=libcuda+0x{file_addr:x} size={size}", + ] + if cr: + shown = [label(a, names, index) for a in cr[:8]] + more = f" (+{len(cr)-8} more)" if len(cr) > 8 else "" + lines.append(" * called by: " + "; ".join(shown) + more) + if cl: + shown = [label(a, names, index) for a in cl[:8]] + more = f" (+{len(cl)-8} more)" if len(cl) > 8 else "" + lines.append(" * calls: " + "; ".join(shown) + more) + lines.append(" *----------------------------------------------------------------- */") + n += 1 + return "\n" + "\n".join(lines) + "\n" + + text = re.sub( + r"\n/\* -+\n \* (?P<name>[^\n]*)\n \* addr=(?P<addr>0x[0-9a-f]+) " + r"file_addr=libcuda\+(?P<file>0x[0-9a-f]+) size=(?P<size>\d+)\n" + r" \*-+ \*/", + annotate, text) + + if prog_names: + def sub(m): + a = int(m.group(1), 16) + return prog_names.get(a, m.group(0)) + text = re.sub(r"\bFUN_([0-9a-f]{8})\b", sub, text) + + with open(dst, "w") as f: + f.write(text) + + print(f"[annotate_corpus] annotated {n} functions -> {out}/buckets") + print(f"[annotate_corpus] names applied: {len(names)}") + + +if __name__ == "__main__": + main() diff --git a/userspace/cuda/tools/callwalk.py b/userspace/cuda/tools/callwalk.py new file mode 100755 index 0000000..15feb45 --- /dev/null +++ b/userspace/cuda/tools/callwalk.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# callwalk.py - walk the Open-IOV callgraph index and print callee trees / +# caller trees around a function. This is the quick way to go from an export +# wrapper to the internal implementation chain without opening Ghidra. +# +# Usage: +# callwalk.py <callgraph.tsv> callees <hex_file_vaddr> [depth] [max_per_node] +# callwalk.py <callgraph.tsv> callers <hex_file_vaddr> [depth] [max_per_node] +# callwalk.py <callgraph.tsv> chain <hex_from> <hex_to> # shortest path +# +# Addresses are FILE virtual addresses (libcuda+0x...); the callgraph columns +# `caller_file`/`callee_file` are used, so Ghidra image-base offsets do not +# matter. `names.tsv` (file_vaddr<TAB>name) is picked up next to the callgraph +# when present, and used to label nodes. +# +# SPDX-License-Identifier: BSD-3-Clause + +import sys +from collections import defaultdict, deque + + +def load_graph(path): + callees = defaultdict(list) + callers = defaultdict(list) + with open(path) as f: + header = f.readline() + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 4: + continue + cf, kf = int(parts[2], 16), int(parts[3], 16) + callees[cf].append(kf) + callers[kf].append(cf) + return callees, callers + + +def load_names(path): + names = {} + try: + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) >= 2: + try: + names[int(parts[0], 16)] = parts[1] + except ValueError: + pass + except FileNotFoundError: + pass + return names + + +def label(a, names): + return f"0x{a:08x}({names[a]})" if a in names else f"0x{a:08x}" + + +def walk(adj, start, depth, limit): + seen = {start} + frontier = [start] + for d in range(depth): + nxt = [] + for node in frontier: + kids = sorted(set(adj.get(node, []))) + if not kids: + continue + print(" " * (d + 1) + " | ".join(label(k, NAMES) for k in kids[:limit]) + + (" ..." if len(kids) > limit else "")) + for k in kids: + if k not in seen: + seen.add(k) + nxt.append(k) + frontier = nxt + if not frontier: + break + + +def shortest_path(callees, start, goal): + if start == goal: + return [start] + prev = {start: None} + q = deque([start]) + while q: + node = q.popleft() + for k in callees.get(node, []): + if k in prev: + continue + prev[k] = node + if k == goal: + path = [goal] + while prev[path[-1]] is not None: + path.append(prev[path[-1]]) + return list(reversed(path)) + q.append(k) + return None + + +def main(): + if len(sys.argv) < 4: + print(__doc__) + sys.exit(2) + graph = sys.argv[1] + mode = sys.argv[2] + addr = int(sys.argv[3], 16) + depth = int(sys.argv[4]) if len(sys.argv) > 4 else 4 + limit = int(sys.argv[5]) if len(sys.argv) > 5 else 8 + + global NAMES + callees, callers = load_graph(graph) + NAMES = load_names(graph.rsplit("/", 1)[0] + "/names.tsv") + + if mode == "chain": + goal = int(sys.argv[4], 16) + path = shortest_path(callees, addr, goal) + if not path: + print("no direct path found") + else: + for a in path: + print(label(a, NAMES)) + return + + print(f"{mode} of {label(addr, NAMES)}:") + walk(callees if mode == "callees" else callers, addr, depth, limit) + + +NAMES = {} + +if __name__ == "__main__": + main() diff --git a/userspace/cuda/tools/cudatool.py b/userspace/cuda/tools/cudatool.py new file mode 100755 index 0000000..2e4c1c6 --- /dev/null +++ b/userspace/cuda/tools/cudatool.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# cudatool.py - Open-IOV CUDA userspace driver analysis toolkit. +# +# A dependency-free (stdlib-only) ELF toolkit for the NVIDIA CUDA userspace +# driver, usable on every driver version without a Ghidra project. It gives +# the fast facts that do not require decompilation: exports, function ranges +# derived from the exception-handling tables, the CUDA forward-compatibility +# note, and a dmesg/crash-address decoder. It is also the version-delta tool: +# `delta` compares two driver versions and reports exactly what changed at +# the interface level, which is what keeps the Open-IOV side anti-fragile +# across driver upgrades. +# +# Commands +# info <lib> ELF/version summary +# exports <lib> [--json] exported symbols (file vaddrs) +# functions <lib> [--json] function ranges from .eh_frame_hdr +# nearest <lib> <hex_addr> nearest preceding function/export for a vaddr +# crash <lib> <map_base> <ip> dmesg mapping base + ip -> file vaddr + symbol +# strings <lib> [--grep RE] defined strings with file offsets +# delta <libA> <libB> interface-level diff between two versions +# wrappers <lib> export stub -> generated wrapper target +# note <lib> CUDA forward-compatibility note (JSON) +# +# Address convention: every address printed by this tool is a FILE VIRTUAL +# ADDRESS, i.e. the `libcuda+0x...` numbers used in the project docs. Ghidra +# may load the same file at a non-zero image base (0x100000 for these builds); +# use tools/ghidra_decompile_at.sh which translates file vaddrs automatically. +# +# License note: this tool only *reads* the vendor binary for interoperability +# analysis; it contains no vendor code and is published under BSD-3-Clause. + +import argparse +import json +import re +import struct +import sys + +ELF_MAGIC = b"\x7fELF" + + +class ELFError(Exception): + pass + + +class ELF: + """Minimal 64-bit little-endian ELF reader.""" + + def __init__(self, path): + self.path = path + with open(path, "rb") as f: + self.data = f.read() + if self.data[:4] != ELF_MAGIC: + raise ELFError(f"{path}: not an ELF file") + if self.data[4] != 2: + raise ELFError(f"{path}: not ELF64") + if self.data[5] != 1: + raise ELFError(f"{path}: not little-endian") + (self.e_type, self.e_machine, _v, self.e_entry, self.e_phoff, self.e_shoff, + self.e_flags, self.e_ehsize, self.e_phentsize, self.e_phnum, + self.e_shentsize, self.e_shnum, self.e_shstrndx) = struct.unpack_from( + "<HHIQQQIHHHHHH", self.data, 16) + + self.program_headers = [] + for i in range(self.e_phnum): + off = self.e_phoff + i * self.e_phentsize + (p_type, p_flags, p_offset, p_vaddr, p_paddr, p_filesz, p_memsz, + p_align) = struct.unpack_from("<IIQQQQQQ", self.data, off) + self.program_headers.append(dict( + type=p_type, flags=p_flags, offset=p_offset, vaddr=p_vaddr, + paddr=p_paddr, filesz=p_filesz, memsz=p_memsz, align=p_align, + index=i)) + + self.sections = [] + self.section_by_name = {} + for i in range(self.e_shnum): + off = self.e_shoff + i * self.e_shentsize + (sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, + sh_link, sh_info, sh_addralign, sh_entsize) = struct.unpack_from( + "<IIQQQQIIQQ", self.data, off) + self.sections.append(dict( + index=i, name_off=sh_name, type=sh_type, flags=sh_flags, + addr=sh_addr, offset=sh_offset, size=sh_size, link=sh_link, + info=sh_info, addralign=sh_addralign, entsize=sh_entsize)) + if self.e_shstrndx: + shstr = self.sections[self.e_shstrndx] + base = shstr["offset"] + for s in self.sections: + end = self.data.find(b"\0", base + s["name_off"]) + s["name"] = self.data[base + s["name_off"]:end].decode( + "utf-8", "replace") + self.section_by_name[s["name"]] = s + + # ---- helpers --------------------------------------------------------- + + def section(self, name): + return self.section_by_name.get(name) + + def exec_segment_base(self): + """Base address reported by dmesg/libcuda mappings: first executable + PT_LOAD vaddr aligned as Linux maps it (page-aligned start).""" + for ph in self.program_headers: + if ph["type"] == 1 and (ph["flags"] & 1): # PT_LOAD | PF_X + return ph["vaddr"] & ~0xFFF + raise ELFError("no executable PT_LOAD segment") + + def read_string(self, offset): + end = self.data.find(b"\0", offset) + return self.data[offset:end].decode("utf-8", "replace") + + def build_id(self): + for s in self.sections: + if s["name"] == ".note.gnu.build-id": + off = s["offset"] + namesz, descsz, ntype = struct.unpack_from("<III", self.data, off) + name = self.data[off + 12:off + 12 + namesz].rstrip(b"\0") + if name == b"GNU" and ntype == 3: + desc = self.data[off + 12 + ((namesz + 3) & ~3): + off + 12 + ((namesz + 3) & ~3) + descsz] + return desc.hex() + return None + + # ---- symbols --------------------------------------------------------- + + def dynamic_symbols(self): + symtab = self.section(".dynsym") + if not symtab: + return [] + strtab = self.sections[symtab["link"]] + out = [] + n = symtab["size"] // 24 + for i in range(n): + off = symtab["offset"] + i * 24 + st_name, st_info, st_other, st_shndx, st_value, st_size = \ + struct.unpack_from("<IBBHQQ", self.data, off) + name = self.read_string(strtab["offset"] + st_name) + out.append(dict( + name=name, + value=st_value, + size=st_size, + type=st_info & 0xF, + bind=st_info >> 4, + shndx=st_shndx, + defined=st_shndx != 0, + )) + return out + + def exports(self): + return [s for s in self.dynamic_symbols() + if s["defined"] and s["type"] == 2 and s["value"]] + + def imports(self): + return [s for s in self.dynamic_symbols() + if not s["defined"] and s["name"]] + + # ---- exception handling function table ------------------------------- + + def function_ranges(self): + """Return [(start, end)] from .eh_frame_hdr. + + .eh_frame_hdr layout (DWARF .eh_frame_hdr, version 1): + u8 version; u8 eh_frame_ptr_enc; u8 fde_count_enc; u8 table_enc; + encoded eh_frame_ptr; encoded fde_count; + table[count] { encoded pc_begin; encoded fde_addr; } + + GCC emits: eh_frame_ptr_enc = DW_EH_PE_pcrel|sdata4 (0x1b), + fde_count_enc = udata4 (0x03), table_enc = DW_EH_PE_datarel|sdata4 + (0x3b). The datarel anchor is the start of .eh_frame_hdr. The parser + below handles the encodings generally (pcrel/datarel + 1/2/4/8-byte + and ULEB/SLEB), so it also works for other toolchains. + """ + hdr = self.section(".eh_frame_hdr") + if not hdr: + return [] + base = hdr["offset"] + anchor = hdr["addr"] + version = self.data[base] + if version != 1: + raise ELFError(f"unsupported .eh_frame_hdr version {version}") + eh_ptr_enc, fde_count_enc, table_enc = self.data[base + 1:base + 4] + + def enc_size(enc): + return {0x0: 0, 0x1: 1, 0x2: 2, 0x3: 4, 0x4: 8, + 0x9: 1, 0xA: 2, 0xB: 4, 0xC: 8}.get(enc & 0x0F, 0) + + def decode(enc, ptr, field_addr): + fmt = enc & 0x0F + if fmt == 0x0: + return 0 + if fmt == 0x1: + val, shift = 0, 0 + while True: + b = self.data[ptr] + ptr += 1 + val |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + return val + if fmt == 0x2: + return struct.unpack_from("<H", self.data, ptr)[0] + if fmt == 0x3: + return struct.unpack_from("<I", self.data, ptr)[0] + if fmt == 0x4: + return struct.unpack_from("<Q", self.data, ptr)[0] + if fmt == 0x9: + val, shift = 0, 0 + while True: + b = self.data[ptr] + ptr += 1 + val |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + if b & 0x40: + val -= 1 << shift + break + return val + if fmt == 0xA: + return struct.unpack_from("<h", self.data, ptr)[0] + if fmt == 0xB: + return struct.unpack_from("<i", self.data, ptr)[0] + if fmt == 0xC: + return struct.unpack_from("<q", self.data, ptr)[0] + raise ELFError(f"unsupported eh encoding 0x{enc:02x}") + + def absolute(enc, ptr): + val = decode(enc, ptr, anchor) + kind = enc & 0x70 + if kind == 0x10: # DW_EH_PE_pcrel: relative to field address + val += anchor + (ptr - base) + elif kind == 0x30: # DW_EH_PE_datarel + val += anchor + return val + + p = base + 4 + p += enc_size(eh_ptr_enc) # skip eh_frame_ptr + count = decode(fde_count_enc, p, anchor) + p += enc_size(fde_count_enc) + starts = [] + for _ in range(count): + pc = absolute(table_enc, p) + p += enc_size(table_enc) + p += enc_size(table_enc) # skip fde address + starts.append(pc) + starts.sort() + out = [] + for i, pc in enumerate(starts): + end = starts[i + 1] if i + 1 < len(starts) else None + out.append((pc, end)) + return out + + # ---- CUDA forward compatibility note --------------------------------- + + def cuda_note(self): + s = self.section(".note.cuda.fwd_compatibility") + if not s: + return None + off = s["offset"] + namesz, descsz, ntype = struct.unpack_from("<III", self.data, off) + name_off = off + 12 + name = self.data[name_off:name_off + namesz].rstrip(b"\0").decode() + desc_off = name_off + ((namesz + 3) & ~3) + desc = self.data[desc_off:desc_off + descsz].rstrip(b"\0").decode() + try: + parsed = json.loads(desc) + except json.JSONDecodeError: + parsed = None + return dict(owner=name, raw=desc, parsed=parsed) + + def debuglink(self): + s = self.section(".gnu_debuglink") + if not s: + return None + name = self.read_string(s["offset"]) + crc = struct.unpack_from("<I", self.data, s["offset"] + (len(name) + 4) // 4 * 4)[0] + return dict(name=name, crc=crc) + + # ---- export stubs -> generated wrapper --------------------------------- + + def relative_relocs(self): + """Map address -> R_X86_64_RELATIVE addend (the initial slot value).""" + out = {} + s = self.section(".rela.dyn") + if not s: + return out + n = s["size"] // 24 + for i in range(n): + off = s["offset"] + i * 24 + r_offset, r_info, r_addend = struct.unpack_from("<QQq", self.data, off) + if (r_info & 0xFFFFFFFF) == 8: # R_X86_64_RELATIVE + out[r_offset] = r_addend + return out + + def wrappers(self): + """Resolve each exported cu* stub to its initial wrapper target. + + The generated stubs are: + cmp dword ptr [rip+d], 0x321cba00 (7 bytes) + je +0xc (2 bytes) + jmp qword ptr [rip+d] (6 bytes, disp at stub+14) + so the interposition slot is stub + 18 + disp32. Its initial value is + set by a R_X86_64_RELATIVE relocation and points at the generated + wrapper (tracing/tool dispatch), whose first callee is the hand-written + implementation. + """ + relocs = self.relative_relocs() + out = [] + for s in self.exports(): + if not s["name"].startswith("cu"): + continue + a = s["value"] + # sanity-check the cmp/jmp pattern before trusting the arithmetic + if self.data[a] != 0x81 or self.data[a + 10] != 0x74 or \ + self.data[a + 12] != 0xFF or self.data[a + 13] != 0x25: + continue + disp = struct.unpack_from("<i", self.data, a + 14)[0] + slot = a + 18 + disp + target = relocs.get(slot) + out.append(dict(name=s["name"], stub=a, slot=slot, wrapper=target)) + return out + + def text_bounds(self): + t = self.section(".text") + if not t: + return None + return (t["addr"], t["addr"] + t["size"]) + + +# ---- commands ------------------------------------------------------------ + + +def cmd_info(args): + elf = ELF(args.lib) + tb = elf.text_bounds() + note = elf.cuda_note() + print(f"file : {args.lib}") + print(f"build id : {elf.build_id()}") + print(f"e_type : {elf.e_type} (3=DYN shared object)") + print(f"exec segment base: 0x{elf.exec_segment_base():x}") + print(f".text : 0x{tb[0]:x} .. 0x{tb[1]:x} ({tb[1]-tb[0]} bytes)") + print(f"exports : {len(elf.exports())}") + print(f"imports : {len(elf.imports())}") + if note and note["parsed"]: + p = note["parsed"] + print(f"cuda note : CUDA {p.get('CUDA version')}, " + f"driver ABI {p.get('driver')}, device {p.get('device')}") + dl = elf.debuglink() + if dl: + print(f"debuglink : {dl['name']} (crc 0x{dl['crc']:08x})") + for s in elf.sections: + print(f" section {s['name']:<28} addr=0x{s['addr']:08x} " + f"off=0x{s['offset']:08x} size={s['size']}") + + +def cmd_exports(args): + elf = ELF(args.lib) + ex = sorted(elf.exports(), key=lambda s: s["value"]) + if args.json: + print(json.dumps([{k: v for k, v in s.items()} for s in ex], indent=1)) + return + for s in ex: + print(f"0x{s['value']:08x} {s['size']:>8} {s['name']}") + + +def cmd_functions(args): + elf = ELF(args.lib) + fr = elf.function_ranges() + if args.json: + print(json.dumps([dict(start=hex(a), end=hex(b) if b else None) + for a, b in fr], indent=1)) + return + for a, b in fr: + print(f"0x{a:08x} 0x{b:08x}" if b else f"0x{a:08x} -") + + +def cmd_nearest(args): + elf = ELF(args.lib) + target = int(args.addr, 16) + ex = sorted(elf.exports(), key=lambda s: s["value"]) + prev = None + for s in ex: + if s["value"] <= target: + prev = s + else: + break + fr = elf.function_ranges() + fstart = None + for a, b in fr: + if a <= target and (b is None or target < b): + fstart = a + break + print(f"vaddr : 0x{target:x}") + print(f"function start : 0x{fstart:x}" if fstart is not None else + "function start : <not covered by .eh_frame_hdr>") + if prev: + print(f"nearest export : {prev['name']} + 0x{target - prev['value']:x} " + f"(export at 0x{prev['value']:x})") + else: + print("nearest export : <none>") + + +def cmd_crash(args): + elf = ELF(args.lib) + base = int(args.map_base, 16) + ip = int(args.ip, 16) + vaddr = ip - base + elf.exec_segment_base() + print(f"file vaddr : 0x{vaddr:x}") + args.addr = hex(vaddr) + cmd_nearest(args) + + +def cmd_strings(args): + elf = ELF(args.lib) + pat = re.compile(args.grep) if args.grep else None + rodata = [s for s in elf.sections if s["type"] == 1 and not (s["flags"] & 4)] + seen = set() + for s in rodata: + blob = elf.data[s["offset"]:s["offset"] + s["size"]] + for m in re.finditer(rb"[\x20-\x7e]{5,}", blob): + text = m.group().decode() + if pat and not pat.search(text): + continue + key = (s["name"], m.start()) + if key in seen: + continue + seen.add(key) + print(f"0x{s['addr'] + m.start():08x} {text}") + + +def cmd_delta(args): + a, b = ELF(args.libA), ELF(args.libB) + ea = {s["name"]: s for s in a.exports()} + eb = {s["name"]: s for s in b.exports()} + added = sorted(set(eb) - set(ea)) + removed = sorted(set(ea) - set(eb)) + moved = [] + resized = [] + for n in sorted(set(ea) & set(eb)): + if ea[n]["value"] != eb[n]["value"]: + moved.append((n, ea[n]["value"], eb[n]["value"])) + if ea[n]["size"] != eb[n]["size"]: + resized.append((n, ea[n]["size"], eb[n]["size"])) + print(f"A: {args.libA}") + print(f" build-id {a.build_id()} exports {len(ea)}") + print(f"B: {args.libB}") + print(f" build-id {b.build_id()} exports {len(eb)}") + na, nb = a.cuda_note(), b.cuda_note() + print(f"note A: {na['raw'] if na else None}") + print(f"note B: {nb['raw'] if nb else None}") + tb_a, tb_b = a.text_bounds(), b.text_bounds() + print(f"text A: 0x{tb_a[0]:x}..0x{tb_a[1]:x} ({tb_a[1]-tb_a[0]})") + print(f"text B: 0x{tb_b[0]:x}..0x{tb_b[1]:x} ({tb_b[1]-tb_b[0]})") + print(f"exports added ({len(added)}): " + ", ".join(added[:40]) + + (" ..." if len(added) > 40 else "")) + print(f"exports removed ({len(removed)}): " + ", ".join(removed[:40]) + + (" ..." if len(removed) > 40 else "")) + print(f"exports moved ({len(moved)})" + + (": " + ", ".join(f"{n} 0x{x:x}->0x{y:x}" for n, x, y in moved[:10]) + if moved else "")) + print(f"exports resized ({len(resized)})" + + (": " + ", ".join(f"{n} {x}->{y}" for n, x, y in resized[:10]) + if resized else "")) + print(f"imports A={len(a.imports())} B={len(b.imports())}") + + +def cmd_wrappers(args): + elf = ELF(args.lib) + ws = elf.wrappers() + if args.json: + print(json.dumps(ws, indent=1)) + return + for w in ws: + if w["wrapper"] is None: + continue + print(f"0x{w['stub']:08x} -> 0x{w['wrapper']:08x} {w['name']}") + + +def cmd_note(args): + elf = ELF(args.lib) + print(json.dumps(elf.cuda_note(), indent=2)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("info"); p.add_argument("lib"); p.set_defaults(fn=cmd_info) + p = sub.add_parser("exports"); p.add_argument("lib") + p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_exports) + p = sub.add_parser("functions"); p.add_argument("lib") + p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_functions) + p = sub.add_parser("nearest"); p.add_argument("lib"); p.add_argument("addr") + p.set_defaults(fn=cmd_nearest) + p = sub.add_parser("crash"); p.add_argument("lib") + p.add_argument("map_base"); p.add_argument("ip"); p.set_defaults(fn=cmd_crash) + p = sub.add_parser("strings"); p.add_argument("lib") + p.add_argument("--grep"); p.set_defaults(fn=cmd_strings) + p = sub.add_parser("delta"); p.add_argument("libA"); p.add_argument("libB") + p.set_defaults(fn=cmd_delta) + p = sub.add_parser("note"); p.add_argument("lib"); p.set_defaults(fn=cmd_note) + p = sub.add_parser("wrappers"); p.add_argument("lib") + p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_wrappers) + + args = ap.parse_args() + try: + args.fn(args) + except ELFError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/userspace/cuda/tools/ghidra/ApplyNameMap.java b/userspace/cuda/tools/ghidra/ApplyNameMap.java new file mode 100644 index 0000000..b8be68f --- /dev/null +++ b/userspace/cuda/tools/ghidra/ApplyNameMap.java @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// ApplyNameMap.java - Ghidra post-script: apply a symbol name map to a +// program. This is the version-transfer hook of the Open-IOV CUDA tooling: +// once a name map exists for version N (derived from public documentation, +// decompiled behaviour or an earlier version), it can be applied to version +// N+1 in seconds, so analysis effort accumulates instead of restarting. +// +// Input format (TSV, no header required): +// <hex-addr> <name> [<optional namespace>] +// Lines starting with '#' and empty lines are ignored. Addresses are file +// virtual addresses ("libcuda+0x..."), not Ghidra image-base offsets. +// +// Usage (analyzeHeadless): +// -process <program> -noanalysis \ +// -postScript ApplyNameMap.java <namemap.tsv> [--dry-run] +// +// The script prints how many names were applied, and leaves a +// "cuda-names-applied" user property on the program with the map path and +// count so a later session can see it happened. + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Listing; +import ghidra.program.model.symbol.SourceType; + +import java.io.BufferedReader; +import java.io.FileReader; + +public class ApplyNameMap extends GhidraScript { + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: ApplyNameMap.java <namemap.tsv> [--dry-run]"); + return; + } + boolean dryRun = args.length > 1 && args[1].equals("--dry-run"); + Listing listing = currentProgram.getListing(); + long imageBase = currentProgram.getImageBase().getOffset(); + int applied = 0, missing = 0, lines = 0; + + try (BufferedReader r = new BufferedReader(new FileReader(args[0]))) { + String line; + while ((line = r.readLine()) != null && !monitor.isCancelled()) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + lines++; + String[] parts = line.split("[ \t]+"); + if (parts.length < 2) continue; + long addr; + try { + addr = Long.parseUnsignedLong(parts[0].replaceFirst("^0x", ""), 16); + } catch (NumberFormatException e) { + continue; + } + String name = parts[1]; + // Map keys are file virtual addresses (libcuda+0x...). + Address a = toAddr(addr + imageBase); + Function f = listing.getFunctionAt(a); + if (f == null) { + missing++; + continue; + } + if (!dryRun) { + try { + f.setName(name, SourceType.USER_DEFINED); + } catch (Exception e) { + println(" cannot rename 0x" + Long.toHexString(addr) + + " -> " + name + ": " + e.getMessage()); + continue; + } + } + applied++; + } + } + if (!dryRun) { + currentProgram.getOptions("OpenIOV") + .setString("cuda-names-applied", args[0] + " (" + applied + " names)"); + } + println(String.format("[ApplyNameMap] lines=%d applied=%s missing=%d%s", + lines, String.valueOf(applied), missing, dryRun ? " (dry run)" : "")); + } +} diff --git a/userspace/cuda/tools/ghidra/BatchDecompile.java b/userspace/cuda/tools/ghidra/BatchDecompile.java new file mode 100644 index 0000000..0319681 --- /dev/null +++ b/userspace/cuda/tools/ghidra/BatchDecompile.java @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// BatchDecompile.java - Ghidra post-script: decompile every function whose +// entry address lies in [start, end) and write the C output plus a +// machine-readable index. +// +// Designed for a very large stripped vendor library (~100k functions, 70 MB). +// The caller splits the code address range into chunks and runs one +// analyzeHeadless process per chunk (all with -readOnly against the same +// analyzed project), which parallelizes and makes the run resumable: a chunk +// whose output already exists is skipped. +// +// Usage (analyzeHeadless, on an already analyzed program): +// -process <program> -readOnly -noanalysis \ +// -postScript BatchDecompile.java <startAddrHex> <endAddrHex> <outdir> [timeoutSecs] +// +// Outputs: +// <outdir>/buckets/bucket_<start>_<end>.c decompiled C, functions in order +// <outdir>/index/bucket_<start>_<end>.tsv addr size name file line status +// +// Decompiled output is vendor-derived analysis output: it stays in the +// version's gitignored run/ tree and is NEVER committed. + +import ghidra.app.script.GhidraScript; +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Listing; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.PrintWriter; + +public class BatchDecompile extends GhidraScript { + + private static int countLines(String s) { + int n = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '\n') n++; + } + return n; + } + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 3) { + println("usage: BatchDecompile.java <startAddrHex> <endAddrHex> <outdir> [timeoutSecs]"); + return; + } + long start = Long.parseUnsignedLong(args[0].replaceFirst("^0x", ""), 16); + long end = Long.parseUnsignedLong(args[1].replaceFirst("^0x", ""), 16); + String outdir = args[2]; + int timeout = args.length > 3 ? Integer.parseInt(args[3]) : 120; + + File buckets = new File(outdir, "buckets"); + File indexDir = new File(outdir, "index"); + buckets.mkdirs(); + indexDir.mkdirs(); + String tag = String.format("%08x_%08x", start, end); + File outFile = new File(buckets, "bucket_" + tag + ".c"); + File idxFile = new File(indexDir, "bucket_" + tag + ".tsv"); + + if (outFile.isFile() && idxFile.isFile()) { + println("[BatchDecompile] chunk " + tag + " already done, skipping"); + return; + } + + Listing listing = currentProgram.getListing(); + long imageBase = currentProgram.getImageBase().getOffset(); + DecompInterface dif = new DecompInterface(); + dif.toggleCCode(true); + dif.toggleSyntaxTree(false); + dif.openProgram(currentProgram); + + int n = 0, fail = 0, skip = 0, lines = 0; + + try (PrintWriter idx = new PrintWriter(new BufferedWriter(new FileWriter(idxFile))); + PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(outFile)))) { + + String banner = "/* CUDA userspace driver decompilation - Open-IOV project\n" + + " * program: " + currentProgram.getName() + "\n" + + " * range: [0x" + Long.toHexString(start) + ", 0x" + + Long.toHexString(end) + ")\n" + + " * Machine-generated analysis output; not part of the public tree.\n" + + " */\n"; + out.print(banner); + lines += countLines(banner); + idx.println("addr\tfile_addr\tsize\tname\tfile\tline\tstatus"); + + FunctionIterator fit = listing.getFunctions(true); + while (fit.hasNext() && !monitor.isCancelled()) { + Function f = fit.next(); + if (f.isExternal()) continue; + long addr = f.getEntryPoint().getOffset(); + if (addr < start || addr >= end) continue; + + long size = f.getBody().getNumAddresses(); + String header = String.format( + "\n/* ------------------------------------------------------------------" + + "----------\n" + + " * %s\n" + + " * addr=0x%08x file_addr=libcuda+0x%x size=%d\n" + + " *-------------------------------------------------------------------" + + "-------- */\n", + f.getName(), addr, addr - imageBase, size); + out.print(header); + lines += countLines(header); + + String status = "ok"; + String code; + if (size > 200000) { + status = "skipped-huge"; + skip++; + code = "/* skipped: body too large (" + size + " bytes) */\n"; + } else { + DecompileResults res = dif.decompileFunction(f, timeout, monitor); + if (res != null && res.decompileCompleted()) { + code = res.getDecompiledFunction().getC(); + } else { + status = "failed"; + fail++; + code = "/* decompilation failed */\n"; + } + } + out.print(code); + out.print("\n"); + lines += countLines(code) + 1; + idx.printf("0x%08x\t0x%08x\t%d\t%s\t%s\t%d\t%s%n", + addr, addr - imageBase, size, f.getName(), + outFile.getName(), lines - countLines(code), status); + n++; + } + } + + println(String.format("[BatchDecompile] %s: functions=%d failed=%d skipped=%d -> %s", + tag, n, fail, skip, outFile.getAbsolutePath())); + } +} diff --git a/userspace/cuda/tools/ghidra/ConfigureAnalysis.java b/userspace/cuda/tools/ghidra/ConfigureAnalysis.java new file mode 100644 index 0000000..71bb75e --- /dev/null +++ b/userspace/cuda/tools/ghidra/ConfigureAnalysis.java @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// ConfigureAnalysis.java - Ghidra pre-script used by the Open-IOV CUDA +// tooling to make headless auto-analysis of a large stripped vendor library +// tractable and reproducible. +// +// The CUDA userspace driver is a ~70 MB, fully stripped x86-64 shared object +// with roughly a hundred thousand functions. The default analyzer set spends +// the majority of its time in the two optional "identifier" analyzers, which +// are not needed to obtain readable decompilation: +// +// * Decompiler Parameter ID - decompiles every function to recover +// parameter types (very expensive, no value +// for a stripped binary where we have no +// type definitions for internal structs) +// * Function ID (FID) - matches function bodies against .fidb +// signature databases (liberal, and we have +// no curated database for this toolchain) +// +// Everything else (disassembly, function start search, aggressive instruction +// finder, exception handling, stack analysis, reference analyzers, ...) is +// kept, because function discovery quality on a stripped binary depends on +// them. +// +// Usage (analyzeHeadless): +// -preScript ConfigureAnalysis.java [verbose] +// +// Re-runnable: the same settings are applied on every analysis of a program. + +import ghidra.app.script.GhidraScript; +import ghidra.framework.options.Options; + +public class ConfigureAnalysis extends GhidraScript { + + private static final String[] DISABLE = { + "Decompiler Parameter ID", + "Function ID", + }; + + private static final String[] ENABLE = { + "Aggressive Instruction Finder", + "GCC Exception Handlers", + "Function Start Search", + "Shared Return Calls", + "Non-Returning Functions - Known", + "Non-Returning Functions - Discovered", + }; + + @Override + public void run() throws Exception { + boolean verbose = getScriptArgs().length > 0; + Options opts = currentProgram.getOptions("Analyzers"); + + if (verbose) { + println("[ConfigureAnalysis] analyzer options before:"); + for (String name : opts.getOptionNames()) { + println(" " + name + " = " + opts.getObject(name, null)); + } + } + + for (String name : DISABLE) { + if (opts.contains(name)) { + opts.setBoolean(name, false); + println("[ConfigureAnalysis] disabled: " + name); + } else { + println("[ConfigureAnalysis] WARN not found: " + name); + } + } + + for (String name : ENABLE) { + if (opts.contains(name)) { + opts.setBoolean(name, true); + println("[ConfigureAnalysis] enabled: " + name); + } + } + + if (verbose) { + println("[ConfigureAnalysis] analyzer options after:"); + for (String name : opts.getOptionNames()) { + println(" " + name + " = " + opts.getObject(name, null)); + } + } + } +} diff --git a/userspace/cuda/tools/ghidra/DecompileAt.java b/userspace/cuda/tools/ghidra/DecompileAt.java new file mode 100644 index 0000000..ed8cf5d --- /dev/null +++ b/userspace/cuda/tools/ghidra/DecompileAt.java @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// DecompileAt.java - Ghidra post-script: decompile the function containing a +// given file virtual address. Version-agnostic copy of the per-version +// helper used with ghidra_decompile_at.sh. +// +// Usage: +// analyzeHeadless <proj> <name> -process <program> -readOnly -noanalysis \ +// -postScript DecompileAt.java <hex-vaddr> [outfile] +// +// If the address does not fall inside a known function, the script tries to +// disassemble at the address; if the target is outside any function body it +// prints a comment with the nearest preceding function instead of failing. + +import ghidra.app.script.GhidraScript; +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Listing; + +import java.io.FileWriter; + +public class DecompileAt extends GhidraScript { + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: DecompileAt.java <hex-vaddr> [outfile]"); + return; + } + long fileAddr = Long.parseUnsignedLong(args[0].replaceFirst("^0x", ""), 16); + // Arguments are FILE virtual addresses (libcuda+0x...). Ghidra may have + // loaded the program at a non-zero image base (0x100000 in this build), + // so translate before resolving. If image base is 0 this is a no-op. + long imageBase = currentProgram.getImageBase().getOffset(); + Address addr = toAddr(fileAddr + imageBase); + long target = addr.getOffset(); + + Function f = getFunctionContaining(addr); + if (f == null) { + Listing listing = currentProgram.getListing(); + FunctionIterator it = listing.getFunctions(addr, false); + if (it.hasNext()) f = it.next(); + } + if (f == null) { + disassemble(addr); + f = createFunction(addr, null); + } + StringBuilder sb = new StringBuilder(); + if (f == null) { + sb.append("// no function at 0x").append(Long.toHexString(target)).append("\n"); + } else { + DecompInterface dif = new DecompInterface(); + dif.toggleCCode(true); + dif.toggleSyntaxTree(true); + dif.openProgram(currentProgram); + DecompileResults res = dif.decompileFunction(f, 300, monitor); + sb.append("// function ").append(f.getName()) + .append(" at libcuda+0x") + .append(Long.toHexString(f.getEntryPoint().getOffset() - imageBase)) + .append(" (program 0x").append(Long.toHexString(f.getEntryPoint().getOffset())) + .append(", query file 0x").append(Long.toHexString(fileAddr)).append(")\n"); + if (res != null && res.decompileCompleted()) { + sb.append("#include <stdint.h>\n") + .append("typedef unsigned char undefined1;\n") + .append("typedef unsigned short undefined2;\n") + .append("typedef unsigned int undefined4;\n") + .append("typedef unsigned long long undefined8;\n") + .append("typedef long long longlong;\n") + .append("typedef unsigned long long ulonglong;\n\n") + .append(res.getDecompiledFunction().getC()); + } else { + sb.append("// decompilation failed\n"); + } + } + String text = sb.toString(); + if (args.length > 1) { + try (FileWriter w = new FileWriter(args[1])) { + w.write(text); + } + } + println(text); + } +} diff --git a/userspace/cuda/tools/ghidra/ExportFunctionIndex.java b/userspace/cuda/tools/ghidra/ExportFunctionIndex.java new file mode 100644 index 0000000..c36f98f --- /dev/null +++ b/userspace/cuda/tools/ghidra/ExportFunctionIndex.java @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// ExportFunctionIndex.java - Ghidra post-script: dump a complete inventory of +// the analyzed program: functions, symbols (exports/imports), memory blocks +// and per-function references to interesting callees, plus the address of +// every function that is reachable from the exported CUDA entry points. +// +// This is the primary "map" artifact for a CUDA userspace driver version: it +// lets later tooling and analysis answer "what is at libcuda+0xNNNNNN" and +// "which internal function implements exported cuFoo". +// +// Usage (analyzeHeadless, on an already analyzed program): +// -process <program> -noanalysis \ +// -postScript ExportFunctionIndex.java <outdir> +// +// Outputs (all TSV, one row per item, with a header row): +// <outdir>/functions.tsv addr size name namespace is_exported +// is_external is_thunk has_body entry_bytes +// <outdir>/exports.tsv addr size name +// <outdir>/imports.tsv addr name library +// <outdir>/blocks.tsv name start end size r/w/x +// <outdir>/callgraph.tsv caller_addr callee_addr call_sites +// <outdir>/strings.tsv addr length string (first 200 chars) +// +// The callgraph is produced from Ghidra's reference model for call +// instructions inside each function body. + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSetView; +import ghidra.program.model.listing.Data; +import ghidra.program.model.listing.DataIterator; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Listing; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.symbol.Reference; +import ghidra.program.model.symbol.ReferenceIterator; +import ghidra.program.model.symbol.Symbol; +import ghidra.program.model.symbol.SymbolIterator; +import ghidra.program.model.symbol.SymbolTable; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.Map; + +public class ExportFunctionIndex extends GhidraScript { + + private PrintWriter open(String dir, String name) throws Exception { + return new PrintWriter(new BufferedWriter(new FileWriter(dir + "/" + name))); + } + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: ExportFunctionIndex.java <outdir>"); + return; + } + String outdir = args[0]; + new java.io.File(outdir).mkdirs(); + + Listing listing = currentProgram.getListing(); + SymbolTable symtab = currentProgram.getSymbolTable(); + // Ghidra may load a PIE/shared object at a non-zero image base (this + // build uses 0x100000). All *file virtual addresses* (what the public + // docs cite as libcuda+0x...) are program addresses minus this base. + long imageBase = currentProgram.getImageBase().getOffset(); + + // ---- functions ------------------------------------------------------- + int nfunc = 0, nexport = 0, nexternal = 0; + Map<Long, String> exported = new HashMap<>(); + try (PrintWriter w = open(outdir, "functions.tsv")) { + w.println("addr\tfile_addr\tsize\tname\tnamespace\texported\texternal\tthunk\tbody"); + FunctionIterator fit = listing.getFunctions(true); + while (fit.hasNext() && !monitor.isCancelled()) { + Function f = fit.next(); + Address ep = f.getEntryPoint(); + boolean isExt = f.isExternal(); + boolean isThunk = f.isThunk(); + String ns = f.getParentNamespace() != null + ? f.getParentNamespace().getName(true) : ""; + String name = f.getName(); + long size = f.getBody().getNumAddresses(); + w.printf("0x%08x\t0x%08x\t%d\t%s\t%s\t%s\t%s\t%s\t%s%n", + ep.getOffset(), ep.getOffset() - imageBase, size, name, ns, + isExt ? "0" : (symtab.isExternalEntryPoint(ep) ? "1" : "0"), + isExt ? "1" : "0", isThunk ? "1" : "0", + size > 0 ? "1" : "0"); + nfunc++; + if (!isExt) { + for (Symbol s : symtab.getSymbols(ep)) { + if (s.isExternalEntryPoint() || s.isGlobal()) { + exported.put(ep.getOffset(), s.getName(true)); + } + } + } + if (isExt) nexternal++; + } + } + + // ---- exports: symbols flagged external-entry / global in .text ------ + // The loader marks ELF .dynsym definitions; enumerating them via the + // symbol table is exact and independent of the function iterator. + try (PrintWriter w = open(outdir, "exports.tsv")) { + w.println("addr\tfile_addr\tsize\tname"); + SymbolIterator sit = symtab.getAllSymbols(true); + while (sit.hasNext() && !monitor.isCancelled()) { + Symbol s = sit.next(); + Address a = s.getAddress(); + if (a == null || !a.isMemoryAddress()) continue; + if (!symtab.isExternalEntryPoint(a)) continue; + Function f = listing.getFunctionAt(a); + long size = f != null ? f.getBody().getNumAddresses() : 0; + w.printf("0x%08x\t0x%08x\t%d\t%s%n", a.getOffset(), a.getOffset() - imageBase, size, s.getName(true)); + nexport++; + } + } + + // ---- imports: external functions and the library they come from ---- + try (PrintWriter w = open(outdir, "imports.tsv")) { + w.println("addr\tfile_addr\tname\tlibrary"); + FunctionIterator fit = listing.getFunctions(true); + while (fit.hasNext() && !monitor.isCancelled()) { + Function f = fit.next(); + if (!f.isExternal()) continue; + String lib = f.getParentNamespace() != null + ? f.getParentNamespace().getName(true) : ""; + w.printf("0x%08x\t0x%08x\t%s\t%s%n", + f.getEntryPoint().getOffset(), + f.getEntryPoint().getOffset() - imageBase, f.getName(), lib); + } + } + + // ---- memory blocks --------------------------------------------------- + try (PrintWriter w = open(outdir, "blocks.tsv")) { + w.println("name\tstart\tend\tsize\tr\tw\tx\tinitialized"); + for (MemoryBlock b : currentProgram.getMemory().getBlocks()) { + w.printf("%s\t0x%08x\t0x%08x\t%d\t%s\t%s\t%s\t%s%n", + b.getName(), b.getStart().getOffset(), b.getEnd().getOffset(), + b.getSize(), b.isRead() ? "1" : "0", b.isWrite() ? "1" : "0", + b.isExecute() ? "1" : "0", b.isInitialized() ? "1" : "0"); + } + } + + // ---- call graph ------------------------------------------------------ + // Function.getCalledFunctions() is the reliable API: it resolves both + // direct calls and call-through references recorded by the analyzers, + // independent of how the reference manager indexes them. + int edges = 0; + try (PrintWriter w = open(outdir, "callgraph.tsv")) { + w.println("caller\tcallee\tcaller_file\tcallee_file"); + FunctionIterator fit = listing.getFunctions(true); + while (fit.hasNext() && !monitor.isCancelled()) { + Function f = fit.next(); + if (f.isExternal()) continue; + for (Function callee : f.getCalledFunctions(monitor)) { + w.printf("0x%08x\t0x%08x\t0x%08x\t0x%08x%n", + f.getEntryPoint().getOffset(), + callee.getEntryPoint().getOffset(), + f.getEntryPoint().getOffset() - imageBase, + callee.getEntryPoint().getOffset() - imageBase); + edges++; + } + } + } + + // ---- defined strings ------------------------------------------------- + int nstr = 0; + try (PrintWriter w = open(outdir, "strings.tsv")) { + w.println("addr\tfile_addr\tlength\tstring"); + DataIterator dit = listing.getDefinedData(true); + while (dit.hasNext() && !monitor.isCancelled()) { + Data d = dit.next(); + if (!d.hasStringValue()) continue; + Object v = d.getValue(); + if (v == null) continue; + String s = v.toString().replace("\t", " ").replace("\n", "\\n"); + if (s.length() > 200) s = s.substring(0, 200); + w.printf("0x%08x\t0x%08x\t%d\t%s%n", d.getAddress().getOffset(), + d.getAddress().getOffset() - imageBase, d.getLength(), s); + nstr++; + } + } + + println(String.format( + "[ExportFunctionIndex] functions=%d exports=%d imports=%d callgraph_edges=%d strings=%d", + nfunc, nexport, nexternal, edges, nstr)); + } +} diff --git a/userspace/cuda/tools/ghidra/PrintProgramInfo.java b/userspace/cuda/tools/ghidra/PrintProgramInfo.java new file mode 100644 index 0000000..86a31b3 --- /dev/null +++ b/userspace/cuda/tools/ghidra/PrintProgramInfo.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// PrintProgramInfo.java - Ghidra post-script: print the program's image base, +// executable block ranges and function count. Used to establish the address +// convention of a project before running decompilation tools. +// +// Usage: +// analyzeHeadless <proj> <name> -process <program> -readOnly -noanalysis \ +// -postScript PrintProgramInfo.java +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.mem.MemoryBlock; + +public class PrintProgramInfo extends GhidraScript { + @Override + public void run() throws Exception { + println("program = " + currentProgram.getName()); + println("language = " + currentProgram.getLanguageID()); + println("image base = 0x" + Long.toHexString(currentProgram.getImageBase().getOffset())); + println("executable blocks:"); + for (MemoryBlock b : currentProgram.getMemory().getBlocks()) { + if (b.isExecute()) { + println(" 0x" + Long.toHexString(b.getStart().getOffset()) + + " - 0x" + Long.toHexString(b.getEnd().getOffset()) + + " " + b.getName()); + } + } + int n = 0; + FunctionIterator fit = currentProgram.getListing().getFunctions(true); + while (fit.hasNext() && !monitor.isCancelled()) { + Function f = fit.next(); + if (!f.isExternal()) n++; + } + println("functions = " + n); + } +} diff --git a/userspace/cuda/tools/ghidra_batch_decompile.sh b/userspace/cuda/tools/ghidra_batch_decompile.sh new file mode 100755 index 0000000..bce8cb9 --- /dev/null +++ b/userspace/cuda/tools/ghidra_batch_decompile.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# SPDX-License-Identifier: BSD-3-Clause +# +# ghidra_batch_decompile.sh - decompile the whole code range of an analyzed +# CUDA program in parallel. Chunks already present are skipped, so the command +# is safe to re-run and to interrupt. +# +# Ghidra takes an exclusive project lock even in -readOnly mode, so a single +# project cannot be opened by several processes. To parallelize, this script +# makes one copy of the analyzed project per worker under <outdir>/wk/ and +# round-robins the chunks across the copies. Copies are kept (they make +# resuming cheap) and can be removed with: rm -rf <outdir>/wk +# +# Usage: +# ghidra_batch_decompile.sh <project_dir> <project_name> <program> <outdir> \ +# <start_hex> <end_hex> [chunk_size_hex] [jobs] [timeout_s] +# +# Defaults: chunk 0x100000, jobs 5, timeout 120. +# env: GHIDRA_HOME, GHIDRA_HEADLESS_MAXMEM (default 4G for batch jobs), MAX_CPU +# +# After it completes, merge per-chunk indexes with: +# ghidra_index_merge.sh <outdir> +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +GHIDRA_HOME="${GHIDRA_HOME:-/snap/ghidra/current/ghidra}" +ANALYZE="$GHIDRA_HOME/support/analyzeHeadless" +export GHIDRA_HEADLESS_MAXMEM="${GHIDRA_HEADLESS_MAXMEM:-4G}" + +PROJECT_DIR="${1:?usage: $0 <project_dir> <project_name> <program> <outdir> <start_hex> <end_hex> [chunk_hex] [jobs] [timeout_s]}" +PROJECT_NAME="${2:?}" +PROGRAM="${3:?}" +OUTDIR="${4:?}" +START="${5:?}" +END="${6:?}" +CHUNK="${7:-0x100000}" +JOBS="${8:-5}" +TIMEOUT="${9:-120}" + +mkdir -p "$OUTDIR/buckets" "$OUTDIR/index" "$OUTDIR/logs" "$OUTDIR/wk" + +START_D=$((START)); END_D=$((END)); CHUNK_D=$((CHUNK)) + +# Build the chunk list first: every chunk gets its OWN project copy, so two +# concurrent jobs can never race on the same Ghidra project lock. +ranges="" +n=0 +for ((a=START_D; a<END_D; a+=CHUNK_D)); do + b=$((a+CHUNK_D)); [ "$b" -gt "$END_D" ] && b=$END_D + ranges+=$(printf "0x%x 0x%x %d\n" "$a" "$b" "$n")$'\n' + n=$((n+1)) +done + +# Create project copies for the chunks that still need work. +while read -r _a _b w; do + [ -z "$w" ] && continue + out="$OUTDIR/buckets/bucket_${_a#0x}_${_b#0x}.c" + idx="$OUTDIR/index/bucket_${_a#0x}_${_b#0x}.tsv" + if [ -f "$out" ] && [ -f "$idx" ]; then continue; fi + wdir="$OUTDIR/wk" + if [ ! -d "$wdir/$PROJECT_NAME.w$w.rep" ]; then + echo "[*] creating project copy $w" + cp -a "$PROJECT_DIR/$PROJECT_NAME.gpr" "$wdir/$PROJECT_NAME.w$w.gpr" + cp -a "$PROJECT_DIR/$PROJECT_NAME.rep" "$wdir/$PROJECT_NAME.w$w.rep" + fi +done <<< "$ranges" + +echo "[*] decompiling $PROGRAM in $n chunks across $JOBS workers" +echo "$ranges" | xargs -P "$JOBS" -n 3 bash -c ' + set -e + a="$1"; b="$2"; w="$3" + tag="${a#0x}_${b#0x}" + log="'$OUTDIR'/logs/decompile_${tag}.log" + GHIDRA_HEADLESS_MAXMEM="'$GHIDRA_HEADLESS_MAXMEM'" "'$ANALYZE'" "'$OUTDIR'/wk" "'$PROJECT_NAME'.w$w" \ + -process "'$PROGRAM'" -readOnly -noanalysis \ + -scriptPath "'$HERE'/ghidra" \ + -postScript BatchDecompile.java "$a" "$b" "'$OUTDIR'" "'$TIMEOUT'" \ + > "$log" 2>&1 || { echo "[FAIL] $a-$b (see $log)"; exit 1; } + tail -1 "$log" +' _ + +echo "[ok] batch decompilation done -> $OUTDIR/buckets" +echo " merge indexes: $HERE/ghidra_index_merge.sh $OUTDIR" diff --git a/userspace/cuda/tools/ghidra_decompile_at.sh b/userspace/cuda/tools/ghidra_decompile_at.sh new file mode 100755 index 0000000..1fa9035 --- /dev/null +++ b/userspace/cuda/tools/ghidra_decompile_at.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# SPDX-License-Identifier: BSD-3-Clause +# +# ghidra_decompile_at.sh - decompile the function containing a file virtual +# address from an already analyzed project. Version-agnostic replacement for +# the per-version helper (same idea, any project/binary). +# +# Usage: ghidra_decompile_at.sh <project_dir> <project_name> <program> <hex_vaddr> [outfile] +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +GHIDRA_HOME="${GHIDRA_HOME:-/snap/ghidra/current/ghidra}" +ANALYZE="$GHIDRA_HOME/support/analyzeHeadless" +export GHIDRA_HEADLESS_MAXMEM="${GHIDRA_HEADLESS_MAXMEM:-12G}" + +PROJECT_DIR="${1:?usage: $0 <project_dir> <project_name> <program> <hex_vaddr> [outfile]}" +PROJECT_NAME="${2:?}" +PROGRAM="${3:?}" +VADDR="${4:?}" +OUT="${5:-$PROJECT_DIR/fn_${VADDR}.c}" + +"$ANALYZE" "$PROJECT_DIR" "$PROJECT_NAME" \ + -process "$PROGRAM" -readOnly -noanalysis \ + -scriptPath "$HERE/ghidra" \ + -postScript DecompileAt.java "$VADDR" "$OUT" \ + 2>&1 | tail -3 + +[ -f "$OUT" ] || { echo "[FAIL] no output"; exit 1; } +echo "[ok] $OUT" diff --git a/userspace/cuda/tools/ghidra_export_index.sh b/userspace/cuda/tools/ghidra_export_index.sh new file mode 100755 index 0000000..3709aae --- /dev/null +++ b/userspace/cuda/tools/ghidra_export_index.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# SPDX-License-Identifier: BSD-3-Clause +# +# ghidra_export_index.sh - export the complete function/symbol/callgraph/ +# string index of an analyzed CUDA program. +# +# Usage: ghidra_export_index.sh <project_dir> <project_name> <program> <outdir> +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +GHIDRA_HOME="${GHIDRA_HOME:-/snap/ghidra/current/ghidra}" +ANALYZE="$GHIDRA_HOME/support/analyzeHeadless" +export GHIDRA_HEADLESS_MAXMEM="${GHIDRA_HEADLESS_MAXMEM:-12G}" + +PROJECT_DIR="${1:?usage: $0 <project_dir> <project_name> <program> <outdir>}" +PROJECT_NAME="${2:?}" +PROGRAM="${3:?}" +OUTDIR="${4:?}" +mkdir -p "$OUTDIR" + +"$ANALYZE" "$PROJECT_DIR" "$PROJECT_NAME" \ + -process "$PROGRAM" \ + -readOnly -noanalysis \ + -scriptPath "$HERE/ghidra" \ + -postScript ExportFunctionIndex.java "$OUTDIR" + +echo "[ok] index written to $OUTDIR" diff --git a/userspace/cuda/tools/ghidra_full_analysis.sh b/userspace/cuda/tools/ghidra_full_analysis.sh new file mode 100755 index 0000000..a143a80 --- /dev/null +++ b/userspace/cuda/tools/ghidra_full_analysis.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# SPDX-License-Identifier: BSD-3-Clause +# +# ghidra_full_analysis.sh - import a CUDA userspace library into a fresh +# Ghidra project and run a full auto-analysis tuned for a large stripped +# vendor binary. +# +# This is the fixed version of the original per-version helper: passing +# `-analysisTimeoutPerFile 0` made this Ghidra build abort analysis after +# 0 seconds (see run/ghidra-analyze.log: "Analysis timed out at 0 seconds"), +# so the old project contains no analysis at all. +# +# Usage: ghidra_full_analysis.sh <binary> [project_dir] [project_name] +# env: GHIDRA_HOME (default /snap/ghidra/current/ghidra) +# GHIDRA_HEADLESS_MAXMEM (default 12G) +# +# The project is kept so the expensive analysis runs exactly once; all later +# decompilation uses ghidra_batch_decompile.sh / ghidra_decompile_at.sh with +# -noanalysis against the same project. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +GHIDRA_HOME="${GHIDRA_HOME:-/snap/ghidra/current/ghidra}" +ANALYZE="$GHIDRA_HOME/support/analyzeHeadless" +export GHIDRA_HEADLESS_MAXMEM="${GHIDRA_HEADLESS_MAXMEM:-12G}" + +BIN="${1:?usage: $0 <binary> [project_dir] [project_name]}" +PROJECT_DIR="${2:-$(dirname "$BIN")/ghidra-full}" +PROJECT_NAME="${3:-cuda-full}" + +[ -x "$ANALYZE" ] || { echo "analyzeHeadless not found at $ANALYZE"; exit 1; } +[ -f "$BIN" ] || { echo "no such binary: $BIN"; exit 1; } +mkdir -p "$PROJECT_DIR" + +echo "[*] importing + analyzing $BIN" +echo " project: $PROJECT_DIR/$PROJECT_NAME" +echo " heap: $GHIDRA_HEADLESS_MAXMEM" +echo " (Decompiler Parameter ID and Function ID are disabled by" +echo " ConfigureAnalysis.java; everything else stays on.)" + +"$ANALYZE" "$PROJECT_DIR" "$PROJECT_NAME" \ + -import "$BIN" \ + -overwrite \ + -scriptPath "$HERE/ghidra" \ + -preScript ConfigureAnalysis.java \ + -analysisTimeoutPerFile 14400 \ + -max-cpu "${MAX_CPU:-8}" + +echo "[ok] analysis complete: $PROJECT_DIR/$PROJECT_NAME" diff --git a/userspace/cuda/tools/ghidra_index_merge.sh b/userspace/cuda/tools/ghidra_index_merge.sh new file mode 100755 index 0000000..44479e9 --- /dev/null +++ b/userspace/cuda/tools/ghidra_index_merge.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# SPDX-License-Identifier: BSD-3-Clause +# +# ghidra_index_merge.sh - merge the per-chunk decompiled_index.tsv files from +# ghidra_batch_decompile.sh into one sorted index and print coverage stats. +# +# Usage: ghidra_index_merge.sh <outdir> +set -euo pipefail +OUTDIR="${1:?usage: $0 <outdir>}" + +head -1 "$OUTDIR"/index/bucket_*.tsv | head -1 > "$OUTDIR/decompiled_index.tsv" +cat "$OUTDIR"/index/bucket_*.tsv | grep -v '^addr' | sort -t$'\t' -k1,1 >> "$OUTDIR/decompiled_index.tsv" + +total=$(grep -c . "$OUTDIR/decompiled_index.tsv" || true) +failed=$(grep -c $'\tfailed$' "$OUTDIR/decompiled_index.tsv" || true) +skipped=$(grep -c 'skipped-huge' "$OUTDIR/decompiled_index.tsv" || true) +echo "[index] rows=$((total-1)) failed=$failed skipped-huge=$skipped" +echo "[index] $OUTDIR/decompiled_index.tsv" |
