diff --git a/docs/tool-verification/EXTENSION-absorption-themida.md b/docs/tool-verification/EXTENSION-absorption-themida.md index c252379..d06f256 100644 --- a/docs/tool-verification/EXTENSION-absorption-themida.md +++ b/docs/tool-verification/EXTENSION-absorption-themida.md @@ -48,7 +48,10 @@ its own subject in one place can be wrong in another, and the wrong part does no - **`references/environment.md`**: the compatibility-shim fact, because it changes what "it ran once" means and it lives in neither file. - **`scripts/iat_callsite_restore.py`** with a unit and a CLI layer, registered in the capability - registry as `call_site_restore`. + registry as `call_site_restore`. Its table search is the offline counterpart of the unpacker's + IAT-location routine, derived from the relation the rewrite leaves behind — the slot holds the + address the call targets — and not from that routine's code; the search refuses two candidate + tables, where that routine would have taken the run it found first. ## What was deliberately not asserted diff --git a/skills/exe-reverse/references/evidence-summary.md b/skills/exe-reverse/references/evidence-summary.md index fefd9ac..8dd415c 100644 --- a/skills/exe-reverse/references/evidence-summary.md +++ b/skills/exe-reverse/references/evidence-summary.md @@ -43,7 +43,7 @@ repository generates itself; no third-party sample is required to reproduce any | Patch refusal on an unproven length | a patch with no length reference reports `[length unproven: pass expect or original-length]` instead of asserting equal length | `observed` | same command with `--bytes CC` and no `--expect` | | Byte-pattern location | reports the hit count, each site's offset/RVA/section, and a verdict; a pattern that matches twice is reported as `ambiguous` | `observed` | `python skills/exe-reverse/scripts/aob_scan.py --patterns ""` | | Cross-build pattern comparison | `--across` reports each build's hit count and states when the count changed | `observed` | `python skills/exe-reverse/scripts/aob_scan.py --patterns P --across ` | -| Import-layer repair | on a generated image carrying both filler positions, a site with no filler, and a target two slots hold: two sites are paired and rewritten, the other two are refused by name, the input hash is unchanged, and every written site re-decodes to an indirect call through the slot the report names | `observed` (tool behaviour on a fixture; no protected sample was repaired here) | `python -m pytest -q tests/unit/test_iat_callsite_restore.py tests/cli/test_cli_iat_callsite.py` | +| Import-layer repair | on a generated image carrying both filler positions, a site with no filler, and a target two slots hold: two sites are paired and rewritten, the other two are refused by name, the input hash is unchanged, and every written site re-decodes to an indirect call through the slot the report names. With the same slots outside every directory, the search recovers the same two pairs from the call targets; a second copy of the table in another section is refused rather than chosen | `observed` (tool behaviour on a fixture; no protected sample was repaired here) | `python -m pytest -q tests/unit/test_iat_callsite_restore.py tests/cli/test_cli_iat_callsite.py` | | Offline fixture generation | builds a structurally valid PE32 or PE32+ image, deterministic in bytes, with a correct checksum, that the reader parses back | `observed` | `python skills/exe-reverse/scripts/make_fixture_pe.py --out f.exe --json` | | Identity scanning | scans the tree, reports findings with context, prints `RESULT=` and sets an exit code suitable for gating | `observed` | `python skills/exe-reverse/scripts/scan_leaks.py` | | Host capability probing | prints a per-route verdict and reads CPUID through a local stub; bits whose meanings are not verified are printed **raw** rather than decoded | `observed` | `python skills/exe-reverse/scripts/hwprobe.py` | diff --git a/skills/exe-reverse/references/iat-virtualization-and-call-site-rewriting.md b/skills/exe-reverse/references/iat-virtualization-and-call-site-rewriting.md index 36f7e91..d5b4c14 100644 --- a/skills/exe-reverse/references/iat-virtualization-and-call-site-rewriting.md +++ b/skills/exe-reverse/references/iat-virtualization-and-call-site-rewriting.md @@ -81,7 +81,8 @@ were counted over. they survived, the import address table directory's range if it did, and the addresses that a write watch reported. Say which source the inventory came from; they do not always agree. An inventory that came back empty is a finding about this run, not a table that holds no addresses: - it means the range has to be supplied or rebuilt before any pairing is possible. + it means the range has to be supplied or rebuilt before any pairing is possible — or found, which + is the search described below. 3. **Pair a site to a slot by value.** The site's target address equals the slot's value. This is the whole pairing rule, and it is also the mechanism an open-source unpacker's fixup routine implements (`observed` in that source: it reads the target from the relative call, looks it up in @@ -99,6 +100,28 @@ were counted over. report, not noise around it: the first is a call the protector resolved by some other route, the second is an import nothing in this code section uses. +## When there is no table to inventory + +A captured image frequently has none of the three sources: the protector destroyed the directory and +no write watch was taken. The table is still findable, because the rewrite left one relation behind — +**the slot holds the address the call targets**. So the call sites can be used as the search key: + +1. Collect the targets of the relative calls that leave the image. Those are the stubs. +2. Scan the **non-executable** sections for pointer-aligned entries equal to one of those targets. +3. Cluster the matches by proximity. One cluster is the table; the range it spans is the inventory. + +Two rules keep this from becoming a guess. **Two clusters are refused**, and both are printed: a +duplicated table, a copied table and a table split by a section boundary all look like this from the +outside, and choosing one of two runs of matching pointers produces a file that loads and calls the +wrong API for one import. **A single match is reported as weak**, because one pointer that happens to +equal a call target is a coincidence until a second entry agrees with it. + +Two properties of the search are worth knowing before reading its result. It finds nothing when the +slots hold tags rather than stub addresses — that build has no value relation to search — and it finds +nothing when the base is wrong, because every candidate target is then computed in the wrong address +space. Those two failures look identical and need opposite responses, so check the base before +concluding that the table holds handles. + ## Version divergence, and why this is a map rather than a fact One product family, three observable behaviours, from two public write-ups and one unpacker's diff --git a/skills/exe-reverse/references/routing.md b/skills/exe-reverse/references/routing.md index f6ea366..d9b0a70 100644 --- a/skills/exe-reverse/references/routing.md +++ b/skills/exe-reverse/references/routing.md @@ -136,7 +136,7 @@ explicit paths for anything it shells out to. `--help` on each one is the author | `scripts/pe_triage.py` | one-pass triage of an unknown binary: identity, build evidence, protective signals with their measurements, surface, and what to do next | | `scripts/pepatch.py` | equal-length byte patches from a spec or from the command line: read-only input, a new output file, an ambiguity guard, a length-provenance rule, checksum recomputation, and every patched byte re-read from the written file | | `scripts/aob_scan.py` | locate a byte pattern and report its hit count, each site's offset/RVA/section, a verdict (`unique` / `ambiguous` / `pervasive` / `no-match`), and hit-count comparison across two builds | -| `scripts/iat_callsite_restore.py` | pair a relative call a protector left behind with the import slot that holds its target, restore the indirect call, and refuse the three cases a byte swap would corrupt: no sixth byte, two slots holding one address, two windows overlapping. Report-only unless `--out` is given | +| `scripts/iat_callsite_restore.py` | pair a relative call a protector left behind with the import slot that holds its target, and restore the indirect call. Finds the table from the calls themselves when no directory describes it; refuses the cases a byte swap would corrupt (no sixth byte, two slots holding one address, two windows overlapping) and the case a choice would corrupt (two candidate tables). Report-only unless `--out` is given | | `scripts/pe_diff.py` | compare two builds by content hash with the volatile header fields normalised, and report the differing ranges with the RVA each maps to | | `scripts/hwprobe.py` | report which routes **this host** supports: CPUID features read through a local stub, CPUID bits printed raw unless their meaning is verified, OS build and boot flags, privilege, and the tool inventory — with an `available` / `needs-` / `unavailable` verdict per route | | `scripts/capabilities.py` | the capability registry: what each capability needs, how to detect it, which script implements it and which reference documents it | diff --git a/skills/exe-reverse/scripts/capabilities.py b/skills/exe-reverse/scripts/capabilities.py index 24d810e..d128479 100644 --- a/skills/exe-reverse/scripts/capabilities.py +++ b/skills/exe-reverse/scripts/capabilities.py @@ -779,8 +779,9 @@ CAPABILITIES = { 'required': ['python310', 'file:iat_callsite_restore.py', 'artifact:dumped_image'], 'depends_on': ['pe_structure_read'], 'scripts': ['iat_callsite_restore.py'], - 'note': 'Offline pairing by value, a six-byte rewrite with the filler byte decided per ' - 'site, and a refusal for the site whose sixth byte is the next instruction. It ' + 'note': 'Offline pairing by value, a search for the table when no directory describes it, ' + 'a six-byte rewrite with the filler byte decided per site, and a refusal for the ' + 'site whose sixth byte is the next instruction or for two candidate tables. It ' 'repairs an import layer, never a protector: a virtualised region is untouched.', }, 'fixture_generation': { diff --git a/skills/exe-reverse/scripts/iat_callsite_restore.py b/skills/exe-reverse/scripts/iat_callsite_restore.py index 5773258..ec218a9 100644 --- a/skills/exe-reverse/scripts/iat_callsite_restore.py +++ b/skills/exe-reverse/scripts/iat_callsite_restore.py @@ -17,11 +17,16 @@ What it does, in order: space the image was captured at (--base, not the header's preferred base). 3. Pairs a site to a slot when the target address *equals* the slot's value. That is the whole pairing rule. - 4. Decides the sixth byte. The rewrite is five bytes into six, so it fits only where a sixth byte + 4. If the described table explained none of the calls, looks for the table the calls do explain: + the non-executable sections are scanned for pointer-sized values equal to a call target, the + matches are clustered, and a single cluster is used. Two clusters are reported and not used, + because choosing between two runs of matching pointers is how a call ends up going through the + wrong slot. Suppress the whole step with --no-discover, or name the range with --iat. + 5. Decides the sixth byte. The rewrite is five bytes into six, so it fits only where a sixth byte is available: a NOP after the call (rewrite in place), a NOP before it (the window starts one byte earlier), or no filler at all -- in which case the sixth byte belongs to the next instruction and the repair is *structural*. That last case is refused, not forced. - 5. Rewrites, then re-reads the written file and re-decodes every site to confirm it addresses the + 6. Rewrites, then re-reads the written file and re-decodes every site to confirm it addresses the intended slot. What it refuses, and why a refusal is the useful output: @@ -31,7 +36,9 @@ What it refuses, and why a refusal is the useful output: often enough to be mistaken for a success; * a site with no filler byte, because writing there overwrites the first byte of the next instruction -- a length change, which is a structural edit and not a byte swap; - * two candidate windows that overlap, because neither can be written without destroying the other. + * two candidate windows that overlap, because neither can be written without destroying the other; + * two runs of slot values matching different call targets, because a single pick from two is a + coin toss that the file will not reveal. What it does not do: unpack. It assumes you already have an image whose code is decrypted. It does not devirtualise, does not resolve handles by arithmetic, and does not carry a table of known @@ -42,11 +49,13 @@ Usage: python iat_callsite_restore.py dumped.exe # report only, writes nothing python iat_callsite_restore.py dumped.exe --out fixed.exe # rewrite and verify python iat_callsite_restore.py dumped.exe --base 0x2B0000 --iat 0x48D000,0x1000 + python iat_callsite_restore.py dumped.exe --no-discover # control: described slots only python iat_callsite_restore.py dumped.exe --json --full-out report.json Exit codes: 0 = the analysis completed and no site was refused (result no-pairs, needs-trace, -report-only or repaired), 1 = at least one site was refused, a rewrite failed verification, or the -run had no slot values to pair against, 2 = usage error, 3 = the input is not a parseable PE. +report-only or repaired), 1 = at least one site was refused, a rewrite failed verification, the run +had no slot values to pair against, or discovery found two candidate tables, 2 = usage error, +3 = the input is not a parseable PE. """ import argparse import hashlib @@ -59,6 +68,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from peutil import PE, ParseError, DIR_IAT, s32 # noqa: E402 DEFAULT_MAX_SITES = 40 +DEFAULT_DISCOVER_GAP = 0x1000 # one page: the unit the loader protects, so a natural table edge SECTION_EXECUTE = 0x20000000 SECTION_CODE = 0x00000020 @@ -99,6 +109,26 @@ def parse_range(text): return int(body[0], 0), int(body[1], 0) +def slots_from_range(pe, start, length, source, slots): + """Add every pointer-sized entry in [start, start+length) to the inventory. + + Slot tables are pointer-aligned in every build seen here, so an unaligned walk is not offered: + a half-aligned table would mix the low half of one entry with the high half of the next, and + the resulting values would pair nothing while looking like a real inventory. + """ + step = 8 if pe.is_pe32plus else 4 + added = False + for slot_rva in range(start, start + length - step + 1, step): + if slot_rva in slots: + continue + value = read_ptr(pe, slot_rva) + if value is None: + continue + slots[slot_rva] = {'value': value, 'source': source} + added = True + return added + + def slot_inventory(pe, iat_override): """{rva: {'value': int, 'source': str}} plus the list of sources actually used.""" slots = {} @@ -119,33 +149,12 @@ def slot_inventory(pe, iat_override): sources.append('import-descriptor') rva, size = pe.directories.get(DIR_IAT, (0, 0)) - if rva and size: - used = False - step = 8 if pe.is_pe32plus else 4 - for slot_rva in range(rva, rva + size - step + 1, step): - if slot_rva in slots: - continue - value = read_ptr(pe, slot_rva) - if value is None: - continue - slots[slot_rva] = {'value': value, 'source': 'iat-directory'} - used = True - if used: - sources.append('iat-directory') + if rva and size and slots_from_range(pe, rva, size, 'iat-directory', slots): + sources.append('iat-directory') if iat_override: start, length = iat_override - used = False - step = 8 if pe.is_pe32plus else 4 - for slot_rva in range(start, start + length - step + 1, step): - if slot_rva in slots: - continue - value = read_ptr(pe, slot_rva) - if value is None: - continue - slots[slot_rva] = {'value': value, 'source': 'explicit-range'} - used = True - if used: + if slots_from_range(pe, start, length, 'explicit-range', slots): sources.append('explicit-range') return slots, sources @@ -175,14 +184,14 @@ def classify_slots(pe, base, slots): return out -def scan_sites(pe, base, value_index): - """Walk the executable sections and classify every relative call/jmp. +def walk_sites(pe, base): + """Every E8/E9 in an executable section, with the absolute target it computes to. - Returns (pairs, unpaired, refused_candidates, counts). A pair is a site whose target equals a - slot's value; anything else that leaves the image is reported as unpaired rather than repaired. + Split from the pairing because discovery needs the targets *before* any slot exists, which is + the whole point of discovery. Keeping one function that both walked and paired made that + ordering impossible to express. """ - pairs = [] - unpaired = [] + sites = [] counts = {'bytes': 0, 'e8': 0, 'e9': 0, 'examined': 0, 'interior': 0} sections = [] @@ -203,27 +212,101 @@ def scan_sites(pe, base, value_index): continue counts['e8' if op == CALL else 'e9'] += 1 rva = sec.virtual_address + off - site_va = base + rva - target_va = site_va + SITE_REL_LEN + s32(data, off + 1) + target_va = base + rva + SITE_REL_LEN + s32(data, off + 1) counts['examined'] += 1 + sites.append({'site_rva': rva, + 'kind': 'call' if op == CALL else 'jmp', + 'target_va': target_va}) - slot_rvas = value_index.get(target_va) - if slot_rvas: - pairs.append({ - 'site_rva': rva, - 'kind': 'call' if op == CALL else 'jmp', - 'target_va': target_va, - 'slot_rvas': sorted(slot_rvas), - }) - continue + return sites, sections, counts - target_rva = target_va - base - if pe.section_of_rva(target_rva) is None: - unpaired.append({'site_rva': rva, 'target_va': target_va}) - else: - counts['interior'] += 1 - return pairs, unpaired, sections, counts +def pair_sites(pe, base, sites, value_index): + """A pair is a site whose target equals a slot's value; the rest is reported, never repaired.""" + pairs = [] + unpaired = [] + interior = 0 + for site in sites: + slot_rvas = value_index.get(site['target_va']) + if slot_rvas: + pairs.append({ + 'site_rva': site['site_rva'], + 'kind': site['kind'], + 'target_va': site['target_va'], + 'slot_rvas': sorted(slot_rvas), + }) + elif pe.section_of_rva(site['target_va'] - base) is None: + unpaired.append({'site_rva': site['site_rva'], 'target_va': site['target_va']}) + else: + interior += 1 + return pairs, unpaired, interior + + +def invert(slots): + """value -> [slot rva, ...]. A list, because two slots holding one address is a real case.""" + index = {} + for rva, info in slots.items(): + index.setdefault(info['value'], []).append(rva) + return index + + +def discover_iat(pe, base, target_vas, max_gap): + """Find the slot table from the calls: the table holds the values those calls target. + + Why this works at all: the rewrite replaced "call through the slot" with "call the stub", so the + slot still holds the stub's address in the builds where the table survived with stubs in it. No + import data is needed -- the targets are the search key. + + Why it refuses: one cluster of matches is used; two or more are reported and not used, because + choosing one of two runs of matching pointers is the kind of guess that yields a file which + loads and calls the wrong API. A single match is called weak for the same reason: one pointer + that happens to equal a target is a coincidence until something else agrees with it. + + The gap tolerance is one page by default, because a page is the unit the loader protects: a + table split across pages is unusual, and a run of matches that spans pages without a + protection boundary between them is one table. + """ + step = 8 if pe.is_pe32plus else 4 + fmt = '= 2: + status = 'unique' + chosen = {'start': clusters[0][0], 'size': clusters[0][-1] - clusters[0][0] + step} + elif len(clusters) > 1: + status = 'ambiguous' + elif len(clusters) == 1: + status = 'weak' + else: + status = 'none' + + return {'status': status, 'targets': len(want), 'hits': len(hits), 'scanned': scanned, + 'clusters': detail, 'chosen': chosen, 'max_gap': max_gap} def plan_windows(pe, pairs): @@ -326,13 +409,33 @@ def verify_written(blob, pe_out, base, planned): def build_report(args, blob, pe, data_out_note=None): base = args.base if args.base is not None else pe.image_base slots, sources = slot_inventory(pe, args.iat) + described = len(slots) + sites, sections, counts = walk_sites(pe, base) + + pairs, unpaired, interior = pair_sites(pe, base, sites, invert(slots)) + counts['interior'] = interior + + discovery = None + # Discovery runs when the described table did not explain the calls: either there was no + # inventory at all, or it produced no pair while calls still leave the image. An explicit --iat + # suppresses it, because that is the reader asserting the range. + if not args.no_discover and not args.iat and not pairs: + outside = {s['target_va'] for s in sites if pe.section_of_rva(s['target_va'] - base) is None} + if outside: + discovery = discover_iat(pe, base, outside, args.discover_gap) + discovery['trigger'] = 'no-slots' if described == 0 else 'no-pairs' + if discovery['chosen']: + added = slots_from_range(pe, discovery['chosen']['start'], + discovery['chosen']['size'], 'discovered-range', slots) + discovery['used'] = bool(added) + if added: + sources.append('discovered-range') + pairs, unpaired, interior = pair_sites(pe, base, sites, invert(slots)) + counts['interior'] = interior + else: + discovery['used'] = False + classes = classify_slots(pe, base, slots) - - value_index = {} - for rva, info in slots.items(): - value_index.setdefault(info['value'], []).append(rva) - - pairs, unpaired, sections, counts = scan_sites(pe, base, value_index) planned, refused = plan_windows(pe, pairs) used = {item['slot_rva'] for item in planned} @@ -348,6 +451,7 @@ def build_report(args, blob, pe, data_out_note=None): 'base_source': 'explicit --base' if args.base is not None else 'header ImageBase', }, 'iat': {'sources': sources, 'slots': len(slots), 'classes': classes}, + 'discovery': discovery, 'sections_scanned': sections, 'scan': counts, 'pairs': planned, @@ -365,6 +469,9 @@ def build_report(args, blob, pe, data_out_note=None): def decide_result(report, wrote): if report['refused'] or report.get('verification_failures'): return 'incomplete' + discovery = report.get('discovery') or {} + if discovery.get('status') == 'ambiguous': + return 'ambiguous-iat' if report['pairs']: return 'repaired' if wrote else 'report-only' if not report['iat']['slots']: @@ -394,6 +501,19 @@ def print_report(report, max_sites): out.write(' zero %d, below image %d, in image %d, above image %d\n' % (classes['zero'], classes['below_image'], classes['in_image'], classes['above_image'])) + discovery = report.get('discovery') + if discovery: + out.write('discover %s after %s: %d call target(s), %d slot value(s) matched, %d cluster(s)\n' + % (discovery['status'], discovery['trigger'], discovery['targets'], + discovery['hits'], len(discovery['clusters']))) + out.write(' scanned %s\n' + % (', '.join('%s@%#x' % (s['name'], s['rva']) + for s in discovery['scanned']) or 'no data section')) + for cluster in discovery['clusters'][:max_sites]: + out.write(' cluster %#010x..%#010x %d hit(s)%s\n' + % (cluster['start'], cluster['end'], cluster['hits'], + ' <- used' if discovery['chosen'] + and cluster['start'] == discovery['chosen']['start'] else '')) out.write('sections %s\n' % (', '.join('%s@%#x' % (s['name'], s['rva']) for s in report['sections_scanned']) or 'none')) scan = report['scan'] @@ -421,7 +541,12 @@ def print_report(report, max_sites): if len(report['unpaired']) > max_sites: out.write(' ... %d more\n' % (len(report['unpaired']) - max_sites)) - if report['result'] == 'incomplete': + if report['result'] == 'ambiguous-iat': + out.write('next more than one run of pointers matches the call targets, so no table was ' + 'chosen: name the range with --iat, or dump the table from the process and pass ' + 'it there. Picking one of these clusters is how a call ends up going through the ' + 'wrong slot\n') + elif report['result'] == 'incomplete': out.write('next each refusal is a decision for a person: a site with no filler byte is a ' 'structural edit, and an ambiguous slot needs the trace that says which slot the ' 'call actually used\n') @@ -461,7 +586,12 @@ def main(): ap.add_argument('--base', type=lambda v: int(v, 0), help='address the image was captured at (default: the header ImageBase)') ap.add_argument('--iat', type=parse_range, metavar='RVA,SIZE', - help='explicit import-table range, for images whose import directory is gone') + help='explicit import-table range; suppresses discovery') + ap.add_argument('--no-discover', action='store_true', + help='do not look for the table when the described slots pair nothing') + ap.add_argument('--discover-gap', type=int, default=DEFAULT_DISCOVER_GAP, metavar='BYTES', + help='matches closer than this many bytes are one table (default %d, one page)' + % DEFAULT_DISCOVER_GAP) ap.add_argument('--max-sites', type=int, default=DEFAULT_MAX_SITES, help='site rows printed per list (default %d)' % DEFAULT_MAX_SITES) ap.add_argument('--json', action='store_true', help='emit the report as JSON') @@ -527,7 +657,7 @@ def main(): if args.full_out: sys.stdout.write('full record %s\n' % args.full_out) - return 1 if report['result'] in ('incomplete', 'no-slots') else 0 + return 1 if report['result'] in ('incomplete', 'no-slots', 'ambiguous-iat') else 0 if __name__ == '__main__': diff --git a/tests/README.md b/tests/README.md index cd4acca..37eda50 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. | diff --git a/tests/cli/test_cli_iat_callsite.py b/tests/cli/test_cli_iat_callsite.py index 43ead99..6db3fda 100644 --- a/tests/cli/test_cli_iat_callsite.py +++ b/tests/cli/test_cli_iat_callsite.py @@ -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): diff --git a/tests/conftest.py b/tests/conftest.py index 0a49e32..bc7619a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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("