#!/usr/bin/env python3 """ Tiny helper for static RE of GC/game471.exe: - Find string xrefs (VA pointers to string bytes in the image) - Find IAT callsites by scanning for "call [abs]" where abs == IAT VA - Dump capstone disassembly around hits (no decompilation) This is intended for format/behavior understanding (clean-room), not patching. """ from __future__ import annotations import argparse import os import struct from dataclasses import dataclass from typing import Iterable, List, Optional, Tuple import pefile # type: ignore from capstone import Cs, CS_ARCH_X86, CS_MODE_32 # type: ignore @dataclass(frozen=True) class Section: name: str rva: int vsize: int raw: int raw_size: int def contains_rva(self, rva: int) -> bool: return self.rva <= rva < self.rva + max(self.vsize, self.raw_size) def contains_raw(self, off: int) -> bool: return self.raw <= off < self.raw + self.raw_size @dataclass(frozen=True) class PeImage: path: str blob: bytes image_base: int entry_rva: int sections: List[Section] def rva_to_raw(self, rva: int) -> Optional[int]: for s in self.sections: if s.contains_rva(rva): return s.raw + (rva - s.rva) return None def raw_to_rva(self, off: int) -> Optional[int]: for s in self.sections: if s.contains_raw(off): return s.rva + (off - s.raw) return None def va_to_raw(self, va: int) -> Optional[int]: return self.rva_to_raw(va - self.image_base) def raw_to_va(self, off: int) -> Optional[int]: rva = self.raw_to_rva(off) return None if rva is None else self.image_base + rva def load_pe(path: str) -> PeImage: with open(path, "rb") as f: blob = f.read() pe = pefile.PE(path, fast_load=False) sections: List[Section] = [] for s in pe.sections: name = s.Name.rstrip(b"\x00").decode("ascii", "replace") sections.append( Section( name=name, rva=int(s.VirtualAddress), vsize=int(s.Misc_VirtualSize), raw=int(s.PointerToRawData), raw_size=int(s.SizeOfRawData), ) ) return PeImage( path=path, blob=blob, image_base=int(pe.OPTIONAL_HEADER.ImageBase), entry_rva=int(pe.OPTIONAL_HEADER.AddressOfEntryPoint), sections=sections, ) def find_bytes(haystack: bytes, needle: bytes) -> List[int]: out: List[int] = [] pos = 0 while True: i = haystack.find(needle, pos) if i < 0: return out out.append(i) pos = i + 1 def iter_xrefs_to_va(img: PeImage, target_va: int) -> Iterable[Tuple[int, int]]: # Returns (xref_raw_off, xref_va) where xref contains little-endian dword == target_va. pat = struct.pack(" List[str]: md = Cs(CS_ARCH_X86, CS_MODE_32) md.detail = False # Heuristic: try to start at a function prolog to avoid decoding garbage. start_va = max(img.image_base, va - before) end_va = va + after start_off = img.va_to_raw(start_va) end_off = img.va_to_raw(end_va) if start_off is None or end_off is None or end_off <= start_off: return [f"(cannot map VA window {hex(start_va)}..{hex(end_va)})"] # Scan backward for common prologs. scan_start = start_off scan_end = img.va_to_raw(va) if scan_end is None: scan_end = start_off scan = img.blob[scan_start:scan_end] prologs = [b"\x55\x8b\xec", b"\x8b\xff\x55\x8b\xec"] best = None for p in prologs: j = scan.rfind(p) if j >= 0: cand = scan_start + j if best is None or cand > best: best = cand if best is not None: start_off = best start_va2 = img.raw_to_va(start_off) if start_va2 is not None: start_va = start_va2 code = img.blob[start_off:end_off] lines: List[str] = [] for ins in md.disasm(code, start_va): lines.append(f"{ins.address:08x}: {ins.mnemonic:6s} {ins.op_str}") if not lines: lines.append("(no instructions decoded)") return lines def find_string_va(img: PeImage, s: bytes) -> Optional[int]: # Find first occurrence in the file and map to VA if inside a section. # Some strings are stored as "...\n\0" rather than a direct "\0" terminator. idx = img.blob.find(s) if idx < 0: # Try common variants. if not s.endswith(b"\x00"): idx = img.blob.find(s + b"\x00") if idx < 0 and not s.endswith(b"\n"): idx = img.blob.find(s + b"\n\x00") if idx < 0 and not s.endswith(b"\x00"): idx = img.blob.find(s + b"\n") if idx < 0: return None rva = img.raw_to_rva(idx) if rva is None: return None return img.image_base + rva def list_import_vas(path: str) -> dict[str, int]: pe = pefile.PE(path, fast_load=False) pe.parse_data_directories( directories=[ pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"], ] ) out: dict[str, int] = {} for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []): dll = entry.dll.decode("ascii", "ignore").lower() for imp in entry.imports: if not imp.name: continue name = imp.name.decode("ascii", "ignore") key = f"{dll}!{name}" # imp.address is the VA of the IAT slot (already ImageBase + RVA) out[key] = int(imp.address) return out def find_iat_callsites(img: PeImage, iat_va: int) -> List[int]: """ Find call dword ptr [abs32] with abs32 == iat_va. x86 encoding: FF 15 """ pat = b"\xFF\x15" + struct.pack(" List[int]: """ Find `E8 rel32` calls that land at target_va. Returns callsite VA. """ out: List[int] = [] blob = img.blob # Limit scan to .text section for speed and relevance. text = next((s for s in img.sections if s.name == ".text"), None) if not text: return out start = text.raw end = text.raw + text.raw_size i = start while i + 5 <= end: if blob[i] == 0xE8: rel = struct.unpack_from(" int: ap = argparse.ArgumentParser() ap.add_argument("exe", help="Path to game471.exe") ap.add_argument("--string", default="data/stage/%s.dat", help="String to xref (ASCII).") ap.add_argument("--before", type=int, default=0x80, help="Bytes before for disasm window.") ap.add_argument("--after", type=int, default=0x180, help="Bytes after for disasm window.") ap.add_argument( "--imports", action="store_true", help="Also locate IAT callsites for CreateFileA/ReadFile/SetFilePointer/GetFileSize/CloseHandle.", ) ap.add_argument( "--call-xrefs", default="", help="Find rel32 E8 callsites to this VA (hex, e.g. 0x65bb00).", ) ap.add_argument( "--va-xrefs", default="", help="Find dword xrefs to this VA by scanning for a literal little-endian pointer (hex, e.g. 0x4ca320).", ) args = ap.parse_args() img = load_pe(args.exe) print(f"exe: {img.exe if hasattr(img,'exe') else img.path}") print(f"image_base={hex(img.image_base)} entry_va={hex(img.image_base + img.entry_rva)}") print("sections:") for s in img.sections: print(f" {s.name:8s} rva={hex(s.rva)} raw={hex(s.raw)} size={hex(s.raw_size)}") needle = args.string.encode("ascii") s_va = find_string_va(img, needle) if s_va is None: print(f"string not found: {args.string!r}") else: print(f"\nstring {args.string!r} VA={hex(s_va)}") xrefs = list(iter_xrefs_to_va(img, s_va)) print(f"xrefs: {len(xrefs)}") for raw_off, xref_va in xrefs[:20]: print(f"\n== xref @ {hex(xref_va)} (raw {hex(raw_off)}) ==") for line in disasm_window(img, xref_va, args.before, args.after)[:80]: print(line) if args.imports: imports = list_import_vas(args.exe) want = [ "kernel32.dll!CreateFileA", "kernel32.dll!ReadFile", "kernel32.dll!SetFilePointer", "kernel32.dll!GetFileSize", "kernel32.dll!CloseHandle", ] for k in want: va = imports.get(k) if va is None: print(f"\nIAT: missing {k}") continue hits = find_iat_callsites(img, va) print(f"\nIAT {k} slot={hex(va)} callsites={len(hits)}") for h in hits[:10]: print(f" callsite {hex(h)}") if hits: print(f"\n== {k} first callsite disasm ==") for line in disasm_window(img, hits[0], args.before, args.after)[:80]: print(line) if args.call_xrefs: target_va = int(args.call_xrefs, 16) hits = find_rel32_callsites_to(img, target_va) print(f"\nrel32 call xrefs to {hex(target_va)}: {len(hits)}") for h in hits[:20]: print(f" callsite {hex(h)}") if hits: print("\n== first callsite disasm ==") for line in disasm_window(img, hits[0], args.before, args.after)[:80]: print(line) if args.va_xrefs: target_va = int(args.va_xrefs, 16) xrefs = list(iter_xrefs_to_va(img, target_va)) print(f"\ndword xrefs to {hex(target_va)}: {len(xrefs)}") for raw_off, xref_va in xrefs[:20]: print(f"\n== xref @ {hex(xref_va)} (raw {hex(raw_off)}) ==") for line in disasm_window(img, xref_va, args.before, args.after)[:80]: print(line) return 0 if __name__ == "__main__": raise SystemExit(main())