Replaces the abandoned pe-sieve Task B: attach + scan the live process to map resolved import pointers (RVA->dll!Func) and label /v2. Recon-first hard gate; clean labels; read-only Frida. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Frida Import-Map — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Name the dynamically-resolved Win32 APIs at their call sites in the /v2 Ghidra image by mapping the packer's resolved import pointers (RVA → dll!Func) from the live process and applying clean labels.
Architecture: One Frida tool (tools/frida/map_imports.py) attaches to the live game, builds {runtime_addr → dll!Func} from loaded-module exports, scans the 0x400000 module for aligned DWORDs matching those addresses, and (a) --recon reports clustering [the hard gate] or (b) writes build/import-map.json. A run_script_inline Java script then applies imp_<dll>_<func> labels to /v2. The scan/match/cluster logic is pure Python (unit-tested); the Frida attach is exercised live.
Tech Stack: Python 3.11 (py -3.11 -X utf8), Frida 17.x (tools/frida/ pattern — see dump_engine.py), ghidra-mcp run_script_inline (Java; GHIDRA_MCP_ALLOW_SCRIPTS=1).
Spec: docs/superpowers/specs/2026-07-09-frida-import-map-design.md.
Global Constraints
- Run Python as
py -3.11 -X utf8 tools/<name>.py …(utf8 mandatory on Windows). Frida scripts:py -3.11 -u -X utf8 tools/frida/<name>.py. - Tools import
tools/paths.py; never hard-code paths.build/is disposable/gitignored. - ASLR safety: build the export map and scan the module in the SAME live process; carry back only RVA (offset into the fixed
0x400000main module). Never scan the old/v2dump for current-process pointer values. - Read-only Frida only —
enumerateModules/enumerateExports+ memory reads (the safe plain-JS pattern; no spawn, noGetProcAddresshook, no patching). - Dump range: the
/v2image covers0x400000..0x660000(size0x260000). A match at RVA≥ 0x260000is heap/out-of-dump and NOT labelable. - Recon is a HARD GATE (Task 1): if matches are not module-resident and in-range, STOP and document; do not build the labeler on heap-resident pointers.
- Ghidra: confirm the active program is
/v2/range_00400000.bin(base0x400000, 4308 fns) before any script — the two-program gotcha (docs/engine-re.mdrunbook). End image mutation withsave_program. - Preserve annotations: never clobber an existing
USER_DEFINEDsymbol.
Task 1: Recon probe — export map, module scan, clustering report (THE GATE)
Files:
- Create:
tools/frida/map_imports.py(Frida attach + export enum + scan + report;--recon) - Create:
tools/frida/test_map_imports.py(unit tests for the pure scan/match/cluster logic) - Reference:
tools/frida/dump_engine.py(attach + module-read boilerplate to mirror),tools/frida/README.md
Interfaces:
-
Produces (pure, importable):
build_export_index(exports) -> dict[int,str];scan_pointer_matches(mem: bytes, base_va: int, index: dict[int,str]) -> list[tuple[int,int,str]]returning(rva, value, name);cluster_runs(rvas: list[int], stride=4) -> list[tuple[int,int]]returning(start_rva, count). -
Produces (Task 2 consumes): the
--reconverdict — whether matches are module-resident and in-range. -
Step 1: Write failing tests for the pure scan/cluster logic
# tools/frida/test_map_imports.py (plain runner, project convention — no pytest)
import sys, struct
from map_imports import build_export_index, scan_pointer_matches, cluster_runs
FAILS = []
def check(c, m):
(FAILS.append(m) or print("FAIL:", m)) if not c else print("ok:", m)
def test_export_index_canonicalizes():
idx = build_export_index([
{"address": 0x76d80e70, "name": "LoadLibraryA", "module": "kernel32.dll"},
{"address": 0x76d7f7f0, "name": "GetProcAddress", "module": "kernel32.dll"},
])
check(idx[0x76d80e70] == "kernel32.dll!LoadLibraryA", "index maps addr->dll!func")
def test_scan_matches_little_endian_aligned():
# base 0x400000; two import pointers at RVA 0x10 and 0x14, junk elsewhere.
idx = {0x76d80e70: "kernel32.dll!LoadLibraryA", 0x76d7f7f0: "kernel32.dll!GetProcAddress"}
mem = bytearray(0x20)
struct.pack_into("<I", mem, 0x10, 0x76d80e70)
struct.pack_into("<I", mem, 0x14, 0x76d7f7f0)
struct.pack_into("<I", mem, 0x18, 0x12345678) # not an export -> no match
m = scan_pointer_matches(bytes(mem), 0x400000, idx)
check([(r, v) for r, v, _ in m] == [(0x10, 0x76d80e70), (0x14, 0x76d7f7f0)],
"scan finds aligned LE pointer matches, skips non-exports")
def test_scan_ignores_unaligned():
idx = {0x76d80e70: "kernel32.dll!LoadLibraryA"}
mem = bytearray(0x10)
struct.pack_into("<I", mem, 0x2, 0x76d80e70) # unaligned -> ignored
check(scan_pointer_matches(bytes(mem), 0x400000, idx) == [], "unaligned pointer ignored")
def test_cluster_runs_groups_contiguous():
# a run of 3 at 0x100/104/108, a singleton at 0x200
check(cluster_runs([0x100, 0x104, 0x108, 0x200]) == [(0x100, 3), (0x200, 1)],
"cluster groups contiguous aligned runs, isolates singleton")
def main():
test_export_index_canonicalizes(); test_scan_matches_little_endian_aligned()
test_scan_ignores_unaligned(); test_cluster_runs_groups_contiguous()
print("FAILURES:", len(FAILS)); return 1 if FAILS else 0
if __name__ == "__main__":
sys.exit(main())
- Step 2: Run the tests to verify they fail
Run: py -3.11 -X utf8 tools/frida/test_map_imports.py
Expected: FAIL — ModuleNotFoundError: No module named 'map_imports'.
- Step 3: Implement the pure logic + Frida recon in
map_imports.py
Pure functions (exact):
import struct
def build_export_index(exports):
"""[{address,name,module}] -> {int addr: 'module!name'} (first name wins per addr)."""
idx = {}
for e in exports:
a = int(e["address"])
if a and a not in idx:
idx[a] = f"{e['module']}!{e['name']}"
return idx
def scan_pointer_matches(mem, base_va, index):
"""Aligned little-endian DWORD scan; return [(rva, value, name)] for values in index."""
out = []
n = len(mem) & ~3
for off in range(0, n, 4):
v = struct.unpack_from("<I", mem, off)[0]
name = index.get(v)
if name is not None:
out.append((off, v, name))
return out
def cluster_runs(rvas, stride=4):
"""Group sorted RVAs into contiguous aligned runs -> [(start_rva, count)]."""
runs = []
for r in sorted(rvas):
if runs and r == runs[-1][0] + runs[-1][1] * stride:
s, c = runs[-1]; runs[-1] = (s, c + 1)
else:
runs.append((r, 1))
return runs
Frida driver (mirror dump_engine.py's attach/rpc; JS enumerates exports + returns the module range bytes). Adapt names to the existing helper if dump_engine.py factors one out:
import sys, frida, json, paths
MODULE = "AGE.EXE"; BASE = 0x400000
_JS = r"""
rpc.exports = {
exports: function () {
var out = [];
Process.enumerateModules().forEach(function (m) {
m.enumerateExports().forEach(function (e) {
out.push({address: e.address.toString(), name: e.name, module: m.name});
});
});
return out;
},
modulemem: function (base, size) {
return Memory.readByteArray(ptr(base), size);
},
modulesize: function (name) {
var m = Process.getModuleByName(name); return [m.base.toString(), m.size];
}
};
"""
def attach():
dev = frida.get_local_device()
procs = [p for p in dev.enumerate_processes() if p.name.lower().startswith("age")]
if not procs:
sys.exit("AGE.EXE not found — launch the game first.")
return dev.attach(procs[0].pid)
def collect():
session = attach()
script = session.create_script(_JS); script.load()
api = script.exports_sync
exports = [{"address": int(e["address"], 16), "name": e["name"], "module": e["module"]}
for e in api.exports()]
base_s, size = api.modulesize(MODULE)
mem = bytes(api.modulemem(int(base_s, 16), size))
session.detach()
return exports, int(base_s, 16), mem
def recon():
exports, base, mem = collect()
idx = build_export_index(exports)
matches = scan_pointer_matches(mem, base, idx)
runs = cluster_runs([r for r, _, _ in matches])
inrange = [r for r, _, _ in matches if r < 0x260000]
print(f"exports mapped={len(idx)} module bytes={len(mem)} matches={len(matches)} in-range(<0x260000)={len(inrange)}")
print(f"clusters(runs>=3)={[hex(s)+':'+str(c) for s,c in runs if c>=3]}")
print(f"singletons={sum(1 for _,c in runs if c==1)}")
verdict = "GATE-PASS: module-resident table present" if any(c>=3 for _,c in runs) and inrange else \
"GATE-FAIL: no in-module import table — STOP, pointers may be heap-resident"
print(verdict)
if __name__ == "__main__":
if "--recon" in sys.argv[1:]:
recon()
else:
from map_imports_full import build # Task 2 adds the writer; keep recon self-contained
build()
(If dump_engine.py already exposes an attach/read helper, import and reuse it instead of duplicating attach().)
- Step 4: Run the unit tests to verify they pass
Run: py -3.11 -X utf8 tools/frida/test_map_imports.py
Expected: PASS (4 tests, FAILURES: 0).
- Step 5: Run the live recon against the running game (THE GATE)
Ensure AGE.EXE is running, then:
Run: py -3.11 -u -X utf8 tools/frida/map_imports.py --recon
Expected: a report line with exports mapped= (thousands), matches=, in-range=, clusters(runs>=3)=, and a GATE-PASS/GATE-FAIL verdict.
-
GATE-PASS (a contiguous in-module run exists, matches in-range) → proceed to Task 2.
-
GATE-FAIL (no in-module table; matches heap-resident/out-of-range) → STOP. Record the finding in
docs/engine-re.md(the approach can't label the dump; a launch-keyed heap dump would be needed) and end the slice. Do not build Tasks 2–4. -
Step 6: Commit
git add tools/frida/map_imports.py tools/frida/test_map_imports.py
git commit -m "re(frida): import-map recon — export scan + clustering gate"
Task 2: Full mapper — write build/import-map.json
Precondition: Task 1 GATE-PASS.
Files:
- Create:
tools/frida/map_imports_full.py(the writer invoked bymap_imports.pydefault mode) - Modify:
tools/frida/map_imports.py(default mode already callsbuild()) - Test: extend
tools/frida/test_map_imports.py
Interfaces:
-
Consumes:
scan_pointer_matches,cluster_runs(Task 1). -
Produces:
select_table_matches(matches, runs, min_run=3) -> (table_map, singletons)— split matches into those inside a contiguous run of ≥min_run(auto-apply) vs isolated (review); andbuild/import-map.json({ "0xRVA": "dll!Func" }),build/import-map-singletons.json. -
Step 1: Write the failing split test
def test_select_table_matches_splits_runs_from_singletons():
from map_imports_full import select_table_matches
matches = [(0x100,0,"a!f"),(0x104,0,"b!g"),(0x108,0,"c!h"),(0x200,0,"d!i")]
runs = [(0x100,3),(0x200,1)]
table, singles = select_table_matches(matches, runs, min_run=3)
# asserts table has the 3 run entries keyed by hex RVA, singles has the lone one
check(set(table) == {"0x100","0x104","0x108"} and set(singles) == {"0x200"},
"run members auto-apply; singleton set aside")
Add its call to main(). Run → FAIL (select_table_matches missing).
- Step 2: Implement
select_table_matches+build()inmap_imports_full.py
import json, paths
from map_imports import scan_pointer_matches, cluster_runs # reuse
# collect() lives in map_imports; import it too
from map_imports import collect, build_export_index
def select_table_matches(matches, runs, min_run=3):
run_rvas = set()
for start, count in runs:
if count >= min_run:
run_rvas.update(start + i*4 for i in range(count))
table = {hex(r): n for r, _, n in matches if r in run_rvas}
singles = {hex(r): n for r, _, n in matches if r not in run_rvas}
return table, singles
def build():
exports, base, mem = collect()
idx = build_export_index(exports)
matches = scan_pointer_matches(mem, base, idx)
runs = cluster_runs([r for r, _, _ in matches])
table, singles = select_table_matches(matches, runs)
(paths.BUILD / "import-map.json").write_text(json.dumps(table, indent=2)+"\n", encoding="utf-8")
(paths.BUILD / "import-map-singletons.json").write_text(json.dumps(singles, indent=2)+"\n", encoding="utf-8")
print(f"wrote {len(table)} table imports -> build/import-map.json; {len(singles)} singletons set aside")
- Step 3: Run unit tests → PASS
Run: py -3.11 -X utf8 tools/frida/test_map_imports.py
Expected: PASS (5 tests).
- Step 4: Generate the real map (live) and sanity-check
Run: py -3.11 -u -X utf8 tools/frida/map_imports.py
Expected: build/import-map.json written with a sane count (dozens+); spot-check that recognizable APIs appear (e.g. a kernel32.dll!ReadFile, winmm.dll!timeGetTime, d3d9.dll!*). If the count is ~17 or clearly noise, revisit the run-selection min_run / range filter before proceeding.
- Step 5: Commit
git add tools/frida/map_imports_full.py tools/frida/test_map_imports.py
git commit -m "re(frida): write import-map.json (in-table matches; singletons set aside)"
Task 3: Ghidra labeler — apply imp_<dll>_<func> labels to /v2
Precondition: Task 2 produced build/import-map.json.
Files:
-
No new Python — driven via ghidra-mcp
run_script_inline(Java), like Task A. -
Step 1: Confirm the correct program is active (two-program gotcha)
Via MCP get_current_program_info (or a probe script): assert base 0x400000 / 4308 functions. If not, open_program /v2/range_00400000.bin + switch_program.
Expected: active program path /v2/range_00400000.bin.
- Step 2: Dry-run report — how many labels, how many collide
run_script_inline (Java): read build/import-map.json, for each RVA → name compute addr = 0x400000 + RVA; report counts: would-create vs already-has-a-USER_DEFINED-symbol. Do not mutate.
import java.nio.file.*; import java.util.*;
import ghidra.program.model.symbol.*;
String txt = new String(Files.readAllBytes(Paths.get("S:/Game Hacking/Eushully/Himegari/age-reimpl/build/import-map.json")));
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("\"0x([0-9a-fA-F]+)\":\\s*\"([^\"]+)\"").matcher(txt);
int create=0, collide=0, total=0;
SymbolTable st = currentProgram.getSymbolTable();
while (m.find()) {
total++;
long rva = Long.parseLong(m.group(1),16);
ghidra.program.model.address.Address a = toAddr(0x400000L + rva);
Symbol s = st.getPrimarySymbol(a);
if (s != null && s.getSource()==SourceType.USER_DEFINED) collide++; else create++;
}
println("total="+total+" wouldCreate="+create+" collideUserDefined="+collide);
Expected: wouldCreate ≈ total; collide small (only if an import RVA overlaps an existing user symbol).
- Step 3: Apply the labels (mutate + save)
run_script_inline (Java) in one transaction: for each RVA → "dll!Func", sanitize → imp_<dll>_<func> (dll without extension, non-alnum → _); skip if a USER_DEFINED symbol already exists at the addr; else createLabel(addr, name, true) (or st.createLabel(addr, name, SourceType.USER_DEFINED)). Print applied/skipped counts. Then save_program.
// name sanitize: "kernel32.dll!ReadFile" -> "imp_kernel32_ReadFile"
String raw = m.group(2); // dll!Func
String dll = raw.substring(0, raw.indexOf('!')).replaceAll("\\.[^.]*$","");
String fn = raw.substring(raw.indexOf('!')+1);
String nm = ("imp_"+dll+"_"+fn).replaceAll("[^A-Za-z0-9_]","_");
Expected: applied ≈ wouldCreate; 0 clobbers; save succeeds.
- Step 4: Commit (docs only — image saved in Ghidra project)
(No repo files change here; the Ghidra image lives in the project, not git. Proceed to Task 4 for the doc commit.)
Task 4: Validate, document, record
Files:
-
Modify:
docs/engine-re.md(mechanism + result under the IAT-reconstruction runbook entry) -
Modify:
docs/tools-reference.md(addmap_imports.py) -
Modify:
~/.claude/…/memory/himegari-port-status.md(milestone) -
Step 1: Validate against known hand-identified APIs
Via MCP: decompile/inspect the call sites we already RE'd and confirm they now read named:
-
FUN_0044f390/FUN_0040e980(resolver chain) →CreateFileA/SetFilePointer/ReadFile. -
sleep_op_0xc8→ itsDAT_0056f3d4timer source → atimeGetTime/GetTickCount-class import. -
A
d3d9Present/Direct3DCreate9site. Record which anchors resolved (need ≥2 of 3 for acceptance). -
Step 2: Update docs
docs/engine-re.md: in the runbook's "the right approach (queued, Frida-based)" note, replace "queued" with the result — mechanism confirmed, N labels applied, validated anchors, tool name. docs/tools-reference.md: add a map_imports.py row under "Runtime capture (Frida)".
- Step 3: Update the status memory
Add a milestone entry: Frida import-map DONE (or GATE-FAIL finding), N imports labeled, validated anchors, tool + map paths.
- Step 4: Commit
git add docs/engine-re.md docs/tools-reference.md
git commit -m "re(frida): import-map applied to /v2 — N Win32 APIs named at call sites; validated"
Self-Review
Spec coverage: recon gate → Task 1 (Steps 5–6, explicit STOP on GATE-FAIL); export map + scan → Task 1 Step 3; import-map.json + singleton split → Task 2; clean labels on /v2 (no type archive) → Task 3; ASLR-safe (same-process map+scan, RVA-only) → Global Constraints + collect(); validation anchors → Task 4 Step 1; docs/memory → Task 4. All covered.
Placeholder scan: the Frida attach()/JS is concrete but flagged "mirror/reuse dump_engine.py" — intentional (match the existing frida helper rather than duplicate); not a hidden TODO. No TBDs.
Type consistency: scan_pointer_matches returns (rva, value, name) and every consumer (recon, build, select_table_matches) unpacks that triple; cluster_runs returns (start_rva, count) used identically in recon/select_table_matches; build/import-map.json is {hex_rva: "dll!func"} produced by build() and consumed by the Task 3 regex "0x..":"..!..". Consistent.