Files
OpenMaidEngine/docs/superpowers/specs/2026-07-09-frida-import-map-design.md
gamer147 7f8da4be71 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>
2026-07-09 09:19:36 -04:00

6.9 KiB

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_0xc8DAT_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.