Files
exe-reverse/tests/unit/test_iat_callsite_restore.py
T
root 5d7c41bdd5 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.
2026-10-27 16:45:46 +08:00

238 lines
11 KiB
Python

"""The import-layer repair's decisions, exercised on a generated scenario.
The script makes three decisions per call site and one per slot, and every one of them can be wrong
in a way that still produces a loadable file:
* which six-byte window belongs to a site (the filler byte can be on either side),
* whether a site can be rewritten at all (a site with no filler byte is a structural edit),
* which slot a target belongs to (two slots can hold the same address).
So the tests are about those decisions rather than about the report's wording: each case asserts the
window or the refusal reason, and the ones covering the rewrite re-decode the bytes the script wrote
instead of trusting the counters it printed.
"""
import argparse
import pytest
pytestmark = pytest.mark.unit
def _args(image, **kw):
base = {"image": str(image), "out": None, "base": None, "iat": None, "max_sites": 40,
"no_discover": False, "discover_gap": 0x1000}
base.update(kw)
return argparse.Namespace(**base)
def test_parse_range_accepts_both_separators(load_script):
mod = load_script("iat_callsite_restore.py")
assert mod.parse_range("0x48D000,0x1000") == (0x48D000, 0x1000)
assert mod.parse_range("0x48D000:0x1000") == (0x48D000, 0x1000)
with pytest.raises(ValueError):
mod.parse_range("0x48D000")
def test_encode_site_is_rip_relative_on_pe32plus(iat_scenario, load_script):
"""PE32+ displacement is relative to the end of the instruction, not to the slot."""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
pe = load_script("peutil.py").PE(scen.path.read_bytes())
raw = mod.encode_site(pe, scen.base, 0x1200, "call", scen.slot_rva)
assert raw[:2] == b"\xFF\x15"
assert int.from_bytes(raw[2:], "little") == 0x3020 - 0x1200 - 6
def test_encode_site_is_absolute_on_pe32(iat_scenario, load_script):
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe32")
pe = load_script("peutil.py").PE(scen.path.read_bytes())
raw = mod.encode_site(pe, scen.base, 0x1200, "jmp", scen.slot_rva)
assert raw[:2] == b"\xFF\x25"
assert int.from_bytes(raw[2:], "little") == scen.base + scen.slot_rva
def test_plan_places_the_window_on_either_side_of_the_call(iat_scenario, load_script):
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = scen.path.read_bytes()
pe = load_script("peutil.py").PE(blob)
pairs = [
{"site_rva": scen.sites["filler_after"], "kind": "call", "target_va": 0,
"slot_rvas": [scen.slot_rva]},
{"site_rva": scen.sites["filler_before"], "kind": "call", "target_va": 0,
"slot_rvas": [scen.slot_rva]},
]
planned, refused = mod.plan_windows(pe, pairs)
assert refused == []
windows = {item["site_rva"]: item["window_rva"] for item in planned}
assert windows[scen.sites["filler_after"]] == scen.sites["filler_after"]
assert windows[scen.sites["filler_before"]] == scen.sites["filler_before"] - 1
def test_plan_refuses_a_site_whose_sixth_byte_is_an_instruction(iat_scenario, load_script):
"""The byte after the call here is a return, so writing would consume the next instruction."""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
pe = load_script("peutil.py").PE(scen.path.read_bytes())
pairs = [{"site_rva": scen.sites["no_filler"], "kind": "call", "target_va": 0,
"slot_rvas": [scen.slot_rva]}]
planned, refused = mod.plan_windows(pe, pairs)
assert planned == []
assert [r["reason"] for r in refused] == ["no-filler"]
assert "next instruction" in refused[0]["detail"]
def test_plan_refuses_a_target_two_slots_hold(iat_scenario, load_script):
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
pe = load_script("peutil.py").PE(scen.path.read_bytes())
pairs = [{"site_rva": scen.sites["ambiguous"], "kind": "call",
"target_va": scen.base + 0x9100, "slot_rvas": list(scen.dup_slot_rvas)}]
planned, refused = mod.plan_windows(pe, pairs)
assert planned == []
assert [r["reason"] for r in refused] == ["ambiguous-slot"]
for rva in scen.dup_slot_rvas:
assert "%#x" % rva in refused[0]["detail"]
def test_the_report_pairs_the_restorable_sites_and_names_the_others(iat_scenario, load_script):
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = scen.path.read_bytes()
pe = load_script("peutil.py").PE(blob)
args = _args(scen.path, iat=(0x3020, 0x18))
report = mod.build_report(args, blob, pe)
assert [p["site_rva"] for p in report["pairs"]] == [scen.sites["filler_after"],
scen.sites["filler_before"]]
assert {r["reason"] for r in report["refused"]} == {"no-filler", "ambiguous-slot"}
assert "explicit-range" in report["iat"]["sources"]
assert report["input"]["base_used"] == scen.base
def test_without_the_slot_range_the_table_is_found_from_the_calls(iat_scenario, load_script):
"""Discovery is the default, and it recovers the same two pairs the explicit range produced.
The scenario's slots sit in a range no directory describes -- the normal shape of a captured
image -- so this is the case discovery exists for. Arriving at the same two pairs as the --iat
run is the whole claim: the table was found, not guessed.
"""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = scen.path.read_bytes()
pe = load_script("peutil.py").PE(blob)
report = mod.build_report(_args(scen.path), blob, pe)
assert report["discovery"]["status"] == "unique"
assert report["discovery"]["trigger"] == "no-pairs"
assert report["discovery"]["chosen"] == {"start": 0x3020, "size": 0x18}
assert "discovered-range" in report["iat"]["sources"]
assert [p["site_rva"] for p in report["pairs"]] == [scen.sites["filler_after"],
scen.sites["filler_before"]]
assert {r["reason"] for r in report["refused"]} == {"no-filler", "ambiguous-slot"}
def test_discovery_can_be_turned_off_for_a_control_run(iat_scenario, load_script):
"""With discovery suppressed, the same image reports what the described slots could not do.
This is the control for the test above: without it, "discovery worked" could be a statement
about the scenario rather than about the search.
"""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = scen.path.read_bytes()
pe = load_script("peutil.py").PE(blob)
report = mod.build_report(_args(scen.path, no_discover=True), blob, pe)
assert report["pairs"] == []
assert report["discovery"] is None
assert len(report["unpaired"]) == 4
assert mod.decide_result(report, wrote=False) == "needs-trace"
def test_a_wrong_base_produces_the_same_verdict_as_a_missing_table(iat_scenario, load_script):
"""Two different causes, one report -- which is why the printed hint names the base first.
A site's target is computed in the address space the image ran at, so a base off by the
relocation delta turns every real pair into an unpaired call. The verdict cannot tell the two
apart, and a reader who assumes "the table holds handles" from this report alone would go and
write a trace they do not need.
"""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = scen.path.read_bytes()
pe = load_script("peutil.py").PE(blob)
report = mod.build_report(_args(scen.path, base=0x400000, iat=(0x3020, 0x18)), blob, pe)
assert report["pairs"] == []
assert len(report["unpaired"]) == 4
assert mod.decide_result(report, wrote=False) == "needs-trace"
assert report["input"]["base_source"] == "explicit --base"
def test_two_clusters_of_matching_pointers_are_refused(iat_scenario_twin, load_script):
mod = load_script("iat_callsite_restore.py")
blob = iat_scenario_twin.read_bytes()
pe = load_script("peutil.py").PE(blob)
report = mod.build_report(_args(iat_scenario_twin), blob, pe)
assert report["discovery"]["status"] == "ambiguous"
assert len(report["discovery"]["clusters"]) == 2
assert report["discovery"]["chosen"] is None
assert report["pairs"] == []
assert mod.decide_result(report, wrote=False) == "ambiguous-iat"
def test_discovery_reports_when_no_slot_holds_a_target(iat_scenario, load_script, tmp_path):
"""A search that found nothing must say so; an empty result is not a clean image."""
mod = load_script("iat_callsite_restore.py")
scen = iat_scenario("pe64")
blob = bytearray(scen.path.read_bytes())
pe = load_script("peutil.py").PE(bytes(blob))
for rva in (0x3020, 0x3028, 0x3030):
off = pe.rva_to_offset(rva)
blob[off:off + 8] = b"\x00" * 8
path = tmp_path / "no-table.exe"
path.write_bytes(bytes(blob))
pe2 = load_script("peutil.py").PE(bytes(blob))
report = mod.build_report(_args(path), bytes(blob), pe2)
assert report["discovery"]["status"] == "none"
assert report["discovery"]["hits"] == 0
assert report["pairs"] == []
assert mod.decide_result(report, wrote=False) == "needs-trace"
def test_a_clean_image_reports_no_pairs(load_script, fixtures):
mod = load_script("iat_callsite_restore.py")
blob = fixtures.pe64.data
pe = load_script("peutil.py").PE(blob)
report = mod.build_report(_args(fixtures.pe64.path), blob, pe)
assert report["pairs"] == []
assert report["unpaired"] == []
assert mod.decide_result(report, wrote=False) == "no-pairs"
def test_no_slot_values_is_its_own_verdict(load_script):
"""A run with nothing to pair against must not send the reader to write a trace.
Both causes -- a table that holds handles, and a table this run never found -- produce the same
empty pair list. The first needs a trace; the second needs --iat. Collapsing them into one token
was the first version of this rule.
"""
mod = load_script("iat_callsite_restore.py")
with_no_slots = {"pairs": [], "refused": [], "unpaired": [1], "iat": {"slots": 0, "classes": {}}}
assert mod.decide_result(with_no_slots, wrote=False) == "no-slots"
with_slots = {"pairs": [], "refused": [], "unpaired": [1],
"iat": {"slots": 4, "classes": {}}}
assert mod.decide_result(with_slots, wrote=False) == "needs-trace"
def test_result_tokens_follow_the_evidence(load_script):
mod = load_script("iat_callsite_restore.py")
base = {"pairs": [], "refused": [], "unpaired": [], "iat": {"slots": 4, "classes": {}}}
assert mod.decide_result(dict(base), wrote=False) == "no-pairs"
assert mod.decide_result(dict(base, pairs=[1]), wrote=False) == "report-only"
assert mod.decide_result(dict(base, pairs=[1]), wrote=True) == "repaired"
assert mod.decide_result(dict(base, unpaired=[1]), wrote=False) == "needs-trace"
assert mod.decide_result(dict(base, refused=[1]), wrote=True) == "incomplete"
assert mod.decide_result(dict(base, verification_failures=[1]), wrote=True) == "incomplete"