Init
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Locator comparison between two builds of the same fixture.
|
||||
|
||||
`--across` answers one question: does the pattern still match the same number of sites on the
|
||||
other build? A count that changes is a statement about the signature, not about the target, and
|
||||
the point of these tests is that each of the three answers (same, changed, unusable either way)
|
||||
is distinguishable in the output and in the exit code.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rebuilt(run_script, tmp_path, fixtures):
|
||||
"""A second, independently built copy of the same recipe."""
|
||||
out = tmp_path / "rebuilt.exe"
|
||||
result = run_script("make_fixture_pe.py", "--out", out, "--recipe", fixtures.pe64.recipe)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return out
|
||||
|
||||
|
||||
def test_same_recipe_reports_an_unchanged_hit_count(run_script, fixtures, rebuilt, hexpat):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", hexpat(b"fixture.pdb"),
|
||||
"--across", rebuilt, "--json")
|
||||
assert result.returncode == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["hits"] == entry["across"]["hits"] == 1
|
||||
assert entry["across"]["same_count"] is True
|
||||
assert entry["across"]["verdict"] == "unique"
|
||||
assert payload["result"] == "ok"
|
||||
|
||||
|
||||
def test_a_recipe_without_the_debug_directory_changes_the_hit_count(run_script, fixtures,
|
||||
hexpat):
|
||||
"""The `bare` recipe has no debug directory, so the PDB pattern has nothing to match."""
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", hexpat(b"fixture.pdb"),
|
||||
"--across", fixtures.bare.path, "--json")
|
||||
assert result.returncode == 1
|
||||
payload = json.loads(result.stdout)
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["hits"] == 1
|
||||
assert entry["across"]["hits"] == 0
|
||||
assert entry["across"]["same_count"] is False
|
||||
assert entry["across"]["verdict"] == "no-match"
|
||||
assert entry["across"]["sites"] == []
|
||||
assert payload["result"] == "needs-attention"
|
||||
|
||||
|
||||
def test_an_equal_hit_count_is_not_the_same_as_a_usable_locator(run_script, fixtures):
|
||||
"""Two hits on both sides: the count agrees and the pattern is still a coin flip."""
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", fixtures.pe64.plant,
|
||||
"--across", fixtures.bare.path, "--json")
|
||||
assert result.returncode == 1
|
||||
payload = json.loads(result.stdout)
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["hits"] == entry["across"]["hits"] == 2
|
||||
assert entry["across"]["same_count"] is True
|
||||
assert entry["verdict"] == "ambiguous"
|
||||
assert payload["result"] == "needs-attention"
|
||||
|
||||
|
||||
def test_the_text_view_explains_a_changed_count(run_script, fixtures, hexpat):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", hexpat(b"fixture.pdb"),
|
||||
"--across", fixtures.bare.path)
|
||||
assert result.returncode == 1
|
||||
assert "hit count changed between builds" in result.stdout
|
||||
assert "across" in result.stdout
|
||||
|
||||
|
||||
def test_the_text_view_explains_an_absent_pattern(run_script, fixtures, hexpat):
|
||||
result = run_script("aob_scan.py", fixtures.bare.path, "--patterns", hexpat(b"fixture.pdb"))
|
||||
assert result.returncode == 1
|
||||
assert "does not exist in this build" in result.stdout
|
||||
|
||||
|
||||
def test_a_patch_invalidates_the_signature_it_was_located_with(run_script, fixtures, tmp_path,
|
||||
hexpat):
|
||||
"""The complete lesson of the two tools together, in one flow.
|
||||
|
||||
The pattern that located the site includes the byte that gets patched, so after the patch the
|
||||
pattern no longer matches that site. `--across` between the original and the patched copy is
|
||||
what turns that from a silent hazard into a reported count change.
|
||||
"""
|
||||
patched = tmp_path / "patched.exe"
|
||||
shutil.copyfile(str(fixtures.pe64.path), str(patched))
|
||||
applied = run_script("pepatch.py", patched, "--pattern", fixtures.pe64.plant,
|
||||
"--at-hit", "0", "--expect", fixtures.pe64.plant,
|
||||
"--bytes", " ".join(fixtures.pe64.plant.split()[:-2] + ["EB", "05"]),
|
||||
"--out", patched)
|
||||
assert applied.returncode == 0, applied.stderr
|
||||
|
||||
located_first = run_script("aob_scan.py", patched, "--patterns", fixtures.pe64.plant, "--json")
|
||||
assert located_first.returncode == 0
|
||||
after = json.loads(located_first.stdout)["patterns"][0]
|
||||
assert after["hits"] == 1, "the patched site no longer matches the pattern it was found with"
|
||||
assert after["verdict"] == "unique"
|
||||
assert after["sites"][0]["offset_hex"] == fixtures.pe64.meta["plant"]["second_file_offset"]
|
||||
|
||||
# The hazard, made concrete: the same pattern with the same index now selects a different
|
||||
# site than it did before the patch, without any error from either tool.
|
||||
dry = run_script("pepatch.py", patched, "--pattern", fixtures.pe64.plant, "--at-hit", "0",
|
||||
"--expect", fixtures.pe64.plant,
|
||||
"--bytes", " ".join(fixtures.pe64.plant.split()[:-2] + ["EB", "05"]),
|
||||
"--dry-run", "--json")
|
||||
assert dry.returncode == 0
|
||||
assert json.loads(dry.stdout)["applied"][0]["offset"] == \
|
||||
int(fixtures.pe64.meta["plant"]["second_file_offset"], 16)
|
||||
|
||||
# Unpatched copy on the left, patched copy on the right: the counts disagree, and that is
|
||||
# reported rather than smoothed over.
|
||||
comparison = run_script("aob_scan.py", fixtures.pe64.path, "--patterns",
|
||||
fixtures.pe64.plant, "--across", patched, "--json")
|
||||
assert comparison.returncode == 1
|
||||
entry = json.loads(comparison.stdout)["patterns"][0]
|
||||
assert entry["hits"] == 2
|
||||
assert entry["across"]["hits"] == 1
|
||||
assert entry["across"]["same_count"] is False
|
||||
|
||||
|
||||
def test_comparing_against_a_non_pe_build_exits_three(run_script, fixtures, tmp_path):
|
||||
other = tmp_path / "not-a-pe.txt"
|
||||
other.write_text("this is not a portable executable\n", encoding="utf-8")
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", fixtures.pe64.plant,
|
||||
"--across", other)
|
||||
assert result.returncode == 3
|
||||
assert "--across" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_comparing_against_a_missing_build_exits_three(run_script, fixtures, tmp_path):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", fixtures.pe64.plant,
|
||||
"--across", tmp_path / "absent.exe")
|
||||
assert result.returncode == 3
|
||||
assert "Traceback" not in result.stderr
|
||||
@@ -0,0 +1,220 @@
|
||||
"""End-to-end flows over the generated fixture: locate, patch, re-read, verify.
|
||||
|
||||
No debugger, no driver, no network and no live target takes part. Every step is a script call,
|
||||
and the assertions are on what the next script in the chain observes -- which is the property
|
||||
that matters, because these tools are meant to agree with each other about one image.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def sha256(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def work(tmp_path, fixtures):
|
||||
"""An isolated copy of the PE32+ fixture, so a flow can never disturb another test."""
|
||||
target = tmp_path / "sample.exe"
|
||||
shutil.copyfile(str(fixtures.pe64.path), str(target))
|
||||
return target
|
||||
|
||||
|
||||
def test_locate_patch_reread_round_trip(run_script, fixtures, work, tmp_path, hexpat):
|
||||
"""The full chain, with each tool checked against what the next one reports."""
|
||||
patched = tmp_path / "patched.exe"
|
||||
first, second = fixtures.pe64.plant_offsets
|
||||
|
||||
# 1. locate: the planted sequence is deliberately ambiguous, and the locator says so.
|
||||
located = run_script("aob_scan.py", work, "--patterns", fixtures.pe64.plant, "--json")
|
||||
assert located.returncode == 1
|
||||
plan = json.loads(located.stdout)
|
||||
assert plan["patterns"][0]["verdict"] == "ambiguous"
|
||||
site = plan["patterns"][0]["sites"][0]
|
||||
|
||||
# 2. patch the first site only, keeping the replacement the same length as the site.
|
||||
replacement = " ".join(fixtures.pe64.plant.split()[:-2] + ["EB", "05"])
|
||||
applied = run_script("pepatch.py", work, "--pattern", fixtures.pe64.plant,
|
||||
"--at-hit", "0", "--expect", fixtures.pe64.plant,
|
||||
"--bytes", replacement, "--out", patched, "--json")
|
||||
assert applied.returncode == 0, applied.stderr
|
||||
result = json.loads(applied.stdout)
|
||||
claim = result["applied"][0]
|
||||
assert result["patches_applied"] == 1
|
||||
assert result["verify_problems"] == []
|
||||
|
||||
# 3. the locator and the patcher must be talking about the same bytes.
|
||||
assert int(site["offset_hex"], 16) == claim["offset"] == first
|
||||
assert int(site["rva_hex"], 16) == int(claim["rva"], 16)
|
||||
assert claim["before"] == fixtures.pe64.plant
|
||||
assert claim["after"] == replacement
|
||||
assert claim["equal_length"] is True
|
||||
|
||||
# 4. re-read the written file: the patch landed, the control site did not move, and the
|
||||
# checksum was recomputed rather than left stale.
|
||||
reread = run_script("peutil.py", patched, "--summary", "--check-sum", "--json")
|
||||
assert reread.returncode == 0, reread.stderr
|
||||
report = json.loads(reread.stdout)
|
||||
assert report["summary"]["checksum_matches"] is True
|
||||
assert report["summary"]["checksum_declared"] == report["summary"]["checksum_computed"]
|
||||
assert report["checksum"]["matches"] is True
|
||||
assert report["summary"]["file_size"] == fixtures.pe64.size
|
||||
body = patched.read_bytes()
|
||||
assert body[first:first + 12] == bytes(int(b, 16) for b in replacement.split())
|
||||
assert body[second:second + 12] == fixtures.pe64.data[second:second + 12]
|
||||
|
||||
# 5. nothing outside the site and the checksum field moved.
|
||||
checksum_field = int(fixtures.pe64.meta["checksum_offset"], 16)
|
||||
diff = {i for i, (a, b) in enumerate(zip(fixtures.pe64.data, body)) if a != b}
|
||||
assert diff - {first + 10} <= set(range(checksum_field, checksum_field + 4))
|
||||
assert first + 10 in diff
|
||||
|
||||
|
||||
def test_control_run_that_changes_nothing_leaves_the_file_identical(run_script, fixtures, work,
|
||||
tmp_path):
|
||||
"""The control for the flow above: a patch whose bytes equal the site's own bytes.
|
||||
|
||||
This is the only zero-change run the pipeline has: a spec with an empty patch array is
|
||||
refused as a usage error, so a no-op patch is what "the pipeline ran and changed nothing"
|
||||
looks like. The output must be byte-identical to the input, checksum included.
|
||||
"""
|
||||
out = tmp_path / "noop.exe"
|
||||
before = sha256(work)
|
||||
result = run_script("pepatch.py", work, "--at-offset", hex(fixtures.pe64.plant_offsets[0]),
|
||||
"--expect", fixtures.pe64.plant, "--bytes", fixtures.pe64.plant,
|
||||
"--out", out, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["checksum_recomputed"] is True
|
||||
assert payload["checksum_after"]["matches"] is True
|
||||
assert payload["output_sha256"] == payload["input_sha256"] == before
|
||||
assert sha256(out) == before
|
||||
assert sha256(work) == before
|
||||
|
||||
|
||||
def test_an_empty_patch_array_is_refused_rather_than_writing_a_copy(run_script, fixtures, work,
|
||||
tmp_path):
|
||||
spec = tmp_path / "empty.json"
|
||||
spec.write_text('{"patches": []}', encoding="utf-8")
|
||||
out = tmp_path / "copy.exe"
|
||||
result = run_script("pepatch.py", work, "--spec", spec, "--out", out)
|
||||
assert result.returncode == 2
|
||||
assert not out.exists()
|
||||
assert sha256(work) == sha256(fixtures.pe64.path)
|
||||
|
||||
|
||||
def test_the_flow_stops_before_writing_when_the_pattern_is_ambiguous(run_script, fixtures, work,
|
||||
tmp_path):
|
||||
out = tmp_path / "never.exe"
|
||||
before = sha256(work)
|
||||
result = run_script("pepatch.py", work, "--pattern", fixtures.pe64.plant,
|
||||
"--bytes", "90 " * 12, "--out", out)
|
||||
assert result.returncode == 1
|
||||
assert "matched 2 sites" in result.stderr
|
||||
assert not out.exists()
|
||||
assert sha256(work) == before
|
||||
|
||||
|
||||
def test_dry_run_reports_the_plan_and_writes_nothing(run_script, fixtures, work, tmp_path):
|
||||
out = tmp_path / "would-be.exe"
|
||||
before = sha256(work)
|
||||
result = run_script("pepatch.py", work, "--pattern", fixtures.pe64.plant, "--at-hit", "1",
|
||||
"--expect", fixtures.pe64.plant, "--bytes", "CC " * 12,
|
||||
"--dry-run", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["patches_applied"] == 1
|
||||
assert payload["applied"][0]["offset"] == fixtures.pe64.plant_offsets[1]
|
||||
assert not out.exists()
|
||||
assert sha256(work) == before
|
||||
|
||||
|
||||
def test_a_spec_file_can_reach_a_branch_inside_a_longer_pattern(run_script, fixtures, work,
|
||||
tmp_path):
|
||||
"""The pattern-offset flow: anchor on a unique longer sequence, then patch the byte inside."""
|
||||
out = tmp_path / "branch.exe"
|
||||
site = fixtures.pe64.plant_offsets[0]
|
||||
spec = tmp_path / "spec.json"
|
||||
spec.write_text(json.dumps({"patches": [{
|
||||
"name": "gate",
|
||||
"pattern": fixtures.pe64.plant + " CC",
|
||||
"pattern-offset": 10,
|
||||
"expect": "74",
|
||||
"bytes": "EB",
|
||||
"why": "invert the conditional branch, keeping the taken distance",
|
||||
}]}), encoding="utf-8")
|
||||
result = run_script("pepatch.py", work, "--spec", spec, "--out", out, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["patches_applied"] == 1
|
||||
record = payload["applied"][0]
|
||||
assert record["offset"] == site + 10
|
||||
assert record["before"] == "74"
|
||||
assert record["after"] == "EB"
|
||||
assert record["length_basis"] == "expect"
|
||||
assert record["why"].startswith("invert")
|
||||
|
||||
reread = run_script("peutil.py", out, "--summary", "--check-sum", "--json")
|
||||
assert reread.returncode == 0
|
||||
assert json.loads(reread.stdout)["checksum"]["matches"] is True
|
||||
assert out.read_bytes()[site + 10] == 0xEB
|
||||
assert out.read_bytes()[site + 11] == 0x05
|
||||
|
||||
|
||||
def test_a_length_change_is_refused_before_anything_is_written(run_script, fixtures, work,
|
||||
tmp_path):
|
||||
out = tmp_path / "resized.exe"
|
||||
result = run_script("pepatch.py", work, "--at-offset", hex(fixtures.pe64.plant_offsets[0]),
|
||||
"--expect", "48", "--bytes", "CC CC CC", "--out", out)
|
||||
assert result.returncode == 1
|
||||
assert "--allow-resize" in result.stderr
|
||||
assert not out.exists()
|
||||
|
||||
allowed = tmp_path / "resized-allowed.exe"
|
||||
forced = run_script("pepatch.py", work, "--at-offset", hex(fixtures.pe64.plant_offsets[0]),
|
||||
"--expect", "48", "--bytes", "CC CC CC", "--allow-resize",
|
||||
"--out", allowed, "--json")
|
||||
assert forced.returncode == 0, forced.stderr
|
||||
payload = json.loads(forced.stdout)
|
||||
assert payload["structural"] == ["cli"]
|
||||
# One byte replaced by three: the file grows by two, and that is the structural edit the
|
||||
# caller asked for by passing --allow-resize.
|
||||
assert payload["output_size"] == payload["input_size"] + 2
|
||||
|
||||
|
||||
def test_the_patched_output_still_parses_as_an_image(run_script, fixtures, work, tmp_path,
|
||||
hexpat):
|
||||
"""A patch that lands in the file must not damage a single header the reader trusts."""
|
||||
out = tmp_path / "patched.exe"
|
||||
result = run_script("pepatch.py", work, "--at-offset", "0x410", "--expect", "48 8B",
|
||||
"--bytes", "CC 8B", "--out", out)
|
||||
assert result.returncode == 0, result.stderr
|
||||
full = run_script("peutil.py", out, "--summary", "--sections", "--imports", "--exports",
|
||||
"--resources", "--tls", "--debug", "--relocs", "--check-sum", "--json")
|
||||
assert full.returncode == 0, full.stderr
|
||||
report = json.loads(full.stdout)
|
||||
assert report["summary"]["sha256"] == sha256(out)
|
||||
assert report["checksum"]["matches"] is True
|
||||
assert [s["name"] for s in report["summary"]["sections"]] == \
|
||||
[".text", ".rdata", ".data", ".rsrc", ".reloc"]
|
||||
assert report["resources"][0]["path"] == ["VERSION", "1", "1033"]
|
||||
assert report["tls"]["callback_rvas"] == ["0x1000"]
|
||||
assert report["debug"][0]["format"] == "RSDS"
|
||||
assert report["imports"][0]["functions"][0]["name"] == "ImportedFunction"
|
||||
|
||||
|
||||
def test_a_real_platform_image_reproduces_its_declared_checksum(run_script, platform_binary):
|
||||
"""The fixture is synthetic; this is the same claim measured on a real system image."""
|
||||
result = run_script("peutil.py", platform_binary, "--summary", "--check-sum", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
report = json.loads(result.stdout)
|
||||
assert report["summary"]["checksum_matches"] is True
|
||||
assert report["checksum"]["declared"] == report["checksum"]["computed"]
|
||||
assert report["summary"]["file_size"] == platform_binary.stat().st_size
|
||||
assert report["summary"]["sections"], "a platform image must have a section table"
|
||||
Reference in New Issue
Block a user