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 0e53415..36f7e91 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 @@ -79,7 +79,9 @@ were counted over. — and record which one you used. 2. **Inventory the slots.** Three sources, in descending order of trust: the import descriptors if 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. + 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. 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 diff --git a/skills/exe-reverse/scripts/iat_callsite_restore.py b/skills/exe-reverse/scripts/iat_callsite_restore.py index b500355..5773258 100644 --- a/skills/exe-reverse/scripts/iat_callsite_restore.py +++ b/skills/exe-reverse/scripts/iat_callsite_restore.py @@ -44,9 +44,9 @@ Usage: python iat_callsite_restore.py dumped.exe --base 0x2B0000 --iat 0x48D000,0x1000 python iat_callsite_restore.py dumped.exe --json --full-out report.json -Exit codes: 0 = the analysis completed and no site was refused (result clean, needs-trace, -report-only or repaired), 1 = at least one site was refused or a rewrite failed verification, -2 = usage error, 3 = the input is not a parseable PE. +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. """ import argparse import hashlib @@ -367,6 +367,11 @@ def decide_result(report, wrote): return 'incomplete' if report['pairs']: return 'repaired' if wrote else 'report-only' + if not report['iat']['slots']: + # No slot values at all is not evidence that the table is untouched: it is evidence that + # this run has nothing to pair against. Reporting that as needs-trace sends the reader to + # write a trace they do not need, which is the same misdiagnosis in a different direction. + return 'no-slots' if report['unpaired']: return 'needs-trace' return 'no-pairs' @@ -420,6 +425,10 @@ def print_report(report, max_sites): 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') + elif report['result'] == 'no-slots': + out.write('next this run has no slot values to pair against: pass --iat RVA,SIZE with ' + 'the table range, or reconstruct the table first. Without it the sites below are ' + 'calls whose target nothing in this image holds\n') elif report['result'] == 'needs-trace': out.write('next either the table does not hold these targets, or the base is not the ' 'one the image ran at -- check --base first, because a wrong base produces the ' @@ -518,7 +527,7 @@ def main(): if args.full_out: sys.stdout.write('full record %s\n' % args.full_out) - return 1 if report['result'] == 'incomplete' else 0 + return 1 if report['result'] in ('incomplete', 'no-slots') else 0 if __name__ == '__main__': diff --git a/tests/unit/test_iat_callsite_restore.py b/tests/unit/test_iat_callsite_restore.py index 74e27d8..b38ae3e 100644 --- a/tests/unit/test_iat_callsite_restore.py +++ b/tests/unit/test_iat_callsite_restore.py @@ -155,9 +155,24 @@ def test_a_clean_image_reports_no_pairs(load_script, fixtures): assert mod.decide_result(report, wrote=False) == "no-pairs" +def test_no_slot_values_is_its_own_verdict(load_script): + """A run with nothing to pair against must not send the reader to write a trace. + + Both causes -- a table that holds handles, and a table this run never found -- produce the same + empty pair list. The first needs a trace; the second needs --iat. Collapsing them into one token + was the first version of this rule. + """ + mod = load_script("iat_callsite_restore.py") + with_no_slots = {"pairs": [], "refused": [], "unpaired": [1], "iat": {"slots": 0, "classes": {}}} + assert mod.decide_result(with_no_slots, wrote=False) == "no-slots" + with_slots = {"pairs": [], "refused": [], "unpaired": [1], + "iat": {"slots": 4, "classes": {}}} + assert mod.decide_result(with_slots, wrote=False) == "needs-trace" + + def test_result_tokens_follow_the_evidence(load_script): mod = load_script("iat_callsite_restore.py") - base = {"pairs": [], "refused": [], "unpaired": [], "iat": {"classes": {}}} + base = {"pairs": [], "refused": [], "unpaired": [], "iat": {"slots": 4, "classes": {}}} assert mod.decide_result(dict(base), wrote=False) == "no-pairs" assert mod.decide_result(dict(base, pairs=[1]), wrote=False) == "report-only" assert mod.decide_result(dict(base, pairs=[1]), wrote=True) == "repaired"