Find the import table from the calls when no directory describes it

A captured image often has no import data: the descriptor is gone, the IAT directory is
gone, and nobody took a write watch. The table is still findable, because the rewrite left
a relation behind -- the slot holds the address the call targets -- so the call sites
become the search key.

discover_iat scans the non-executable sections for pointer-aligned entries equal to the
target of a relative call that leaves the image, then clusters the matches by proximity.
One cluster is used and its span becomes the inventory. Two are printed and refused: a
duplicated table, a copied table and a table split by a section boundary all look like
this from the outside, and picking one produces a file that loads and calls the wrong API
for one import. A single match is called weak rather than used.

Discovery runs when the described slots explained none of the calls, not only when the
inventory came back empty -- a protector that leaves a plausible-looking but useless
descriptor is the case the narrower rule missed. --iat suppresses it because that is the
reader asserting the range, and --no-discover is the control that keeps "the search
worked" from being a statement about the fixture.

walk_sites and pair_sites replace the single scan-and-pair pass, because discovery needs
the targets before any slot exists and one function could not express that order.
decide_result gains ambiguous-iat, which exits 1 like the other two verdicts that mean
this run did not finish the job.

Tests: the same image with its slots outside every directory recovers the two pairs the
explicit range produced; the control with --no-discover still reports needs-trace; a
second copy of the table in another section is refused with its own token; and a search
that matched nothing says so instead of reading as a clean image.

The reference gains the search, its two refusals, and the pair of failures that look
identical from the report -- slots holding tags have no value relation to search, and a
wrong base finds nothing either, so the base is checked first.
This commit is contained in:
root
2026-10-27 16:45:46 +08:00
parent 9ce5ac29d2
commit 5d7c41bdd5
10 changed files with 331 additions and 74 deletions
+26 -1
View File
@@ -7,7 +7,8 @@ What a caller can rely on, and what this file pins:
* with --out, the input file is byte-identical afterwards, the output is a different file, and the
six bytes written at each site decode to an indirect call through the slot the report names;
* a refusal is exit 1 and the word "incomplete" -- the failure mode this repository is organised
against is a partial repair reported as a finished one.
against is a partial repair reported as a finished one; two candidate tables get their own token,
"ambiguous-iat", because a person has to say which one is the table.
"""
import hashlib
import json
@@ -67,6 +68,29 @@ def test_the_pe32_displacement_is_an_absolute_slot_address(run_script, iat_scena
assert int.from_bytes(raw[2:], "little") == scen.base + scen.slot_rva
def test_discovery_finds_the_table_from_the_calls(run_script, iat_scenario):
"""No --iat, no described range for these slots: the report must show the search, not a guess."""
scen = iat_scenario("pe64")
text = run_script("iat_callsite_restore.py", scen.path)
assert text.returncode == 1, text.stderr
assert "discover unique after no-pairs" in text.stdout
assert "<- used" in text.stdout
payload = document(run_script("iat_callsite_restore.py", scen.path, "--json").stdout)
assert payload["discovery"]["chosen"] == {"start": 0x3020, "size": 0x18}
assert payload["discovery"]["trigger"] == "no-pairs"
assert [p["site_rva"] for p in payload["pairs"]] == [scen.sites["filler_after"],
scen.sites["filler_before"]]
def test_two_candidate_tables_are_refused_with_their_own_token(run_script, iat_scenario_twin):
result = run_script("iat_callsite_restore.py", iat_scenario_twin)
assert result.returncode == 1, result.stderr
assert "RESULT=ambiguous-iat" in result.stdout
assert "discover ambiguous" in result.stdout
assert "more than one run of pointers" in result.stdout
def test_a_clean_image_exits_zero(run_script, fixtures):
result = run_script("iat_callsite_restore.py", fixtures.pe64.path)
assert result.returncode == 0, result.stderr
@@ -102,6 +126,7 @@ def test_json_carries_the_document_then_the_verdict_line(run_script, iat_scenari
scen.sites["filler_before"]]
assert {r["reason"] for r in payload["refused"]} == {"no-filler", "ambiguous-slot"}
assert "output" not in payload, "a report-only run must not claim an output file"
assert payload["discovery"] is None, "--iat is the reader asserting the range; no search runs"
def test_full_out_writes_the_same_document_and_says_where(run_script, iat_scenario, tmp_path):