plan: frida import-map slice (spec + implementation plan)
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>
This commit is contained in:
384
docs/superpowers/plans/2026-07-09-frida-import-map.md
Normal file
384
docs/superpowers/plans/2026-07-09-frida-import-map.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# 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 `0x400000` main module). Never scan the old `/v2` dump for current-process pointer values.
|
||||
- **Read-only Frida only** — `enumerateModules`/`enumerateExports` + memory reads (the safe plain-JS pattern; no spawn, no `GetProcAddress` hook, no patching).
|
||||
- **Dump range:** the `/v2` image covers `0x400000`..`0x660000` (size `0x260000`). A match at RVA `≥ 0x260000` is 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` (base `0x400000`, 4308 fns) before any script — the two-program gotcha (`docs/engine-re.md` runbook). End image mutation with `save_program`.
|
||||
- Preserve annotations: never clobber an existing `USER_DEFINED` symbol.
|
||||
|
||||
---
|
||||
|
||||
## 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 `--recon` verdict — whether matches are module-resident and in-range.
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the pure scan/cluster logic**
|
||||
|
||||
```python
|
||||
# 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):
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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 by `map_imports.py` default mode)
|
||||
- Modify: `tools/frida/map_imports.py` (default mode already calls `build()`)
|
||||
- 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); and `build/import-map.json` (`{ "0xRVA": "dll!Func" }`), `build/import-map-singletons.json`.
|
||||
|
||||
- [ ] **Step 1: Write the failing split test**
|
||||
|
||||
```python
|
||||
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()` in `map_imports_full.py`**
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```java
|
||||
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`.
|
||||
|
||||
```java
|
||||
// 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` (add `map_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` → its `DAT_0056f3d4` timer source → a `timeGetTime`/`GetTickCount`-class import.
|
||||
- A `d3d9` `Present` / `Direct3DCreate9` site.
|
||||
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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
109
docs/superpowers/specs/2026-07-09-frida-import-map-design.md
Normal file
109
docs/superpowers/specs/2026-07-09-frida-import-map-design.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Frida import-map — naming dynamically-resolved Win32 APIs — design
|
||||
|
||||
**Date:** 2026-07-09
|
||||
**Status:** approved (design), plan pending
|
||||
**Replaces:** the abandoned pe-sieve Task B in
|
||||
`docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md`.
|
||||
**Home in the canonical map:** results + the mapping mechanism land in `docs/engine-re.md`
|
||||
(the runbook already carries the design sketch under "IAT reconstruction — tried, DOESN'T WORK →
|
||||
the right approach"); the tools go in `docs/tools-reference.md`. This spec is the design record.
|
||||
|
||||
## Motivation
|
||||
|
||||
AGE.EXE ships with a **zeroed IAT** resolved via `GetProcAddress` at load (confirmed: `pack_check.py`,
|
||||
and pe-sieve's `/imp` produced ~17 genuine + 300+ spurious entries — see the `engine-re.md` runbook).
|
||||
So the game's hot Win32 APIs (`ReadFile`/`CreateFileA`/`SetFilePointer`/`timeGetTime`/d3d9 device
|
||||
methods) show in Ghidra only as indirect calls through unnamed pointers, and we have been identifying
|
||||
them by format-string and behaviour archaeology. This slice names them at their call sites in one pass —
|
||||
the same high-leverage move as the Task A dispatch-handler labeling, for the Win32 surface.
|
||||
|
||||
## Approach (decided in brainstorming)
|
||||
|
||||
**Attach + scan, recon-first**, applying **clean labels** (no type archive):
|
||||
- Build `{runtime_addr → "dll!Func"}` from the **live** process's loaded-module export tables.
|
||||
- Scan the `0x400000` module's memory for aligned DWORDs whose value is in that set → those locations
|
||||
hold resolved import pointers → record **`RVA → name`**.
|
||||
- Apply `imp_<dll>_<func>` **labels** to the `/v2` Ghidra image at `0x400000 + RVA`.
|
||||
|
||||
**Why ASLR-safe:** the export map and the module scan are done in the *same* live process, so the stored
|
||||
pointer values and the export addresses are from one consistent address space. We carry back only the
|
||||
**RVA** (offset into the fixed-base `0x400000` main module, which is not relocated), so the labels
|
||||
computed live apply to the earlier dump — the code/table *locations* are identical across launches even
|
||||
though the stored pointer *values* differ.
|
||||
|
||||
**Why read-only / anti-tamper-safe:** only Frida `enumerateModules`/`enumerateExports` + memory reads —
|
||||
the same plain-JS read pattern the existing capture scripts use. No spawn, no `GetProcAddress` hook, no
|
||||
patching. (This is *not* the "live-debugger" case the user gated behind "probe carefully first.")
|
||||
|
||||
## The one unknown, and the recon gate
|
||||
|
||||
We do **not** yet know *where* the packer stores the resolved pointers. pe-sieve found genuine import
|
||||
thunks at RVA `~0x202xxx` **inside the module** (`in_main:1`), which suggests a module-resident,
|
||||
RVA-stable pointer table — exactly what this approach needs. But whether the `GetProcAddress`-resolved
|
||||
hot APIs also land in-module, or in a heap block (non-stable base, **not** in the `/v2` dump), is unproven.
|
||||
|
||||
**Recon is a HARD GATE.** Task 1 is read-only and only *reports*. It must show the matches cluster in a
|
||||
**module-resident** region within the dump's range (RVA `< 0x260000`, the dump size) before any labeler
|
||||
is built. If the pointers turn out to be heap-resident, we **stop and report that honestly** — the slice
|
||||
cannot label the dump and would need a different mechanism (e.g. a heap dump keyed to the same launch).
|
||||
No forcing.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `tools/frida/map_imports.py` — export map + module scan (Tasks 1 & 2)
|
||||
|
||||
- Attach by PID (default: locate `AGE.EXE`, like `dump_engine.py`).
|
||||
- Frida JS: `Process.enumerateModules()` → `Module.enumerateExports(m.name)` → `{addr → "dll!Func"}`.
|
||||
Handle collisions/forwarders (multiple names per addr) by keeping a canonical name + noting alternates.
|
||||
- Read the `0x400000` module range; scan 4-byte-aligned DWORDs; collect `(RVA, value, name)` for values
|
||||
in the export set.
|
||||
- **`--recon` (Task 1, report only):** print total exports mapped, total matches, and the **clustering**:
|
||||
contiguous aligned runs (candidate import tables) vs isolated singletons; min/max RVA of matches and how
|
||||
many fall within the dump range (`< 0x260000`). No file written. This is the gate.
|
||||
- **default (Task 2):** write `build/import-map.json` = `{ "0xRVA": "dll!Func", ... }` restricted to matches
|
||||
inside identified contiguous table region(s); isolated singletons written to a separate
|
||||
`build/import-map-singletons.json` for review (not auto-applied — a lone match is more likely coincidental).
|
||||
|
||||
### 2. Ghidra labeler (Task 3) — `run_script_inline` Java (reuse the Task A pattern)
|
||||
|
||||
- Confirm the active program is `/v2/range_00400000.bin` (base `0x400000`, 4308 fns — the two-program
|
||||
gotcha from Task A).
|
||||
- Read `build/import-map.json`; for each `RVA → name`: `createLabel(toAddr(0x400000+RVA),
|
||||
"imp_" + sanitize(dll) + "_" + func, SourceType.USER_DEFINED)`. Skip if a `USER_DEFINED` label already
|
||||
exists there (idempotent, no clobber). One transaction; `save_program`.
|
||||
|
||||
## Data flow
|
||||
|
||||
live process ─▶ export map `{addr→name}` + module DWORD scan ─▶ `RVA→name` (`build/import-map.json`)
|
||||
─▶ Ghidra `createLabel` on `/v2` ─▶ call sites read `CALL dword ptr [imp_kernel32_ReadFile]`.
|
||||
|
||||
## Validation (Task 4)
|
||||
|
||||
Cross-check against APIs already hand-identified, so the mapping is proven, not assumed:
|
||||
- The `call-script` resolver chain (`FUN_0044f390` / `FUN_0040e980`) should now show `CreateFileA` /
|
||||
`SetFilePointer` / `ReadFile` at its file-I/O call sites.
|
||||
- The `sleep` timer source (`sleep_op_0xc8` → `DAT_0056f3d4`) should resolve to a `timeGetTime`/`GetTickCount`-class import.
|
||||
- A `d3d9` `Present`/`Direct3DCreate9` site should be named.
|
||||
|
||||
If those light up correctly, the mapping is real. Record the count of labels applied and the validated
|
||||
anchors. Then update `docs/engine-re.md` (mechanism + result), `docs/tools-reference.md` (the new tool),
|
||||
and the status memory.
|
||||
|
||||
## Scope & boundaries
|
||||
|
||||
- **In:** all loaded modules' exports (no curation); module-resident import pointers; clean labels.
|
||||
- **Out (explicitly):** typed Win32 signatures / the type archive (labels only this pass — signatures are a
|
||||
later layer); heap-resident pointer tables (out of reach of the dump — recon decides if this blocks us);
|
||||
the `GetProcAddress`-hook/spawn variant (rejected: fragile, needs spawn); renaming the DLL functions
|
||||
themselves (we label the *pointer slots*, not the targets, which aren't in the dump).
|
||||
- **Regenerable:** `build/import-map.json` regenerates from a live run; disposable per project convention.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Recon (Task 1) reports match clustering and the in-module/in-range verdict; **the gate is passed
|
||||
explicitly** (matches are module-resident, within the dump) before Task 2/3 run — or the slice stops
|
||||
here with a documented finding.
|
||||
- `build/import-map.json` produced with a sane count (dozens+ of real imports, not ~17 and not 300+ noise).
|
||||
- Ghidra labeler applies labels with 0 clobbers of existing `USER_DEFINED` symbols; `save_program` succeeds.
|
||||
- ≥2 of the three validation anchors (resolver-chain file I/O, sleep timer, d3d9) read as named calls.
|
||||
- Docs + memory updated.
|
||||
Reference in New Issue
Block a user