Separate an empty slot inventory from a table that holds handles

An empty pair list had two causes and one verdict. When the run never found the import
table at all, the report said needs-trace, which sends the reader off to write a trace of
the protector's resolver -- the right move for a table full of handles and the wrong move
for a table this run simply never located. The misdiagnosis is the expensive kind: it
costs a debugger session and it looks like progress.

decide_result now returns no-slots when the inventory is empty, and the printed hint says
which of the two it is (supply --iat, or reconstruct the table). no-slots exits 1 with
needs-trace, because the run did not do the job it was asked to do; the docstring's exit
code table says so.

The reference gains the matching sentence in the inventory step: an inventory that came
back empty is a finding about the run, not a table that holds no addresses.

Test: test_no_slot_values_is_its_own_verdict pins both sides -- no slots plus unpaired
calls is no-slots, some slots plus unpaired calls is still needs-trace -- and the existing
token test now carries a slot count so it cannot pass for the wrong reason.
This commit is contained in:
root
2026-10-27 16:34:45 +08:00
parent e5b45d9072
commit 9ce5ac29d2
3 changed files with 32 additions and 6 deletions
@@ -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
@@ -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__':
+16 -1
View File
@@ -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"