Last active
May 25, 2026 14:12
-
-
Save buzzer-re/a8a85c1613364ec3ef344e4e92a6122f to your computer and use it in GitHub Desktop.
Go pclntab function recovery for IDA for Golang 1.2, 1.16, 1.18 and 1.20. Wrote with Codex (GPT)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Minimal Go pclntab function recovery for IDA. | |
| # | |
| # Run from IDA with File > Script file..., or copy to IDA/plugins and invoke | |
| # Edit > Plugins > Go pclntab recover. | |
| import re | |
| import struct | |
| import ida_auto | |
| import ida_bytes | |
| import ida_funcs | |
| import ida_ida | |
| import ida_idaapi | |
| import ida_name | |
| import ida_segment | |
| import idautils | |
| try: | |
| import ida_dirtree | |
| except Exception: | |
| ida_dirtree = None | |
| PCLN_MAGICS = { | |
| 0xFFFFFFFB: "v12", | |
| 0xFFFFFFFA: "v116", | |
| 0xFFFFFFF0: "v118", | |
| 0xFFFFFFF1: "v120", | |
| } | |
| BUILDINFO_MAGIC = b"\xff Go buildinf:" | |
| MAX_FUNCS = 2_000_000 | |
| MAX_SEG_SCAN = 128 * 1024 * 1024 | |
| class PclnHeader: | |
| def __init__(self, base, version, ptr_size): | |
| self.base = base | |
| self.version = version | |
| self.ptr_size = ptr_size | |
| self.quantum = read_u8(base + 6) or 1 | |
| self.nfunc = 0 | |
| self.nfiles = 0 | |
| self.text_start = 0 | |
| self.funcname_offset = 0 | |
| self.cu_offset = 0 | |
| self.filetab_offset = 0 | |
| self.pctab_offset = 0 | |
| self.pcln_offset = 0 | |
| class GoFunc: | |
| def __init__(self, ea, full_name, package, receiver, short_name): | |
| self.ea = ea | |
| self.full_name = full_name | |
| self.package = package | |
| self.receiver = receiver | |
| self.short_name = short_name | |
| self.name = sanitize_go_name(full_name) | |
| def msg(s): | |
| print("[go-recover] %s" % s) | |
| def read_bytes(ea, n): | |
| if ea == ida_idaapi.BADADDR or n <= 0: | |
| return None | |
| return ida_bytes.get_bytes(ea, n) | |
| def read_u8(ea): | |
| b = read_bytes(ea, 1) | |
| return b[0] if b else None | |
| def read_u32(ea): | |
| b = read_bytes(ea, 4) | |
| return struct.unpack("<I", b)[0] if b and len(b) == 4 else None | |
| def read_u64(ea): | |
| b = read_bytes(ea, 8) | |
| return struct.unpack("<Q", b)[0] if b and len(b) == 8 else None | |
| def read_uintp(ea, ptr_size): | |
| if ptr_size == 4: | |
| return read_u32(ea) | |
| if ptr_size == 8: | |
| return read_u64(ea) | |
| return None | |
| def read_cstr(ea, max_len=512): | |
| b = read_bytes(ea, max_len) | |
| if not b: | |
| return "" | |
| end = b.find(b"\x00") | |
| if end >= 0: | |
| b = b[:end] | |
| try: | |
| return b.decode("utf-8") | |
| except UnicodeDecodeError: | |
| return "" | |
| def read_uleb128(ea): | |
| value = 0 | |
| shift = 0 | |
| off = 0 | |
| while off < 16: | |
| b = read_u8(ea + off) | |
| if b is None: | |
| return None | |
| off += 1 | |
| value |= (b & 0x7F) << shift | |
| if (b & 0x80) == 0: | |
| return value, off | |
| shift += 7 | |
| return None | |
| def read_varint_bytes(ea): | |
| got = read_uleb128(ea) | |
| if not got: | |
| return None | |
| size, used = got | |
| if size > 1024 * 1024: | |
| return None | |
| b = read_bytes(ea + used, size) | |
| if b is None: | |
| return None | |
| return b, used + size | |
| def is_valid_ea(ea): | |
| return ida_segment.getseg(ea) is not None and read_bytes(ea, 1) is not None | |
| def best_text_start(): | |
| entry = ida_ida.inf_get_start_ea() | |
| if is_valid_ea(entry): | |
| seg = ida_segment.getseg(entry) | |
| if seg: | |
| return seg.start_ea | |
| for start in idautils.Segments(): | |
| seg = ida_segment.getseg(start) | |
| if seg and (seg.perm & ida_segment.SEGPERM_EXEC): | |
| return seg.start_ea | |
| return entry | |
| def iter_segments(): | |
| for start in idautils.Segments(): | |
| seg = ida_segment.getseg(start) | |
| if seg: | |
| yield seg | |
| def seg_name(seg): | |
| try: | |
| return ida_segment.get_segm_name(seg) or "" | |
| except Exception: | |
| return "" | |
| def detect_pclntab(): | |
| for seg in iter_segments(): | |
| if ".gopclntab" in seg_name(seg): | |
| hit = try_pclntab_at(seg.start_ea) | |
| if hit: | |
| return hit | |
| likely = (".rodata", ".rdata", ".text", ".data.rel.ro", "__rodata", "__DATA_CONST") | |
| ordered = sorted( | |
| iter_segments(), | |
| key=lambda s: 0 if any(x in seg_name(s) for x in likely) else 1, | |
| ) | |
| for seg in ordered: | |
| size = min(seg.end_ea - seg.start_ea, MAX_SEG_SCAN) | |
| ea = seg.start_ea | |
| end = seg.start_ea + size | |
| while ea + 8 <= end: | |
| magic = read_u32(ea) | |
| if magic in PCLN_MAGICS: | |
| hit = try_pclntab_at(ea) | |
| if hit: | |
| return hit | |
| ea += 4 | |
| return None | |
| def try_pclntab_at(ea): | |
| magic = read_u32(ea) | |
| version = PCLN_MAGICS.get(magic) | |
| ptr_size = read_u8(ea + 7) | |
| if not version or ptr_size not in (4, 8): | |
| return None | |
| h = PclnHeader(ea, version, ptr_size) | |
| if version in ("v12", "v116"): | |
| h.nfunc = read_uintp(ea + 8, ptr_size) or 0 | |
| if version == "v116": | |
| func_table = 8 + 2 * ptr_size | |
| after_table = func_table + h.nfunc * 2 * ptr_size | |
| h.funcname_offset = read_uintp(ea + after_table, ptr_size) or 0 | |
| else: | |
| h.nfunc = read_u64(ea + 8) or 0 | |
| h.nfiles = read_u64(ea + 16) or 0 | |
| h.text_start = read_u64(ea + 24) or 0 | |
| h.funcname_offset = read_u64(ea + 32) or 0 | |
| h.cu_offset = read_u64(ea + 40) or 0 | |
| h.filetab_offset = read_u64(ea + 48) or 0 | |
| h.pctab_offset = read_u64(ea + 56) or 0 | |
| h.pcln_offset = read_u64(ea + 64) or 0 | |
| if not is_valid_ea(h.text_start): | |
| h.text_start = best_text_start() | |
| if h.nfunc == 0 or h.nfunc > MAX_FUNCS: | |
| return None | |
| return h | |
| def parse_functions(h): | |
| if h.version == "v12": | |
| return parse_v12(h) | |
| if h.version == "v116": | |
| return parse_v116(h) | |
| return parse_v118(h) | |
| def parse_v12(h): | |
| out = [] | |
| p = h.ptr_size | |
| table = h.base + 8 + p | |
| for i in range(h.nfunc): | |
| ent = table + i * 2 * p | |
| entry_pc = read_uintp(ent, p) | |
| rec_off = read_uintp(ent + p, p) | |
| if entry_pc is None or rec_off is None or not is_valid_ea(entry_pc): | |
| continue | |
| name_off = read_u32(h.base + rec_off + p) | |
| if name_off is None: | |
| continue | |
| add_go_func(out, entry_pc, read_cstr(h.base + name_off)) | |
| return out | |
| def parse_v116(h): | |
| out = [] | |
| p = h.ptr_size | |
| table = h.base + 8 + 2 * p | |
| names = h.base + h.funcname_offset | |
| for i in range(h.nfunc): | |
| ent = table + i * 2 * p | |
| entry_pc = read_uintp(ent, p) | |
| rec_off = read_uintp(ent + p, p) | |
| if entry_pc is None or rec_off is None or not is_valid_ea(entry_pc): | |
| continue | |
| name_off = read_u32(h.base + rec_off + p) | |
| if name_off is None: | |
| continue | |
| add_go_func(out, entry_pc, read_cstr(names + name_off)) | |
| return out | |
| def parse_v118(h): | |
| out = [] | |
| table = h.base + h.pcln_offset | |
| names = h.base + h.funcname_offset | |
| bad = 0 | |
| for i in range(h.nfunc): | |
| ent = table + i * 8 | |
| pc_off = read_u32(ent) | |
| rec_off = read_u32(ent + 4) | |
| if pc_off is None or rec_off is None: | |
| continue | |
| entry_pc = h.text_start + pc_off | |
| if not is_valid_ea(entry_pc): | |
| bad += 1 | |
| continue | |
| rec = table + rec_off | |
| name_off = read_u32(rec + 4) | |
| if name_off is None: | |
| continue | |
| add_go_func(out, entry_pc, read_cstr(names + name_off)) | |
| if bad: | |
| msg("skipped %d funcs with invalid entry PCs" % bad) | |
| return out | |
| def add_go_func(out, ea, full_name): | |
| if not full_name or not looks_like_go_name(full_name): | |
| return | |
| package, receiver, short = parse_go_func_name(full_name) | |
| out.append(GoFunc(ea, full_name, package, receiver, short)) | |
| def looks_like_go_name(s): | |
| if len(s) > 500 or "\n" in s or "\r" in s: | |
| return False | |
| return "." in s or s in ("init", "main") | |
| def parse_go_func_name(full): | |
| slash = full.rfind("/") + 1 | |
| dot = full.find(".", slash) | |
| if dot < 0: | |
| return "", None, full | |
| package = full[:dot] | |
| rest = full[dot + 1 :] | |
| if rest.startswith("("): | |
| end = rest.find(").") | |
| if end >= 0: | |
| return package, rest[: end + 1], rest[end + 2 :] | |
| return package, None, rest | |
| def clean_component(s): | |
| s = re.sub(r"[^0-9A-Za-z_]", "_", s) | |
| s = re.sub(r"_+", "_", s).strip("_") | |
| if not s: | |
| return "go" | |
| if s[0].isdigit(): | |
| return "_" + s | |
| return s | |
| def sanitize_go_name(full): | |
| package, receiver, short = parse_go_func_name(full) | |
| if "." in package: | |
| package = package.rsplit("/", 1)[-1] | |
| parts = [] | |
| if package: | |
| parts.append(clean_component(package)) | |
| if receiver: | |
| parts.append(clean_component(receiver.replace("*", "").strip("()"))) | |
| if short: | |
| parts.append(clean_component(short)) | |
| return "_".join(parts) or "go_func" | |
| def unique_name(base, ea, used): | |
| name = base | |
| if name in used: | |
| name = "%s_%x" % (base, ea) | |
| i = 1 | |
| while name in used: | |
| name = "%s_%x_%d" % (base, ea, i) | |
| i += 1 | |
| used.add(name) | |
| return name | |
| def find_build_info(ptr_size): | |
| for seg in iter_segments(): | |
| size = min(seg.end_ea - seg.start_ea, MAX_SEG_SCAN) | |
| off = 0 | |
| while off + len(BUILDINFO_MAGIC) <= size: | |
| ea = seg.start_ea + off | |
| if read_bytes(ea, len(BUILDINFO_MAGIC)) == BUILDINFO_MAGIC: | |
| info = parse_build_info(ea, ptr_size) | |
| if info: | |
| return info | |
| off += 16 | |
| return {} | |
| def parse_build_info(ea, fallback_ptr_size): | |
| hdr_ptr_size = read_u8(ea + 14) | |
| flags = read_u8(ea + 15) | |
| ptr_size = hdr_ptr_size if hdr_ptr_size in (4, 8) else fallback_ptr_size | |
| if flags is None: | |
| return {} | |
| if flags & 2: | |
| start = ea + 16 + 2 * ptr_size | |
| ver = read_varint_bytes(start) | |
| if not ver: | |
| return {} | |
| mod = read_varint_bytes(start + ver[1]) | |
| if not mod: | |
| return {} | |
| raw = mod[0] | |
| if len(raw) > 32: | |
| raw = raw[16:-16] | |
| modinfo = raw.decode("utf-8", "ignore").rstrip("\x00") | |
| else: | |
| mod_ptr = read_uintp(ea + 16 + 2 * ptr_size, ptr_size) | |
| mod_len = read_uintp(ea + 16 + 3 * ptr_size, ptr_size) | |
| if not mod_ptr or not mod_len or mod_len > 1024 * 1024: | |
| return {} | |
| raw = read_bytes(mod_ptr, mod_len) | |
| if not raw: | |
| return {} | |
| modinfo = raw.decode("utf-8", "ignore").rstrip("\x00") | |
| return parse_modinfo(modinfo) | |
| def parse_modinfo(text): | |
| info = {} | |
| for line in text.splitlines(): | |
| fields = line.split("\t") | |
| if len(fields) >= 2 and fields[0] == "path": | |
| info["path"] = fields[1] | |
| elif len(fields) >= 2 and fields[0] == "mod": | |
| info["module"] = fields[1] | |
| return info | |
| def root_package(path): | |
| cut = len(path) | |
| for ch in "[({": | |
| pos = path.find(ch) | |
| if pos >= 0: | |
| cut = min(cut, pos) | |
| s = path[:cut].rstrip(". ") | |
| if ":" in s: | |
| return s.split(":", 1)[0] | |
| if "/" not in s and "." in s: | |
| return s.split(".", 1)[0] | |
| return s | |
| def classify_package(pkg, module_root): | |
| first = pkg.split("/", 1)[0] | |
| if "." not in first: | |
| return "stdlib" | |
| if module_root and pkg.startswith(module_root): | |
| return "user" | |
| return "thirdparty" | |
| def folder_for_package(pkg, module_root): | |
| pkg = root_package(pkg) | |
| if not pkg: | |
| return "/unpackaged" | |
| if pkg == "main": | |
| return "/main" | |
| kind = classify_package(pkg, module_root) | |
| if kind == "stdlib": | |
| return "/_stdlib/" + clean_folder_path(pkg) | |
| if kind == "thirdparty": | |
| return "/_thirdparty/" + clean_folder_path(pkg.replace(".", "_")) | |
| return "/" + clean_folder_path(pkg) | |
| def clean_folder_path(path): | |
| return "/".join(clean_component(p) for p in path.split("/") if p) | |
| def apply_functions(funcs, module_root): | |
| used = set() | |
| renamed = 0 | |
| created = 0 | |
| moved = 0 | |
| funcs = sorted({f.ea: f for f in funcs}.values(), key=lambda f: f.ea) | |
| addrs = [f.ea for f in funcs] | |
| tree = get_func_tree() | |
| for i, f in enumerate(funcs): | |
| end = addrs[i + 1] if i + 1 < len(addrs) else ida_idaapi.BADADDR | |
| pfn = ida_funcs.get_func(f.ea) | |
| if not pfn or pfn.start_ea != f.ea: | |
| if ida_funcs.add_func(f.ea, end): | |
| created += 1 | |
| pfn = ida_funcs.get_func(f.ea) | |
| old_name = ida_funcs.get_func_name(f.ea) or ida_name.get_name(f.ea) | |
| new_name = unique_name(f.name, f.ea, used) | |
| if ida_name.set_name(f.ea, new_name, ida_name.SN_CHECK | ida_name.SN_NOWARN): | |
| renamed += 1 | |
| else: | |
| new_name = unique_name("%s_%x" % (f.name, f.ea), f.ea, used) | |
| if ida_name.set_name(f.ea, new_name, ida_name.SN_CHECK | ida_name.SN_NOWARN): | |
| renamed += 1 | |
| folder = folder_for_package(f.package, module_root) | |
| ida_bytes.set_cmt( | |
| f.ea, | |
| "Go: %s\nPackage: %s\nFolder: %s" % (f.full_name, f.package, folder), | |
| 0, | |
| ) | |
| if tree and move_function(tree, old_name, new_name, folder): | |
| moved += 1 | |
| if pfn: | |
| ida_funcs.set_func_cmt(pfn, "Go package: %s" % (f.package or "unknown"), False) | |
| return created, renamed, moved | |
| def get_func_tree(): | |
| if ida_dirtree is None: | |
| return None | |
| try: | |
| tree = ida_dirtree.get_std_dirtree(ida_dirtree.DIRTREE_FUNCS) | |
| if tree: | |
| tree.load() | |
| return tree | |
| except Exception: | |
| return None | |
| def mkdirs(tree, folder): | |
| cur = "" | |
| for part in folder.strip("/").split("/"): | |
| if not part: | |
| continue | |
| cur += "/" + part | |
| if not tree.isdir(cur): | |
| err = tree.mkdir(cur) | |
| if err not in (ida_dirtree.DTE_OK, ida_dirtree.DTE_ALREADY_EXISTS): | |
| return False | |
| return tree.isdir(folder) | |
| def move_function(tree, old_name, new_name, folder): | |
| if not mkdirs(tree, folder): | |
| return False | |
| dest = folder.rstrip("/") + "/" + new_name | |
| for name in (new_name, old_name): | |
| if not name: | |
| continue | |
| src = find_tree_file(tree, name) | |
| if src and src != dest: | |
| return tree.rename(src, dest) == ida_dirtree.DTE_OK | |
| return False | |
| def find_tree_file(tree, name): | |
| direct = "/" + name | |
| if tree.isfile(direct): | |
| return direct | |
| class Visitor(ida_dirtree.dirtree_visitor_t): | |
| def __init__(self): | |
| ida_dirtree.dirtree_visitor_t.__init__(self) | |
| self.path = None | |
| def visit(self, cursor, entry): | |
| if tree.isfile(entry) and tree.get_entry_name(entry) == name: | |
| self.path = tree.get_abspath(cursor) | |
| return 1 | |
| return 0 | |
| v = Visitor() | |
| try: | |
| tree.traverse(v) | |
| except Exception: | |
| return None | |
| return v.path | |
| def run(): | |
| ida_auto.auto_wait() | |
| h = detect_pclntab() | |
| if not h: | |
| msg("no Go pclntab found") | |
| return | |
| funcs = parse_functions(h) | |
| if not funcs: | |
| msg("pclntab found at %#x, but no functions parsed" % h.base) | |
| return | |
| build = find_build_info(h.ptr_size) | |
| module_root = build.get("module") or build.get("path") | |
| created, renamed, moved = apply_functions(funcs, module_root) | |
| msg( | |
| "pclntab=%#x %s funcs=%d created=%d renamed=%d moved=%d module=%s" | |
| % (h.base, h.version, len(funcs), created, renamed, moved, module_root or "") | |
| ) | |
| class GoRecoverPlugin(ida_idaapi.plugin_t): | |
| flags = ida_idaapi.PLUGIN_MOD | ida_idaapi.PLUGIN_UNL | |
| comment = "Recover Go functions from pclntab" | |
| help = "Recover Go functions from pclntab" | |
| wanted_name = "Go pclntab recover" | |
| wanted_hotkey = "" | |
| def init(self): | |
| return ida_idaapi.PLUGIN_OK | |
| def run(self, arg): | |
| run() | |
| def term(self): | |
| pass | |
| def PLUGIN_ENTRY(): | |
| return GoRecoverPlugin() | |
| if __name__ == "__main__": | |
| run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment