mirror of
https://codeberg.org/reverse-skill-lab/exe-reverse
synced 2026-11-15 20:34:25 +08:00
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:
+1
-1
@@ -26,7 +26,7 @@ and "the script was measured doing X on this host".
|
||||
| `cli/test_cli_coverage.py` | Coverage derived from the scripts directory rather than a hand-written list: every shipped `.py` is either exercised or declared uncovered with a reason, and every one answers `--help` with its usage block. A new script fails here until it is registered. |
|
||||
| `cli/test_cli_json.py` | Each script's machine-readable output: keys, values, and the conventions that are not what a reader would assume -- including `pe_triage.py`'s identity block on a generated image (and that it writes nothing) and `pe_diff.py`'s self-comparison, which is the control for every other diff. |
|
||||
| `cli/test_cli_registry_surface.py` | The registry and doctor subprocess contracts: `--list` / `--json` / single-capability exit codes and `RESULT=` tokens, the doctor's three blocks and its 0-versus-3 verdict, and the view flags that print one block only. |
|
||||
| `unit/test_iat_callsite_restore.py` | The import-layer repair's four decisions on a generated image: the six-byte window on either side of a call, the refusal when the sixth byte is the next instruction, the refusal when two slots hold one address, the pairing rule, and the verdict token that follows from the evidence rather than from a count. |
|
||||
| `unit/test_iat_callsite_restore.py` | The import-layer repair's decisions on a generated image: the six-byte window on either side of a call, the refusal when the sixth byte is the next instruction, the refusal when two slots hold one address, the pairing rule, the search that recovers the table from the call targets and the control that suppresses it, two candidate tables refused, an empty search reported as empty, and the verdict token that follows from the evidence rather than from a count. |
|
||||
| `cli/test_cli_iat_callsite.py` | The repair's published contract as a subprocess: a report-only run writes nothing into the working directory, `--out` leaves the input byte-identical and the written sites re-decode to indirect calls, the PE32 displacement is an absolute slot address where the PE32+ one is relative, and a refusal is exit 1 rather than a quiet success. |
|
||||
| `cli/test_cli_dart_snapshot.py` | The snapshot reader's CLI: the refusals and their exit codes, `--find` case-insensitivity and the exit code a miss produces, `--at` by file offset and by virtual address, and the `--json` document. |
|
||||
| `integration/test_pipeline_end_to_end.py` | Build, locate, patch, re-read: the patched bytes, the untouched control site, the recomputed checksum, and a control run in which nothing changes. |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -591,6 +591,25 @@ def iat_scenario(tmp_path_factory, build_fixture, load_script):
|
||||
return _build
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def iat_scenario_twin(iat_scenario, fixture_dir, load_script):
|
||||
"""The same scenario with one more slot in a different section.
|
||||
|
||||
Two runs of matching pointers is the case discovery must refuse: the second cluster holds the
|
||||
same target from a section the first cluster is not in, which is what a duplicated or copied
|
||||
table looks like from the outside. Built here so both layers test the same bytes.
|
||||
"""
|
||||
scen = iat_scenario("pe64")
|
||||
data = bytearray(scen.path.read_bytes())
|
||||
pe = load_script("peutil.py").PE(bytes(data))
|
||||
off = pe.rva_to_offset(0x5010)
|
||||
assert off is not None, "the second-cluster RVA has no file offset in the fixture"
|
||||
data[off:off + 8] = struct.pack("<Q", scen.base + 0x9000)
|
||||
path = fixture_dir / "pe64-iat-twin.exe"
|
||||
path.write_bytes(bytes(data))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def synthetic_elf(fixture_dir):
|
||||
"""The generated ELF64, written once for the whole session."""
|
||||
|
||||
@@ -19,7 +19,8 @@ pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _args(image, **kw):
|
||||
base = {"image": str(image), "out": None, "base": None, "iat": None, "max_sites": 40}
|
||||
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)
|
||||
|
||||
@@ -110,18 +111,41 @@ def test_the_report_pairs_the_restorable_sites_and_names_the_others(iat_scenario
|
||||
assert report["input"]["base_used"] == scen.base
|
||||
|
||||
|
||||
def test_without_the_slot_range_the_report_says_needs_trace(iat_scenario, load_script):
|
||||
"""No slot describes these targets, and the calls leave the image: that is the handle case.
|
||||
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.
|
||||
|
||||
It is also the reason the verdict is not "clean". Nothing was found by pairing, and the correct
|
||||
reading is that pairing cannot work on this image, not that the image is untouched.
|
||||
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"
|
||||
|
||||
@@ -145,6 +169,38 @@ def test_a_wrong_base_produces_the_same_verdict_as_a_missing_table(iat_scenario,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user