mirror of
https://codeberg.org/reverse-skill-lab/exe-reverse
synced 2026-11-15 20:04: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.
695 lines
28 KiB
Python
695 lines
28 KiB
Python
"""Shared fixtures for the exe-reverse regression suite.
|
|
|
|
What this file guarantees:
|
|
|
|
* every input is *built*, never committed and never downloaded -- the synthetic PE images come
|
|
from ``skills/exe-reverse/scripts/make_fixture_pe.py`` at test time, into a pytest temporary
|
|
directory that disappears with the session;
|
|
* the scripts under test are loaded by path, because ``skills/exe-reverse/scripts/`` is a flat
|
|
directory of runnable files, not an importable package;
|
|
* a missing prerequisite -- an older interpreter, an absent local tool -- is a *skip with a
|
|
printed reason*, never a failure. A wrong result is always a failure.
|
|
|
|
Nothing here touches the network, a debugger, a device or a kernel component.
|
|
"""
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
TESTS_DIR = pathlib.Path(__file__).resolve().parent
|
|
REPO_ROOT = TESTS_DIR.parent
|
|
SCRIPTS_DIR = REPO_ROOT / "skills" / "exe-reverse" / "scripts"
|
|
|
|
# The scripts declare "Python 3.9+" in their own docstrings.
|
|
MIN_PYTHON = (3, 9)
|
|
|
|
# Every script this suite may load by name or run as a subprocess. The last five were added with
|
|
# their own layers of the suite: the capability registry and the environment doctor answer questions
|
|
# about this host, the Dart snapshot reader takes an ELF rather than a PE (so its input is generated
|
|
# here by `build_synthetic_elf` instead of by `make_fixture_pe.py`), the folder summarises records
|
|
# too large to read (its tests assert on the bounds of its output rather than on fixtures of any
|
|
# particular size), and the reducer's proof path drives an external solver, so its solver-facing
|
|
# tests run against a stub and the pure parts against plain text.
|
|
SCRIPT_FILES = (
|
|
"peutil.py",
|
|
"pepatch.py",
|
|
"hwprobe.py",
|
|
"aob_scan.py",
|
|
"make_fixture_pe.py",
|
|
"scan_leaks.py",
|
|
"capabilities.py",
|
|
"doctor.py",
|
|
"dart_snapshot_strings.py",
|
|
"fold.py",
|
|
"trace_reduce.py",
|
|
# The two route helpers: the first script a task should run on an unknown image, and the
|
|
# two-build comparison. Added with tests/cli/test_cli_coverage.py, which fails if a shipped
|
|
# script is neither in this tuple nor declared uncovered with a reason.
|
|
"pe_triage.py",
|
|
"pe_diff.py",
|
|
# The import-layer repair: it pairs a rewritten relative call with the slot that holds its
|
|
# target, and refuses the three cases a byte-swapping tool would corrupt. Its scenario is
|
|
# generated below, because the decisions it makes are about byte layout, not about any sample.
|
|
"iat_callsite_restore.py",
|
|
)
|
|
|
|
# A hang detector, not a performance assertion. Nothing in this suite is meant to be slow.
|
|
RUN_TIMEOUT = 180
|
|
|
|
# Suite name -> recipe name in make_fixture_pe.py, so recipes are referenced rather than
|
|
# duplicated. `default` is PE32+ with every structure; `pe32` is the 32-bit layout; `bare`
|
|
# drops exports/resources/TLS/debug; `fatalign` uses a 4 KiB file alignment, which is large
|
|
# enough that .rdata has no virtual-only tail at all.
|
|
#
|
|
# The `no-virtual-tail` recipe is deliberately NOT used: with `rdata_virtual_pad = 0` its own
|
|
# guard (`rdata_payload_len > rdata_virtual_pad`) rejects the layout it names, so the recipe
|
|
# cannot be built and exits 1. `fatalign` covers the same property. Recorded in benchmark.md.
|
|
FIXTURE_RECIPES = {
|
|
"pe64": "default",
|
|
"pe32": "pe32",
|
|
"bare": "bare",
|
|
"fatalign": "fat-align",
|
|
}
|
|
|
|
# --- synthetic leak corpus ---------------------------------------------------
|
|
# One line per rule in scan_leaks.py. Each line is shaped like the thing its rule targets, and
|
|
# (except for machine-guid, which is a measured observation -- see the unit tests) is not
|
|
# suppressed by the exemption list.
|
|
#
|
|
# Every sample is assembled from fragments at import time rather than quoted whole. The suite
|
|
# deliberately contains strings shaped like a pasted key, a token and a real profile path; if
|
|
# they were written whole, the repository's own pre-publish scan would report this file the
|
|
# moment it walked the tracked surface. Splitting them keeps the runtime value matched by the
|
|
# rule while the source text matches nothing. A scanner's own fixtures have to be written this
|
|
# way, and that is a property of the scan surface, not a trick.
|
|
def parts(*chunks):
|
|
"""Join string fragments at run time (see the note above)."""
|
|
return "".join(chunks)
|
|
|
|
|
|
LEAK_LINES = (
|
|
("private-key-block", parts("-----BEGIN RSA ", "PRIVATE KEY-----")),
|
|
("github-token", parts("ghp_", "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8")),
|
|
("aws-access-key", parts("AKIA", "Q7ZK3MNP2RVW8XTD")),
|
|
("slack-token", parts("xoxb-", "1234567890abcdef")),
|
|
("generic-secret-assignment", parts('api_key = "', "QwErTyUiOpAsDfGhJkLzXcVb", '"')),
|
|
("licence-blob-literal", parts('license_key: "', "ABCDEFGH1234", '"')),
|
|
("windows-user-profile", parts("C:\\Users\\", "mkorvin", "\\notes.md")),
|
|
("posix-home", parts("/home/", "mkorvin", "/notes.md")),
|
|
("machine-guid", parts("MachineGuid = ", "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071")),
|
|
("windows-sid-literal", parts("S-1-5-21-", "1004336348-1177238915-682003330-", "1001")),
|
|
("volume-serial-literal", parts("Volume Serial Number: ", "1A2B-3C4D")),
|
|
("literal-endpoint-with-creds", parts("ftp://", "deploy:Sup3rSecret@", "45.77.", "10.199/pub")),
|
|
("public-ipv4-literal", parts("45.77.", "10.199")),
|
|
("literal-host-with-path", parts("https://licensing.", "hexweld.", "org/v2/activate")),
|
|
("product-name-with-version", parts("Hexweld Editor ", "4.2.1")),
|
|
("exe-name-with-version", parts("build: hexweld", ".exe v4.2")),
|
|
("email-address", parts("k.morozov@", "hexweld.org")),
|
|
("vendor-copyright-line", parts("Copyright (c) 2024 ", "Hexweld Systems")),
|
|
)
|
|
|
|
# Lines that must never be reported: tool names, protector names, format vocabulary, platform
|
|
# terms, placeholder tokens, reserved documentation addresses, well-known SIDs.
|
|
EXEMPT_LINES = (
|
|
("tool name", "IDA Pro 9.0.2 opened the image"),
|
|
("protector name", "the UPX 4.2 stub wraps a Themida section"),
|
|
("format term", "RVA versus file offset; the PDB is RSDS CodeView"),
|
|
("platform term", "Microsoft Windows 11.0 build 26100 with PowerShell"),
|
|
("placeholder token", "TARGET: APP_URL and SAMPLE with PLACEHOLDER values"),
|
|
("documentation address", "203.0.113.9 and 198.51.100.7 and 192.0.2.44"),
|
|
("well-known SID", "S-1-5-18 and S-1-5-32-544 are not machine identity"),
|
|
("loopback and example", "127.0.0.1 and localhost and example.com"),
|
|
("machine guid context", parts("MachineGuid = ", "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071")),
|
|
("net tld", parts("https://licensing.", "hexweld.", "net/v2/activate")),
|
|
)
|
|
|
|
# Measured deviations from the exemption list, pinned as observations rather than wishes. Each
|
|
# is asserted by a unit test so a change to either list is visible instead of silent:
|
|
# * the bare-GUID exemption matches inside the machine-guid rule's own match text, so that
|
|
# rule cannot produce a finding;
|
|
# * the `.NET` entry matches any `.net` TLD, so a hit inside such a URL or address is
|
|
# suppressed as a platform term;
|
|
# * the `<[a-z_]+>` placeholder alternative is surrounded by `\b` on both sides, so it cannot
|
|
# match bracketed prose; only word-character adjacency reaches it.
|
|
#
|
|
# A fourth deviation was measured and then corrected while this suite was being written: for one
|
|
# revision of the exemption list, a standard-name alternative matched the `HTTP` inside every
|
|
# `https?://` URL and swallowed every literal-host-with-path hit. benchmark.md records both
|
|
# states; the suite asserts the corrected one.
|
|
SUPPRESSED_RULES = ("machine-guid",)
|
|
|
|
# Lines that must never be reported: tool names, protector names, format vocabulary, platform
|
|
# terms, placeholder tokens, reserved documentation addresses, well-known SIDs.
|
|
EXEMPT_LINES = (
|
|
("tool name", "IDA Pro 9.0.2 opened the image"),
|
|
("protector name", "the UPX 4.2 stub wraps a Themida section"),
|
|
("format term", "RVA versus file offset; the PDB is RSDS CodeView"),
|
|
("platform term", "Microsoft Windows 11.0 build 26100 with PowerShell"),
|
|
("placeholder token", "TARGET: APP_URL and SAMPLE with PLACEHOLDER values"),
|
|
("documentation address", "203.0.113.9 and 198.51.100.7 and 192.0.2.44"),
|
|
("well-known SID", "S-1-5-18 and S-1-5-32-544 are not machine identity"),
|
|
("loopback and example", "127.0.0.1 and localhost and example.com"),
|
|
("machine guid context", "MachineGuid = 3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071"),
|
|
("net tld", "https://licensing.hexweld.net/v2/activate"),
|
|
)
|
|
|
|
# The one rule whose hits are suppressed by the exemption list. Measured, not assumed: the
|
|
# bare-GUID exemption matches inside the machine-guid rule's own match text. Pinned by a unit
|
|
# test so a change in either list is visible instead of silent.
|
|
SUPPRESSED_RULES = ("machine-guid",)
|
|
|
|
|
|
# --- synthetic ELF64 for dart_snapshot_strings.py -----------------------------
|
|
# That script's input is a Dart AOT snapshot -- an ELF shared object named `app.so` -- and this
|
|
# repository may never carry a sample, not even a small one. So the suite builds the container
|
|
# instead: a valid ELF64 header, a `.text` of a handful of bytes, a `.rodata` holding one one-byte
|
|
# (ASCII) literal and one two-byte (UTF-16LE) literal at known offsets, and a section-name string
|
|
# table. A few hundred bytes, written at test time into the session temporary directory.
|
|
#
|
|
# What this image is *not*: a real snapshot. It exists so the reader can be held against a container
|
|
# whose offsets are known in advance, which is what proves the literal-recovery logic. The
|
|
# instruction-level counts an earlier version of the reader reported were withdrawn -- a byte scan
|
|
# cannot read this format, and the reference records why -- so nothing here stands in for them.
|
|
|
|
# Neither `49 8B /r` (a pool load through r15) nor `4X 8B|8D 05` (a RIP-relative load) appears in
|
|
# these bytes, which is what makes the zero counts below a measurement rather than an artifact.
|
|
ELF_TEXT_BYTES = bytes((0x90, 0x90, 0xC3, 0xCC, 0x55, 0x48, 0x89, 0xE5,
|
|
0x5D, 0xC3, 0x0F, 0x1F, 0x44, 0x00, 0x00, 0x90))
|
|
ELF_TEXT_VADDR = 0x1000
|
|
# Large enough that a virtual address is never mistaken for a file offset: the reader treats an
|
|
# `--at` value above the file size as a vaddr, and this image is under 1 KiB.
|
|
ELF_RODATA_VADDR = 0x400000
|
|
|
|
# (offset within .rodata, vaddr offset, text, encoding). The ASCII run at 0x00 and the UTF-16LE run
|
|
# at 0x1C are the two shapes the reader documents; the NUL padding between them keeps the two-byte
|
|
# run on the phase the scan walks, because that scan advances from the section base in steps of two.
|
|
# That phase dependency is a defect in the reader rather than a property of a snapshot, and it is
|
|
# pinned in unit/test_dart_snapshot_strings.py; the fixture places its literals so the recovery path
|
|
# can be measured at all.
|
|
ELF_LITERALS = (
|
|
(0x00, "premium_license", "one-byte"),
|
|
(0x10, "trial_mode", "one-byte"),
|
|
(0x1C, "vip_pass", "two-byte"),
|
|
)
|
|
|
|
|
|
def _elf_section_header(name_off, typ, flags, addr, offset, size, align=8, entsize=0,
|
|
link=0, info=0):
|
|
"""One 64-byte ELF64 section header, in the order the reader unpacks it."""
|
|
return struct.pack("<IIQQQQIIQQ", name_off, typ, flags, addr, offset, size,
|
|
link, info, align, entsize)
|
|
|
|
|
|
def build_synthetic_elf(with_text=True):
|
|
"""(data, meta) for a minimal ELF64 whose `.rodata` holds three known literals.
|
|
|
|
The layout is derived here rather than asserted from a table, so a change to any literal's size
|
|
moves the offsets with it and the tests still compare against where the fixture *did* put them.
|
|
"""
|
|
text = ELF_TEXT_BYTES
|
|
rodata = b"premium_license\x00" + b"trial_mode\x00\x00"
|
|
rodata += "vip_pass".encode("utf-16-le") + b"\x00\x00"
|
|
rodata += b"\x00" * ((8 - len(rodata) % 8) % 8)
|
|
shstr = b"\x00.text\x00.rodata\x00.shstrtab\x00"
|
|
|
|
ehdr_size = 64
|
|
text_off = 0x40
|
|
rodata_off = text_off + (len(text) if with_text else 0)
|
|
shstr_off = rodata_off + len(rodata)
|
|
shoff = shstr_off + len(shstr)
|
|
shoff += (8 - shoff % 8) % 8
|
|
|
|
sections = [
|
|
# The NULL section every image starts with; the reader does not special-case it.
|
|
_elf_section_header(0, 0, 0, 0, 0, 0, align=0),
|
|
]
|
|
if with_text:
|
|
sections.append(_elf_section_header(1, 1, 0x6, ELF_TEXT_VADDR, text_off,
|
|
len(text)))
|
|
sections.append(_elf_section_header(7, 1, 0x2, ELF_RODATA_VADDR, rodata_off,
|
|
len(rodata)))
|
|
sections.append(_elf_section_header(15, 3, 0, 0, shstr_off, len(shstr), align=1))
|
|
shnum = len(sections)
|
|
|
|
ident = b"\x7fELF" + bytes((2, 1, 1, 0)) + b"\x00" * 8
|
|
ehdr = ident + struct.pack("<HHIQQQIHHHHHH",
|
|
3, # e_type = ET_DYN, a shared object
|
|
0x3E, # e_machine = EM_X86_64
|
|
1, # e_version
|
|
0, # e_entry
|
|
0, # e_phoff: no program headers
|
|
shoff, # e_shoff
|
|
0, # e_flags
|
|
ehdr_size, # e_ehsize
|
|
0, # e_phentsize
|
|
0, # e_phnum
|
|
64, # e_shentsize
|
|
shnum, # e_shnum
|
|
shnum - 1) # e_shstrndx: the last section is .shstrtab
|
|
|
|
data = bytearray(b"\x00" * shoff)
|
|
data[0:ehdr_size] = ehdr
|
|
if with_text:
|
|
data[text_off:text_off + len(text)] = text
|
|
data[rodata_off:rodata_off + len(rodata)] = rodata
|
|
data[shstr_off:shstr_off + len(shstr)] = shstr
|
|
data.extend(b"".join(sections))
|
|
|
|
meta = {
|
|
"size": len(data),
|
|
"text": {"offset": text_off, "vaddr": ELF_TEXT_VADDR, "size": len(text)},
|
|
"rodata": {"offset": rodata_off, "vaddr": ELF_RODATA_VADDR, "size": len(rodata)},
|
|
"literals": [
|
|
{"offset": rodata_off + rel, "vaddr": ELF_RODATA_VADDR + rel,
|
|
"text": value, "encoding": encoding}
|
|
for rel, value, encoding in ELF_LITERALS
|
|
],
|
|
}
|
|
return bytes(data), meta
|
|
|
|
|
|
class SyntheticElf(object):
|
|
"""A generated ELF image plus the offsets its literals were placed at."""
|
|
|
|
__slots__ = ("path", "data", "meta")
|
|
|
|
def __init__(self, path, data, meta):
|
|
self.path = path
|
|
self.data = data
|
|
self.meta = meta
|
|
|
|
@property
|
|
def size(self):
|
|
return len(self.data)
|
|
|
|
@property
|
|
def text(self):
|
|
return self.meta["text"]
|
|
|
|
@property
|
|
def rodata(self):
|
|
return self.meta["rodata"]
|
|
|
|
@property
|
|
def literals(self):
|
|
return self.meta["literals"]
|
|
|
|
def literal(self, value):
|
|
"""The fixture record for one literal, by its text."""
|
|
for entry in self.literals:
|
|
if entry["text"] == value:
|
|
return entry
|
|
raise KeyError("%r is not one of the fixture's literals: %r"
|
|
% (value, [e["text"] for e in self.literals]))
|
|
|
|
def __repr__(self):
|
|
return "SyntheticElf(%s, %d bytes)" % (self.path, self.size)
|
|
|
|
|
|
def pytest_report_header(config):
|
|
return [
|
|
"exe-reverse suite: python %s, scripts %s" % (sys.version.split()[0], SCRIPTS_DIR),
|
|
]
|
|
|
|
|
|
class ScriptResult(object):
|
|
"""One script run: returncode, stdout, stderr, plus the argv that produced it."""
|
|
|
|
__slots__ = ("returncode", "stdout", "stderr", "argv")
|
|
|
|
def __init__(self, returncode, stdout, stderr, argv):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.argv = argv
|
|
|
|
def __iter__(self):
|
|
return iter((self.returncode, self.stdout, self.stderr))
|
|
|
|
def __repr__(self):
|
|
return "ScriptResult(returncode=%r, argv=%r)" % (self.returncode, self.argv)
|
|
|
|
|
|
class BuiltFixture(object):
|
|
"""A generated fixture image plus the metadata make_fixture_pe.py returned for it."""
|
|
|
|
__slots__ = ("name", "path", "meta", "recipe")
|
|
|
|
def __init__(self, name, path, meta, recipe):
|
|
self.name = name
|
|
self.path = path
|
|
self.meta = meta
|
|
self.recipe = recipe
|
|
|
|
@property
|
|
def data(self):
|
|
return self.path.read_bytes()
|
|
|
|
@property
|
|
def size(self):
|
|
return len(self.data)
|
|
|
|
@property
|
|
def sha256(self):
|
|
return hashlib.sha256(self.data).hexdigest()
|
|
|
|
@property
|
|
def plant(self):
|
|
"""The byte sequence planted twice in .text, as a spaced hex string."""
|
|
return self.meta["plant"]["bytes"]
|
|
|
|
@property
|
|
def plant_offsets(self):
|
|
return (int(self.meta["plant"]["first_file_offset"], 16),
|
|
int(self.meta["plant"]["second_file_offset"], 16))
|
|
|
|
def section(self, name):
|
|
return self.meta["sections"][name]
|
|
|
|
def __repr__(self):
|
|
return "BuiltFixture(%r, %s)" % (self.name, self.path)
|
|
|
|
|
|
class FixtureSet(object):
|
|
"""Attribute and index access to the built fixtures."""
|
|
|
|
def __init__(self, items):
|
|
self._items = dict(items)
|
|
|
|
def __getattr__(self, name):
|
|
try:
|
|
return self._items[name]
|
|
except KeyError:
|
|
raise AttributeError(name)
|
|
|
|
def __getitem__(self, name):
|
|
return self._items[name]
|
|
|
|
def names(self):
|
|
return sorted(self._items)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def interpreter_gate():
|
|
"""Skip everything, with a reason, when the interpreter is older than the scripts require."""
|
|
if sys.version_info < MIN_PYTHON:
|
|
pytest.skip("skills/exe-reverse/scripts declare Python %d.%d+; this session is running "
|
|
"%s -- install a newer interpreter to exercise the suite"
|
|
% (MIN_PYTHON[0], MIN_PYTHON[1], sys.version.split()[0]))
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def require_local_tool():
|
|
"""Skip a check, with a reason, when a tool it needs is not installed."""
|
|
|
|
def _need(tool):
|
|
found = shutil.which(tool)
|
|
if not found:
|
|
pytest.skip("local tool %r is not on PATH; this check is inert without it" % tool)
|
|
return found
|
|
|
|
return _need
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def load_script():
|
|
"""Import a tested script by path, once per session.
|
|
|
|
``pepatch.py`` and ``aob_scan.py`` put their own directory on ``sys.path`` and then import
|
|
``peutil`` by bare name. Registering one shared module per name keeps a single PE class in
|
|
the process instead of two classes that compare unequal.
|
|
"""
|
|
cache = {}
|
|
|
|
def _load(filename):
|
|
if filename not in SCRIPT_FILES:
|
|
raise ValueError("%r is not one of the tested scripts: %r" % (filename, SCRIPT_FILES))
|
|
name = filename[:-3]
|
|
if name in cache:
|
|
return cache[name]
|
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
spec = importlib.util.spec_from_file_location(name, SCRIPTS_DIR / filename)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
cache[name] = module
|
|
return module
|
|
|
|
return _load
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def run_script():
|
|
"""Run a tested script as a subprocess and return (returncode, stdout, stderr)."""
|
|
|
|
def _run(filename, *args, **kwargs):
|
|
if filename not in SCRIPT_FILES:
|
|
raise ValueError("%r is not one of the tested scripts: %r" % (filename, SCRIPT_FILES))
|
|
timeout = kwargs.pop("timeout", RUN_TIMEOUT)
|
|
cwd = kwargs.pop("cwd", None)
|
|
if kwargs:
|
|
raise TypeError("unexpected keyword arguments: %r" % sorted(kwargs))
|
|
argv = [sys.executable, str(SCRIPTS_DIR / filename)] + [str(a) for a in args]
|
|
try:
|
|
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout,
|
|
cwd=cwd, encoding="utf-8", errors="replace")
|
|
except subprocess.TimeoutExpired:
|
|
pytest.fail("%s did not finish within %ds: %r" % (filename, timeout, argv))
|
|
return ScriptResult(proc.returncode, proc.stdout, proc.stderr, argv)
|
|
|
|
return _run
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def fixture_dir(tmp_path_factory):
|
|
return tmp_path_factory.mktemp("exe-reverse-fixtures")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def build_fixture(fixture_dir, load_script, run_script):
|
|
"""Build one named fixture through the real CLI and cross-check it against build_pe()."""
|
|
maker = load_script("make_fixture_pe.py")
|
|
cache = {}
|
|
|
|
def _build(name):
|
|
if name in cache:
|
|
return cache[name]
|
|
if name not in FIXTURE_RECIPES:
|
|
raise ValueError("unknown fixture %r; known: %r" % (name, sorted(FIXTURE_RECIPES)))
|
|
recipe = FIXTURE_RECIPES[name]
|
|
dest = fixture_dir / (name + ".exe")
|
|
result = run_script("make_fixture_pe.py", "--out", dest, "--recipe", recipe)
|
|
if result.returncode != 0:
|
|
pytest.fail("make_fixture_pe.py --recipe %s failed with %d: %s"
|
|
% (recipe, result.returncode, result.stderr))
|
|
data, meta = maker.build_pe(**maker.RECIPES[recipe])
|
|
if dest.read_bytes() != data:
|
|
pytest.fail("make_fixture_pe.py wrote bytes that differ from its own build_pe() "
|
|
"output -- the CLI and the library disagree")
|
|
built = BuiltFixture(name, dest, meta, recipe)
|
|
cache[name] = built
|
|
return built
|
|
|
|
return _build
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def fixtures(build_fixture):
|
|
"""The four generated images, addressed as ``fixtures.pe64`` / ``fixtures['pe32']``."""
|
|
return FixtureSet({name: build_fixture(name) for name in FIXTURE_RECIPES})
|
|
|
|
|
|
class IatScenario(object):
|
|
"""A generated image carrying the six-byte decisions the repair script must make.
|
|
|
|
Five sites: one with the filler byte after the call, one with it before, one with no filler at
|
|
all, one whose target two slots both hold, and the slots themselves in a range the import
|
|
directory does not describe (which is the normal case for a captured image).
|
|
"""
|
|
|
|
__slots__ = ("path", "pe_path", "base", "slot_rva", "dup_slot_rvas", "sites", "bitness")
|
|
|
|
def __init__(self, **kw):
|
|
for key in self.__slots__:
|
|
setattr(self, key, kw.get(key))
|
|
|
|
def __repr__(self):
|
|
return "IatScenario(%s)" % self.path
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def iat_scenario(tmp_path_factory, build_fixture, load_script):
|
|
"""Build the scenario by editing a fixture the rest of the suite already uses.
|
|
|
|
Editing rather than synthesising is deliberate: the script's job is arithmetic over a real
|
|
header and a real section table, and a hand-built header would let a wrong RVA mapping agree
|
|
with itself. The fixture is copied first, so the shared build stays pristine for every other
|
|
test in the session.
|
|
"""
|
|
peutil = load_script("peutil.py")
|
|
root = tmp_path_factory.mktemp("iat-scenario")
|
|
cache = {}
|
|
|
|
def _build(name="pe64"):
|
|
if name in cache:
|
|
return cache[name]
|
|
built = build_fixture(name)
|
|
pe = peutil.PE(built.data)
|
|
base = pe.image_base
|
|
psize = 8 if pe.is_pe32plus else 4
|
|
data = bytearray(built.data)
|
|
|
|
def put(rva, raw):
|
|
off = pe.rva_to_offset(rva)
|
|
assert off is not None, "scenario RVA %#x has no file offset" % rva
|
|
data[off:off + len(raw)] = raw
|
|
|
|
def call(rva, target_va):
|
|
return b"\xE8" + struct.pack("<i", target_va - (base + rva + 5))
|
|
|
|
target = base + 0x9000
|
|
target_dup = base + 0x9100
|
|
put(0x3020, target.to_bytes(psize, "little"))
|
|
put(0x3028, target_dup.to_bytes(psize, "little"))
|
|
put(0x3030, target_dup.to_bytes(psize, "little"))
|
|
# The generated .text is NOP-filled, so every context byte a case depends on is written
|
|
# explicitly. Without that, "NOP before" and "NOP after" hold everywhere and the three
|
|
# layout cases collapse into one.
|
|
put(0x11FF, b"\x00")
|
|
put(0x1200, call(0x1200, target) + b"\x90")
|
|
put(0x120F, b"\x90")
|
|
put(0x1211, call(0x1211, target) + b"\x00")
|
|
put(0x121F, b"\x00")
|
|
put(0x1220, call(0x1220, target) + b"\xC3")
|
|
put(0x122F, b"\x00")
|
|
put(0x1230, call(0x1230, target_dup) + b"\x90")
|
|
|
|
path = root / ("%s-iat.exe" % name)
|
|
path.write_bytes(bytes(data))
|
|
scenario = IatScenario(
|
|
path=path,
|
|
pe_path=built.path,
|
|
base=base,
|
|
slot_rva=0x3020,
|
|
dup_slot_rvas=(0x3028, 0x3030),
|
|
sites={"filler_after": 0x1200, "filler_before": 0x1211,
|
|
"no_filler": 0x1220, "ambiguous": 0x1230},
|
|
bitness=64 if pe.is_pe32plus else 32,
|
|
)
|
|
cache[name] = scenario
|
|
return scenario
|
|
|
|
return _build
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def synthetic_elf(fixture_dir):
|
|
"""The generated ELF64, written once for the whole session."""
|
|
data, meta = build_synthetic_elf()
|
|
path = fixture_dir / "app.so"
|
|
path.write_bytes(data)
|
|
return SyntheticElf(path, data, meta)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def synthetic_elf_without_text(fixture_dir):
|
|
"""The same image with no `.text`, for the reader's "not an AOT code image" path."""
|
|
data, meta = build_synthetic_elf(with_text=False)
|
|
path = fixture_dir / "no-text.so"
|
|
path.write_bytes(data)
|
|
return SyntheticElf(path, data, meta)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def hwprobe_report(run_script):
|
|
"""One host capability report for the whole session.
|
|
|
|
The probe executes a CPUID stub, so re-running it per assertion would buy nothing and cost
|
|
seconds. It is a host fact, not a per-test one.
|
|
"""
|
|
result = run_script("hwprobe.py", "--json")
|
|
if result.returncode != 0:
|
|
pytest.fail("hwprobe.py --json exited %d: %s" % (result.returncode, result.stderr))
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def hexpat():
|
|
"""Render bytes as the spaced hex form the scripts accept."""
|
|
|
|
def _hexpat(obj):
|
|
if isinstance(obj, str):
|
|
return " ".join("%02X" % b for b in obj.encode("latin-1"))
|
|
return " ".join("%02X" % b for b in bytearray(obj))
|
|
|
|
return _hexpat
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def leak_lines():
|
|
return LEAK_LINES
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def exempt_lines():
|
|
return EXEMPT_LINES
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def weak_line(leak_lines):
|
|
"""A sample that produces exactly one weak finding and no strong one."""
|
|
return dict(leak_lines)["vendor-copyright-line"]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def strong_line(leak_lines):
|
|
"""A sample that produces a strong finding."""
|
|
return dict(leak_lines)["windows-user-profile"]
|
|
|
|
|
|
@pytest.fixture
|
|
def synthetic_leak_root(tmp_path):
|
|
"""A scan surface holding one shaped leak per rule, plus exemption lines."""
|
|
root = tmp_path / "leakroot"
|
|
root.mkdir()
|
|
body = ["# synthetic analysis notes"] + [line for _rule, line in LEAK_LINES]
|
|
(root / "notes.md").write_text("\n".join(body) + "\n", encoding="utf-8")
|
|
(root / "exempt.md").write_text("\n".join(line for _cls, line in EXEMPT_LINES) + "\n",
|
|
encoding="utf-8")
|
|
nested = root / "skills" / "exe-reverse" / "references"
|
|
nested.mkdir(parents=True)
|
|
(nested / "deep.md").write_text("no identity here\n", encoding="utf-8")
|
|
return root
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_scan_root(tmp_path):
|
|
"""A scan surface with nothing to report."""
|
|
root = tmp_path / "clean"
|
|
root.mkdir()
|
|
(root / "notes.md").write_text(
|
|
"Static PE analysis of the generated fixture: sections, imports, checksum.\n",
|
|
encoding="utf-8")
|
|
return root
|
|
|
|
|
|
@pytest.fixture
|
|
def platform_binary(require_local_tool):
|
|
"""A real platform image to parse, or a skip when this host has none."""
|
|
root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
for rel in (r"System32\notepad.exe", r"System32\kernel32.dll", r"System32\ntdll.dll"):
|
|
candidate = pathlib.Path(root) / rel
|
|
if candidate.is_file():
|
|
return candidate
|
|
pytest.skip("no platform image under %s (System32 not present); this check is inert here"
|
|
% root)
|