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>
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
0x400000module's memory for aligned DWORDs whose value is in that set → those locations hold resolved import pointers → recordRVA → name. - Apply
imp_<dll>_<func>labels to the/v2Ghidra image at0x400000 + 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, likedump_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
0x400000module 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 separatebuild/import-map-singletons.jsonfor 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(base0x400000, 4308 fns — the two-program gotcha from Task A). - Read
build/import-map.json; for eachRVA → name:createLabel(toAddr(0x400000+RVA), "imp_" + sanitize(dll) + "_" + func, SourceType.USER_DEFINED). Skip if aUSER_DEFINEDlabel 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-scriptresolver chain (FUN_0044f390/FUN_0040e980) should now showCreateFileA/SetFilePointer/ReadFileat its file-I/O call sites. - The
sleeptimer source (sleep_op_0xc8→DAT_0056f3d4) should resolve to atimeGetTime/GetTickCount-class import. - A
d3d9Present/Direct3DCreate9site 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.jsonregenerates 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.jsonproduced with a sane count (dozens+ of real imports, not ~17 and not 300+ noise).- Ghidra labeler applies labels with 0 clobbers of existing
USER_DEFINEDsymbols;save_programsucceeds. - ≥2 of the three validation anchors (resolver-chain file I/O, sleep timer, d3d9) read as named calls.
- Docs + memory updated.