mirror of
https://codeberg.org/reverse-skill-lab/exe-reverse
synced 2026-11-15 20:44:20 +08:00
references/iat-virtualization-and-call-site-rewriting.md records the two edits one protector family makes to a native image: the import slot stops holding an address and the indirect call that read it becomes a relative call to a stub. It carries the pairing rule -- a site's target equals a slot's value -- the three placements of the filler byte in a six-byte rewrite and the refusal when there is none, the divergence between builds that modify the code section and builds that only redirect the table, the small set of imports redirected twice, the choice between repairing, extracting a payload, observing and reporting, and the limits an automatic unpacker inherits from its own dump. scripts/iat_callsite_restore.py implements the mechanical half offline: inventory the slots from three sources, pair by value, decide the six-byte window per site, refuse an ambiguous slot and a site whose sixth byte is the next instruction, rewrite, then re-decode every written site from the file it wrote. It is report-only unless --out is given, and it does not unpack: a virtualised entry has no site list to restore. Registered in both symptom tables, the routing, reference and script indexes, the capability registry as call_site_restore, and the coverage, route-inventory and evidence tables. dynamic-debugging.md gains the instrument rule the second write-up demonstrates (a target that runs at full speed and dies when stopped has a perturbation detector), memory-and-dumps.md gains the call-site step of a rebuild, environment.md gains the compatibility-shim trap, and protectors-and-packers.md gains one recognition row plus a pointer rather than the detail, because that file makes no product-level claims. The target-behaviour claims stay unverified: no protected sample was analysed here. What is observed is the tool's behaviour on a generated image -- both filler positions, both refusals, both displacement forms -- and what the source material states about itself. FINDINGS F16 records the defect this pass produced, a slot's magnitude read as its meaning, with the test that fails without the fix. .gitignore gains ref/, which was not covered by the existing rule because that rule carries a leading dot.
125 lines
5.3 KiB
Python
125 lines
5.3 KiB
Python
"""The import-layer repair's published contract, exercised as a subprocess.
|
|
|
|
What a caller can rely on, and what this file pins:
|
|
|
|
* without --out, nothing is written and the run is an analysis (exit 0 or 1 depending on whether a
|
|
site was refused, never a silent file);
|
|
* 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.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.cli
|
|
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def document(text):
|
|
return json.JSONDecoder().raw_decode(text)[0]
|
|
|
|
|
|
def test_analysis_writes_nothing_and_reports_incomplete(run_script, iat_scenario, tmp_path):
|
|
scen = iat_scenario("pe64")
|
|
before = sha(scen.path)
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18", cwd=tmp_path)
|
|
assert result.returncode == 1, result.stderr
|
|
assert "RESULT=incomplete" in result.stdout
|
|
assert sha(scen.path) == before
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_the_rewrite_is_verified_and_leaves_the_input_alone(run_script, iat_scenario,
|
|
load_script, tmp_path):
|
|
scen = iat_scenario("pe64")
|
|
before = sha(scen.path)
|
|
out = tmp_path / "fixed.exe"
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18",
|
|
"--out", out)
|
|
assert result.returncode == 1, result.stderr
|
|
assert "VERIFY FAILED" not in result.stdout
|
|
assert "RESULT=incomplete" in result.stdout
|
|
assert sha(scen.path) == before, "the input was modified"
|
|
|
|
pe = load_script("peutil.py").PE(out.read_bytes())
|
|
for window in (scen.sites["filler_after"], scen.sites["filler_before"] - 1):
|
|
raw = pe.rva_bytes(window, 6)
|
|
assert raw[:2] == b"\xFF\x15", raw.hex()
|
|
# The refused site is untouched: the byte after its call is still the original instruction.
|
|
assert pe.rva_bytes(scen.sites["no_filler"] + 5, 1) == b"\xC3"
|
|
|
|
|
|
def test_the_pe32_displacement_is_an_absolute_slot_address(run_script, iat_scenario,
|
|
load_script, tmp_path):
|
|
scen = iat_scenario("pe32")
|
|
out = tmp_path / "fixed32.exe"
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18", "--out", out)
|
|
assert result.returncode == 1, result.stderr
|
|
pe = load_script("peutil.py").PE(out.read_bytes())
|
|
raw = pe.rva_bytes(scen.sites["filler_after"], 6)
|
|
assert raw[:2] == b"\xFF\x15"
|
|
assert int.from_bytes(raw[2:], "little") == scen.base + scen.slot_rva
|
|
|
|
|
|
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
|
|
assert "RESULT=no-pairs" in result.stdout
|
|
|
|
|
|
def test_out_equal_to_the_input_is_a_usage_error(run_script, iat_scenario):
|
|
scen = iat_scenario("pe64")
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--out", scen.path)
|
|
assert result.returncode == 2
|
|
assert "never written" in result.stderr
|
|
|
|
|
|
def test_a_non_pe_is_exit_three(run_script, tmp_path):
|
|
junk = tmp_path / "notape.bin"
|
|
junk.write_bytes(b"MZ" + b"\x00" * 200)
|
|
result = run_script("iat_callsite_restore.py", junk)
|
|
assert result.returncode == 3
|
|
assert "error:" in result.stderr
|
|
|
|
|
|
def test_json_carries_the_document_then_the_verdict_line(run_script, iat_scenario):
|
|
scen = iat_scenario("pe64")
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18", "--json")
|
|
payload = document(result.stdout)
|
|
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
|
|
assert lines[-1].startswith("RESULT=")
|
|
assert payload["result"] == lines[-1].split("=", 1)[1]
|
|
assert payload["input"]["bitness"] == scen.bitness
|
|
assert payload["input"]["base_used"] == scen.base
|
|
assert sorted(payload["iat"]["classes"]) == ["above_image", "below_image", "in_image", "zero"]
|
|
assert [p["site_rva"] for p in payload["pairs"]] == [scen.sites["filler_after"],
|
|
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"
|
|
|
|
|
|
def test_full_out_writes_the_same_document_and_says_where(run_script, iat_scenario, tmp_path):
|
|
scen = iat_scenario("pe64")
|
|
record = tmp_path / "report.json"
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18",
|
|
"--full-out", record)
|
|
assert result.returncode == 1
|
|
assert "full record" in result.stdout
|
|
saved = json.loads(record.read_text(encoding="utf-8"))
|
|
assert saved["tool"] == "iat_callsite_restore.py"
|
|
assert saved["result"] == "incomplete"
|
|
|
|
|
|
def test_max_sites_bounds_the_printed_lists(run_script, iat_scenario):
|
|
scen = iat_scenario("pe64")
|
|
result = run_script("iat_callsite_restore.py", scen.path, "--iat", "0x3020,0x18",
|
|
"--max-sites", "0")
|
|
assert result.returncode == 1
|
|
assert "more" in result.stdout
|