scientific-method-engine 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/PKG-INFO +1 -1
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/pyproject.toml +1 -1
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/cli.py +1 -1
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/reports.py +196 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_x86.py +131 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/.gitignore +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/LICENSE +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/README.md +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/__init__.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/__main__.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ClearNoReturnFunctions.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/CreateFunctions.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportBoundedFlow.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportFunctionFingerprints.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportFunctionInventory.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/MergeFallThroughFragment.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/RecoverCitedFunctions.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/RepairReturningCallers.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallArguments.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallPaths.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallSitesWithScalars.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallsToRange.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportConstantFirstArgumentCalls.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDataBytes.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDecompileMatches.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDecompileWindow.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFilePatternInMemory.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFirstArgumentCallSummary.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFunctionScalarConstants.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFunctionSummary.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportInstructionContext.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportInstructionWindow.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportMemoryBlockForFileOffset.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportMemoryBlocks.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportRandomnessCandidates.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportReferences.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportScalarConstants.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportStringReferences.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportSymbolReferences.java +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/__init__.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/dispatch.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/image.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/machine.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/pe.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/trace.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/values.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_dispatch.py +0 -0
- {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_pe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scientific-method-engine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code.
|
|
5
5
|
Project-URL: Source, https://github.com/kibertoad/refurbished-dinosaurs-toolkit/tree/main/packages/scientific-method-engine
|
|
6
6
|
Author: kibertoad
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scientific-method-engine"
|
|
7
7
|
# The release workflow writes the published version from the package's release tag.
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.2.0"
|
|
9
9
|
description = "Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code."
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -8,7 +8,7 @@ from . import PREPARED_PROTOCOL
|
|
|
8
8
|
CONFIG_LIMIT = 1024 * 1024
|
|
9
9
|
PREPARED_CONFIG_LIMIT = 16 * 1024 * 1024
|
|
10
10
|
USAGE = ("Usage: scientific-method-engine <operand|operand-candidates|target|bounds|owner|callees|trace|uses|arguments|"
|
|
11
|
-
"effects|returns|memory|incoming|guards|allocation|dispatch> <config.json|->\n"
|
|
11
|
+
"effects|returns|memory|incoming|call-order|guards|allocation|dispatch> <config.json|->\n"
|
|
12
12
|
" scientific-method-engine ghidra-scripts")
|
|
13
13
|
|
|
14
14
|
|
|
@@ -994,6 +994,200 @@ def callees(image, config):
|
|
|
994
994
|
"that does not. Neither proves runtime recursion."}
|
|
995
995
|
|
|
996
996
|
|
|
997
|
+
# These branches test CX/ECX (LOOPE/LOOPNE also ZF), so an adjacent CMP/TEST never describes their predicate.
|
|
998
|
+
COUNT_BRANCHES = frozenset(("jcxz", "jecxz", "jrcxz", "loop", "loope", "loopne", "loopz", "loopnz"))
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _operand_view(ins, o):
|
|
1002
|
+
if o.type == X86_OP_REG:
|
|
1003
|
+
return {"kind": "register", "name": ins.reg_name(o.reg), "width": o.size}
|
|
1004
|
+
if o.type == X86_OP_IMM:
|
|
1005
|
+
return {"kind": "immediate", "value": o.imm, "width": o.size}
|
|
1006
|
+
if o.type == X86_OP_MEM:
|
|
1007
|
+
return {"kind": "memory", "width": memory_width(ins, o), "segmentRegister": segment_register(ins, o.mem),
|
|
1008
|
+
"baseRegister": ins.reg_name(o.mem.base) or None, "indexRegister": ins.reg_name(o.mem.index) or None,
|
|
1009
|
+
"displacement": o.mem.disp}
|
|
1010
|
+
return {"kind": "unresolved"}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _stack_cleanup(ins):
|
|
1014
|
+
"""The bytes an immediate ADD SP/ESP releases, or None for any other instruction or a negative immediate."""
|
|
1015
|
+
if (ins is None or ins.mnemonic != "add" or len(ins.operands) != 2 or ins.operands[0].type != X86_OP_REG
|
|
1016
|
+
or ins.reg_name(ins.operands[0].reg) not in ("sp", "esp") or ins.operands[1].type != X86_OP_IMM):
|
|
1017
|
+
return None
|
|
1018
|
+
bits = 8 * ins.operands[0].size
|
|
1019
|
+
amount = ins.operands[1].imm & ((1 << bits) - 1)
|
|
1020
|
+
return amount if 0 < amount < 1 << (bits - 1) else None
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def call_order(image, config):
|
|
1024
|
+
"""Group the confirmed incoming calls of each containing entry by necessary guards and CFG order."""
|
|
1025
|
+
flat = incoming(image, config)
|
|
1026
|
+
entry_limit = integer(config.get("entryLimit", 64), 1, 256, "entry limit")
|
|
1027
|
+
analysis_limit = integer(config.get("analysisLimit", 1000000), 1, 10000000, "call order analysis limit")
|
|
1028
|
+
established = entries(image)
|
|
1029
|
+
bodies = {e: body(image, e, config.get("instructionLimit", 10000)) for e in established[:entry_limit]}
|
|
1030
|
+
conflicts = _cross_entry_overlaps(bodies)
|
|
1031
|
+
unchecked = established[entry_limit:]
|
|
1032
|
+
sites = {r["site"] for r in flat["confirmed"]}
|
|
1033
|
+
owners = {at: [e for e, b in bodies.items() if at in b["instructions"]] for at in sites}
|
|
1034
|
+
reports = []
|
|
1035
|
+
for entry, b in bodies.items():
|
|
1036
|
+
selected = sorted(at for at in sites if entry in owners[at])
|
|
1037
|
+
if not selected:
|
|
1038
|
+
continue
|
|
1039
|
+
usable = b["complete"] and not unchecked and not conflicts.get(entry) and not flat["truncated"] and all(owners[at] == [entry] for at in selected)
|
|
1040
|
+
instructions, successors, branches, exits = b["instructions"], {}, [], set()
|
|
1041
|
+
ending = {}
|
|
1042
|
+
for at, ins in instructions.items():
|
|
1043
|
+
ending.setdefault(at + ins.size, []).append(at)
|
|
1044
|
+
for at, ins in instructions.items():
|
|
1045
|
+
m, following = base_mnemonic(ins), at + ins.size
|
|
1046
|
+
targets = []
|
|
1047
|
+
if m in RETURNS or m == "hlt" or unsupported_transfer(image, ins):
|
|
1048
|
+
pass
|
|
1049
|
+
elif m in ("jmp", "ljmp"):
|
|
1050
|
+
d = image.indirect_jumps.get(at)
|
|
1051
|
+
targets = [r["target"] for r in d["rows"]] if d else [call_target(image, at, ins)[0]]
|
|
1052
|
+
elif m.startswith("j") or m.startswith("loop"):
|
|
1053
|
+
target = call_target(image, at, ins)[0]
|
|
1054
|
+
targets = [target, following]
|
|
1055
|
+
if target != following:
|
|
1056
|
+
producer = ending.get(at, []) if m not in COUNT_BRANCHES and at != entry else []
|
|
1057
|
+
comparison = instructions[producer[0]] if len(producer) == 1 else None
|
|
1058
|
+
context = ({"site": producer[0], "mnemonic": comparison.mnemonic,
|
|
1059
|
+
"operands": [_operand_view(comparison, o) for o in comparison.operands]}
|
|
1060
|
+
if comparison and comparison.mnemonic in ("cmp", "test") else None)
|
|
1061
|
+
for taken, to in ((True, target), (False, following)):
|
|
1062
|
+
if to is not None:
|
|
1063
|
+
branches.append({"site": at, "to": to, "taken": taken, "predicate": m,
|
|
1064
|
+
"comparison": context, "interpretation": "necessary caller CFG edge, not a runtime value or preserved guard"})
|
|
1065
|
+
else:
|
|
1066
|
+
targets = [following]
|
|
1067
|
+
successors[at] = sorted(set(t for t in targets if t in instructions))
|
|
1068
|
+
# Returns, halts, unsupported frames and transfers out of the body leave the caller CFG.
|
|
1069
|
+
if not targets or any(t not in instructions for t in targets):
|
|
1070
|
+
exits.add(at)
|
|
1071
|
+
predecessors = {}
|
|
1072
|
+
for source, targets in successors.items():
|
|
1073
|
+
for to in targets:
|
|
1074
|
+
predecessors.setdefault(to, set()).add(source)
|
|
1075
|
+
for guard in branches:
|
|
1076
|
+
if guard["comparison"] and predecessors.get(guard["site"], set()) != {guard["comparison"]["site"]}:
|
|
1077
|
+
guard["comparison"] = None
|
|
1078
|
+
cache, spent, capped = {}, 0, False
|
|
1079
|
+
def reachable(start, blocked=None, stops=frozenset()):
|
|
1080
|
+
nonlocal spent, capped
|
|
1081
|
+
key = (start, blocked, stops)
|
|
1082
|
+
if key in cache:
|
|
1083
|
+
return cache[key]
|
|
1084
|
+
todo, seen = [start], set()
|
|
1085
|
+
while todo:
|
|
1086
|
+
at = todo.pop()
|
|
1087
|
+
if at in seen or at not in instructions or at in stops:
|
|
1088
|
+
continue
|
|
1089
|
+
if spent >= analysis_limit:
|
|
1090
|
+
capped = True
|
|
1091
|
+
return set()
|
|
1092
|
+
spent += 1
|
|
1093
|
+
seen.add(at)
|
|
1094
|
+
todo.extend(to for to in successors[at] if (at, to) != blocked)
|
|
1095
|
+
cache[key] = seen
|
|
1096
|
+
return seen
|
|
1097
|
+
|
|
1098
|
+
def dominates(first, second):
|
|
1099
|
+
"""Whether every route from the entry to second passes first."""
|
|
1100
|
+
return second not in reachable(entry, stops=frozenset((first,)))
|
|
1101
|
+
|
|
1102
|
+
def must_follow(first, second, guards):
|
|
1103
|
+
"""Whether every route from first's continuation reaches second before an exit or a guard revisit."""
|
|
1104
|
+
start = first + instructions[first].size
|
|
1105
|
+
if start == second:
|
|
1106
|
+
return True
|
|
1107
|
+
if start in guards:
|
|
1108
|
+
return False
|
|
1109
|
+
seen = reachable(start, stops=guards | {second})
|
|
1110
|
+
return not seen & exits and not any(to in guards for at in seen for to in successors[at])
|
|
1111
|
+
necessary = {at: [] for at in selected}
|
|
1112
|
+
for guard in branches:
|
|
1113
|
+
remaining = reachable(entry, (guard["site"], guard["to"]))
|
|
1114
|
+
for at in selected:
|
|
1115
|
+
if at not in remaining:
|
|
1116
|
+
necessary[at].append({k: v for k, v in guard.items() if k != "to"})
|
|
1117
|
+
later = {at: reachable(at + instructions[at].size) & sites for at in selected}
|
|
1118
|
+
rows = []
|
|
1119
|
+
for at in selected:
|
|
1120
|
+
following = at + instructions[at].size
|
|
1121
|
+
amount = _stack_cleanup(instructions.get(following))
|
|
1122
|
+
rows.append({"site": at, "necessaryGuards": necessary[at] if not capped else [],
|
|
1123
|
+
"cleanup": {"continuation": following, "site": following if amount is not None else None,
|
|
1124
|
+
"argumentBytes": amount, "status": "observed after assumed return" if amount is not None else "unread cleanup",
|
|
1125
|
+
"assumption": "callee returns to the next instruction"},
|
|
1126
|
+
"calleeEffects": {"status": "unresolved", "reason": "caller CFG never proves callee return success or restored/preserved state"}})
|
|
1127
|
+
partitions = {}
|
|
1128
|
+
for row in rows:
|
|
1129
|
+
key = tuple((g["site"], g["taken"]) for g in row["necessaryGuards"])
|
|
1130
|
+
partitions.setdefault(key, []).append(row["site"])
|
|
1131
|
+
groups = []
|
|
1132
|
+
for members in partitions.values():
|
|
1133
|
+
shared_guards = next(r["necessaryGuards"] for r in rows if r["site"] == members[0]) if not capped else []
|
|
1134
|
+
guard_sites = frozenset(g["site"] for g in shared_guards)
|
|
1135
|
+
local_later = {at: reachable(at + instructions[at].size, stops=guard_sites) & sites for at in members}
|
|
1136
|
+
pairs = [(a, z) for index, a in enumerate(members) for z in members[index + 1:]]
|
|
1137
|
+
sequence = all((z in local_later[a]) != (a in local_later[z]) for a, z in pairs) and all(a not in local_later[a] for a in members)
|
|
1138
|
+
alternatives = len(members) > 1 and all(z not in local_later[a] and a not in local_later[z] for a, z in pairs)
|
|
1139
|
+
kind = "unread" if not usable or capped else "sequence" if sequence else "branchAlternatives" if alternatives else "unread"
|
|
1140
|
+
order = sorted(members, key=lambda a: sum(a in local_later[z] for z in members if z != a)) if kind == "sequence" else None
|
|
1141
|
+
# Equal single-edge guards do not make every member run: a later call may be reached around an
|
|
1142
|
+
# earlier one (two branches to it, a jump table) or be skipped after it. Each call must dominate
|
|
1143
|
+
# the next and the next must follow it on every route within the visit.
|
|
1144
|
+
if order and not all(dominates(a, z) and must_follow(a, z, guard_sites) for a, z in zip(order, order[1:])):
|
|
1145
|
+
kind, order = "unread", None
|
|
1146
|
+
groups.append({"kind": kind, "sites": members, "order": order,
|
|
1147
|
+
"sharedGuards": shared_guards,
|
|
1148
|
+
"scope": "one visit past the shared guard edges" if guard_sites else "caller CFG",
|
|
1149
|
+
"mayRepeatAcrossGuardVisits": any(a in later[a] for a in members)})
|
|
1150
|
+
if capped or not usable:
|
|
1151
|
+
# A cycle found in a partial read still may repeat; finding none there proves nothing.
|
|
1152
|
+
for group in groups:
|
|
1153
|
+
group["mayRepeatAcrossGuardVisits"] = group["mayRepeatAcrossGuardVisits"] or None
|
|
1154
|
+
if capped:
|
|
1155
|
+
for group in groups:
|
|
1156
|
+
group.update(kind="unread", order=None, sharedGuards=[])
|
|
1157
|
+
for row in rows:
|
|
1158
|
+
row["necessaryGuards"] = []
|
|
1159
|
+
relations = []
|
|
1160
|
+
for index, first in enumerate(selected):
|
|
1161
|
+
for second in selected[index + 1:]:
|
|
1162
|
+
forward, backward = second in later[first], first in later[second]
|
|
1163
|
+
kind = ("unread" if not usable or capped or forward and backward else
|
|
1164
|
+
"sequence" if forward or backward else "branchAlternatives")
|
|
1165
|
+
relations.append({"sites": [first, second], "kind": kind,
|
|
1166
|
+
"order": [first, second] if kind == "sequence" and forward else [second, first] if kind == "sequence" else None})
|
|
1167
|
+
if relations and all(r["kind"] == "branchAlternatives" for r in relations):
|
|
1168
|
+
common = [g for g in rows[0]["necessaryGuards"] if all(any(g["site"] == h["site"] and g["taken"] == h["taken"] for h in row["necessaryGuards"]) for row in rows)]
|
|
1169
|
+
groups = [{"kind": "branchAlternatives", "sites": selected, "order": None, "sharedGuards": common,
|
|
1170
|
+
"scope": "caller CFG", "mayRepeatAcrossGuardVisits": any(a in later[a] for a in selected)}]
|
|
1171
|
+
reports.append({"entry": entry, "ranges": b["intervals"], "boundaryUsable": usable, "orderingUsable": usable and not capped,
|
|
1172
|
+
"gaps": b["gaps"], "conflicts": conflicts.get(entry, []), "analysisCapped": capped, "analysisSteps": spent,
|
|
1173
|
+
"calls": rows, "groups": groups, "relations": relations, "assumptions": b["assumedContinuations"]})
|
|
1174
|
+
controls = config.get("orderControls", [])
|
|
1175
|
+
if not isinstance(controls, list) or len(controls) > 256:
|
|
1176
|
+
raise ValueError("Invalid call order controls")
|
|
1177
|
+
for control in controls:
|
|
1178
|
+
if not isinstance(control, dict) or control.get("kind") not in ("sequence", "branchAlternatives") or not isinstance(control.get("sites"), list) or not control["sites"] or len(control["sites"]) > 256 or any(type(at) is not int for at in control["sites"]) or type(control.get("entry")) is not int:
|
|
1179
|
+
raise ValueError("Invalid call order control")
|
|
1180
|
+
report = next((r for r in reports if r["entry"] == control.get("entry")), None)
|
|
1181
|
+
# Sequence sites are compared in order; branch alternatives have none.
|
|
1182
|
+
expected = control["sites"] if control["kind"] == "sequence" else sorted(control["sites"])
|
|
1183
|
+
group = next((g for g in report["groups"] if g["kind"] == control["kind"] and (g["order"] if g["kind"] == "sequence" else sorted(g["sites"])) == expected), None) if report else None
|
|
1184
|
+
if group is None:
|
|
1185
|
+
raise ValueError("Call order positive control missed")
|
|
1186
|
+
return {"incoming": flat, "callers": reports, "uncheckedEntries": unchecked, "orderControls": controls,
|
|
1187
|
+
"unownedSites": sorted(at for at in sites if not owners[at]),
|
|
1188
|
+
"interpretation": "Order and guards describe a conditional caller CFG. Cleanup follows an assumed return; callees' effects, successful return and state restoration remain unresolved."}
|
|
1189
|
+
|
|
1190
|
+
|
|
997
1191
|
def _body_report(b):
|
|
998
1192
|
return {k: v for k, v in b.items() if k != "instructions"} | {"instructionCount": len(b["instructions"])}
|
|
999
1193
|
|
|
@@ -1158,6 +1352,8 @@ def _run_report(image, config, command):
|
|
|
1158
1352
|
return owner(image, config)
|
|
1159
1353
|
if command == "callees":
|
|
1160
1354
|
return callees(image, config)
|
|
1355
|
+
if command == "call-order":
|
|
1356
|
+
return call_order(image, config)
|
|
1161
1357
|
if command == "incoming":
|
|
1162
1358
|
return incoming(image, config)
|
|
1163
1359
|
if command == "operand-candidates":
|
|
@@ -349,6 +349,137 @@ class NearPointerSegmentTests(unittest.TestCase):
|
|
|
349
349
|
self.assertTrue(all(p["dereferenceSegmentRegister"] == "es" and p["segmentRelationship"] == "sameWithinModel" for p in links))
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
class CallOrderTests(unittest.TestCase):
|
|
353
|
+
def run_order(self, code, **extra):
|
|
354
|
+
data = code.bytes()
|
|
355
|
+
cfg = configuration(data, target=code.labels["helper"], **extra)
|
|
356
|
+
cfg["regions"][0]["entries"] = [0, code.labels["helper"]]
|
|
357
|
+
return run_report(data, cfg, "call-order"), cfg
|
|
358
|
+
|
|
359
|
+
def test_guarded_sequence_retains_shared_cmp_cleanup_and_unread_effects(self):
|
|
360
|
+
c = Code().emit("83 7e fa 05").branch("7c", "end")
|
|
361
|
+
c.label("one").branch("e8", "helper").emit("83 c4 08")
|
|
362
|
+
c.label("two").branch("e8", "helper").emit("83 c4 08")
|
|
363
|
+
c.label("end").emit("c3").label("helper").emit("c3")
|
|
364
|
+
r, cfg = self.run_order(c, controls=[c.labels["one"], c.labels["two"]], orderControls=[{"entry": 0, "kind": "sequence", "sites": [c.labels["one"], c.labels["two"]]}])
|
|
365
|
+
caller = r["callers"][0]
|
|
366
|
+
self.assertEqual(caller["groups"][0]["kind"], "sequence")
|
|
367
|
+
self.assertEqual(caller["groups"][0]["sharedGuards"][0]["comparison"]["operands"][0]["segmentRegister"], "ss")
|
|
368
|
+
self.assertTrue(all(row["cleanup"]["argumentBytes"] == 8 and row["calleeEffects"]["status"] == "unresolved" for row in caller["calls"]))
|
|
369
|
+
self.assertEqual(len(r["incoming"]["confirmed"]), 2)
|
|
370
|
+
|
|
371
|
+
def test_adjacent_comparison_is_not_claimed_when_another_edge_enters_the_branch(self):
|
|
372
|
+
c = Code().emit("85 c0").branch("74", "compare").branch("e9", "condition")
|
|
373
|
+
c.label("compare").emit("83 fb 05").label("condition").branch("7c", "end").branch("e8", "helper")
|
|
374
|
+
c.label("end").emit("c3").label("helper").emit("c3")
|
|
375
|
+
r, _ = self.run_order(c)
|
|
376
|
+
guards = r["callers"][0]["groups"][0]["sharedGuards"]
|
|
377
|
+
conditional = next(g for g in guards if g["site"] == c.labels["condition"])
|
|
378
|
+
self.assertIsNone(conditional["comparison"])
|
|
379
|
+
|
|
380
|
+
def test_sequence_order_comes_from_flow_not_ascending_addresses(self):
|
|
381
|
+
c = Code().branch("e9", "first").label("second").branch("e8", "helper").emit("c3")
|
|
382
|
+
c.label("first").branch("e8", "helper").branch("e9", "second").label("helper").emit("c3")
|
|
383
|
+
r, _ = self.run_order(c)
|
|
384
|
+
self.assertEqual(r["callers"][0]["groups"][0]["order"], [c.labels["first"], c.labels["second"]])
|
|
385
|
+
|
|
386
|
+
def test_branch_alternatives_do_not_become_a_sequence(self):
|
|
387
|
+
c = Code().emit("85 c0").branch("74", "other").branch("e8", "helper").emit("c3")
|
|
388
|
+
c.label("other").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
389
|
+
r, cfg = self.run_order(c)
|
|
390
|
+
self.assertEqual(r["callers"][0]["groups"][0]["kind"], "branchAlternatives")
|
|
391
|
+
cfg["orderControls"] = [{"entry": 0, "kind": "sequence", "sites": r["callers"][0]["groups"][0]["sites"]}]
|
|
392
|
+
with self.assertRaisesRegex(ValueError, "positive control"):
|
|
393
|
+
run_report(c.bytes(), cfg, "call-order")
|
|
394
|
+
|
|
395
|
+
def test_outer_loop_keeps_sequence_within_a_guard_visit_and_reports_recurrence(self):
|
|
396
|
+
c = Code().label("guard").emit("83 f8 05").branch("7c", "end")
|
|
397
|
+
c.label("one").branch("e8", "helper").label("two").branch("e8", "helper").branch("eb", "guard")
|
|
398
|
+
c.label("end").emit("c3").label("helper").emit("c3")
|
|
399
|
+
r, _ = self.run_order(c)
|
|
400
|
+
g = r["callers"][0]["groups"][0]
|
|
401
|
+
self.assertEqual(g["kind"], "sequence")
|
|
402
|
+
self.assertEqual(g["order"], [c.labels["one"], c.labels["two"]])
|
|
403
|
+
self.assertTrue(g["mayRepeatAcrossGuardVisits"])
|
|
404
|
+
self.assertEqual(g["scope"], "one visit past the shared guard edges")
|
|
405
|
+
|
|
406
|
+
def test_shared_ownership_never_verifies_order(self):
|
|
407
|
+
c = Code().emit("90").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
408
|
+
cfg = configuration(c.bytes(), target=c.labels["helper"])
|
|
409
|
+
cfg["regions"][0]["entries"] = [0, 1, c.labels["helper"]]
|
|
410
|
+
r = run_report(c.bytes(), cfg, "call-order")
|
|
411
|
+
self.assertTrue(r["callers"])
|
|
412
|
+
self.assertTrue(all(not caller["orderingUsable"] for caller in r["callers"]))
|
|
413
|
+
self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
|
|
414
|
+
|
|
415
|
+
def test_overlapping_entries_are_rejected_by_the_flat_boundary_inventory(self):
|
|
416
|
+
c = Code().emit("66 90").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
417
|
+
cfg = configuration(c.bytes(), target=c.labels["helper"])
|
|
418
|
+
cfg["regions"][0]["entries"] = [0, 1, c.labels["helper"]]
|
|
419
|
+
r = run_report(c.bytes(), cfg, "call-order")
|
|
420
|
+
self.assertFalse(r["incoming"]["confirmed"])
|
|
421
|
+
self.assertGreater(sum(r["incoming"]["counts"].values()), 0)
|
|
422
|
+
self.assertFalse(r["callers"])
|
|
423
|
+
|
|
424
|
+
def test_caps_and_recurring_calls_leave_order_unread(self):
|
|
425
|
+
c = Code().branch("e8", "helper").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
426
|
+
for options in ({"entryLimit": 1}, {"limit": 1}, {"instructionLimit": 1}, {"analysisLimit": 1}):
|
|
427
|
+
r, _ = self.run_order(c, **options)
|
|
428
|
+
self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
|
|
429
|
+
loop = Code().label("again").branch("e8", "helper").branch("eb", "again").label("helper").emit("c3")
|
|
430
|
+
r, _ = self.run_order(loop)
|
|
431
|
+
self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
|
|
432
|
+
|
|
433
|
+
def test_capped_analysis_does_not_deny_recurrence(self):
|
|
434
|
+
c = Code().branch("e8", "helper").emit("90 c3").label("helper").emit("c3")
|
|
435
|
+
r, _ = self.run_order(c, analysisLimit=1)
|
|
436
|
+
self.assertTrue(r["callers"][0]["analysisCapped"])
|
|
437
|
+
self.assertIsNone(r["callers"][0]["groups"][0]["mayRepeatAcrossGuardVisits"])
|
|
438
|
+
|
|
439
|
+
def test_a_call_reached_around_another_is_not_sequenced_after_it(self):
|
|
440
|
+
# Two branches reach the first call, so neither edge alone is necessary, and the JMP skips it.
|
|
441
|
+
c = Code().emit("85 c0").branch("74", "first").branch("75", "first").branch("eb", "second")
|
|
442
|
+
c.label("first").branch("e8", "helper").label("second").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
443
|
+
r, cfg = self.run_order(c)
|
|
444
|
+
self.assertEqual(r["callers"][0]["groups"][0]["kind"], "unread")
|
|
445
|
+
cfg["orderControls"] = [{"entry": 0, "kind": "sequence", "sites": [c.labels["first"], c.labels["second"]]}]
|
|
446
|
+
with self.assertRaisesRegex(ValueError, "positive control"):
|
|
447
|
+
run_report(c.bytes(), cfg, "call-order")
|
|
448
|
+
|
|
449
|
+
def test_a_call_that_can_be_skipped_after_another_is_not_sequenced_after_it(self):
|
|
450
|
+
c = Code().label("first").branch("e8", "helper").emit("85 c0").branch("74", "second").branch("75", "second").emit("c3")
|
|
451
|
+
c.label("second").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
452
|
+
r, _ = self.run_order(c)
|
|
453
|
+
self.assertEqual(r["callers"][0]["groups"][0]["kind"], "unread")
|
|
454
|
+
|
|
455
|
+
def test_alternatives_have_the_full_group_shape_and_unordered_controls(self):
|
|
456
|
+
c = Code().emit("85 c0").branch("74", "other").label("one").branch("e8", "helper").emit("c3")
|
|
457
|
+
c.label("other").branch("e8", "helper").emit("c3").label("helper").emit("c3")
|
|
458
|
+
r, _ = self.run_order(c, orderControls=[{"entry": 0, "kind": "branchAlternatives", "sites": [c.labels["other"], c.labels["one"]]}])
|
|
459
|
+
g = r["callers"][0]["groups"][0]
|
|
460
|
+
self.assertEqual(g["kind"], "branchAlternatives")
|
|
461
|
+
self.assertEqual((g["scope"], g["mayRepeatAcrossGuardVisits"]), ("caller CFG", False))
|
|
462
|
+
|
|
463
|
+
def test_count_branches_and_entry_branches_take_no_adjacent_comparison(self):
|
|
464
|
+
c = Code().emit("83 f8 05").branch("e3", "end").branch("e8", "helper").label("end").emit("c3").label("helper").emit("c3")
|
|
465
|
+
r, _ = self.run_order(c)
|
|
466
|
+
self.assertIsNone(r["callers"][0]["calls"][0]["necessaryGuards"][0]["comparison"])
|
|
467
|
+
# The CMP before the entry is its only CFG predecessor, but the entry is also entered from outside.
|
|
468
|
+
c = Code().label("compare").emit("83 f8 05").label("entry").branch("7c", "end").branch("e8", "helper").branch("eb", "compare")
|
|
469
|
+
c.label("end").emit("c3").label("helper").emit("c3")
|
|
470
|
+
data = c.bytes()
|
|
471
|
+
cfg = configuration(data, target=c.labels["helper"])
|
|
472
|
+
cfg["regions"][0]["entries"] = [c.labels["entry"], c.labels["helper"]]
|
|
473
|
+
r = run_report(data, cfg, "call-order")
|
|
474
|
+
self.assertIsNone(r["callers"][0]["calls"][0]["necessaryGuards"][0]["comparison"])
|
|
475
|
+
|
|
476
|
+
def test_a_negative_stack_adjustment_is_not_cleanup(self):
|
|
477
|
+
c = Code().branch("e8", "helper").emit("81 c4 00 80 c3").label("helper").emit("c3")
|
|
478
|
+
r, _ = self.run_order(c)
|
|
479
|
+
self.assertEqual(r["callers"][0]["calls"][0]["cleanup"]["status"], "unread cleanup")
|
|
480
|
+
self.assertIsNone(r["callers"][0]["calls"][0]["cleanup"]["argumentBytes"])
|
|
481
|
+
|
|
482
|
+
|
|
352
483
|
class ReporterTests(unittest.TestCase):
|
|
353
484
|
def test_register_parts_preserve_neighbor(self):
|
|
354
485
|
r = report("b8 34 12 b0 00 c3")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|