Add the import-layer repair and the Themida absorption

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.
This commit is contained in:
root
2026-10-27 16:29:59 +08:00
parent af9603c985
commit e5b45d9072
19 changed files with 1281 additions and 2 deletions
+124
View File
@@ -0,0 +1,124 @@
"""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