scientific-method-engine 0.1.0__py3-none-any.whl

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.
Files changed (45) hide show
  1. scientific_method_engine/__init__.py +5 -0
  2. scientific_method_engine/__main__.py +3 -0
  3. scientific_method_engine/cli.py +75 -0
  4. scientific_method_engine/ghidra/ClearNoReturnFunctions.java +22 -0
  5. scientific_method_engine/ghidra/CreateFunctions.java +27 -0
  6. scientific_method_engine/ghidra/ExportBoundedFlow.java +59 -0
  7. scientific_method_engine/ghidra/ExportFunctionFingerprints.java +135 -0
  8. scientific_method_engine/ghidra/ExportFunctionInventory.java +50 -0
  9. scientific_method_engine/ghidra/MergeFallThroughFragment.java +63 -0
  10. scientific_method_engine/ghidra/RecoverCitedFunctions.java +93 -0
  11. scientific_method_engine/ghidra/RepairReturningCallers.java +76 -0
  12. scientific_method_engine/ghidra/ReportCallArguments.java +65 -0
  13. scientific_method_engine/ghidra/ReportCallPaths.java +106 -0
  14. scientific_method_engine/ghidra/ReportCallSitesWithScalars.java +95 -0
  15. scientific_method_engine/ghidra/ReportCallsToRange.java +67 -0
  16. scientific_method_engine/ghidra/ReportConstantFirstArgumentCalls.java +55 -0
  17. scientific_method_engine/ghidra/ReportDataBytes.java +36 -0
  18. scientific_method_engine/ghidra/ReportDecompileMatches.java +71 -0
  19. scientific_method_engine/ghidra/ReportDecompileWindow.java +61 -0
  20. scientific_method_engine/ghidra/ReportFilePatternInMemory.java +102 -0
  21. scientific_method_engine/ghidra/ReportFirstArgumentCallSummary.java +63 -0
  22. scientific_method_engine/ghidra/ReportFunctionScalarConstants.java +56 -0
  23. scientific_method_engine/ghidra/ReportFunctionSummary.java +64 -0
  24. scientific_method_engine/ghidra/ReportInstructionContext.java +64 -0
  25. scientific_method_engine/ghidra/ReportInstructionWindow.java +36 -0
  26. scientific_method_engine/ghidra/ReportMemoryBlockForFileOffset.java +105 -0
  27. scientific_method_engine/ghidra/ReportMemoryBlocks.java +52 -0
  28. scientific_method_engine/ghidra/ReportRandomnessCandidates.java +74 -0
  29. scientific_method_engine/ghidra/ReportReferences.java +42 -0
  30. scientific_method_engine/ghidra/ReportScalarConstants.java +55 -0
  31. scientific_method_engine/ghidra/ReportStringReferences.java +102 -0
  32. scientific_method_engine/ghidra/ReportSymbolReferences.java +72 -0
  33. scientific_method_engine/x86/__init__.py +0 -0
  34. scientific_method_engine/x86/dispatch.py +51 -0
  35. scientific_method_engine/x86/image.py +172 -0
  36. scientific_method_engine/x86/machine.py +659 -0
  37. scientific_method_engine/x86/pe.py +96 -0
  38. scientific_method_engine/x86/reports.py +1259 -0
  39. scientific_method_engine/x86/trace.py +547 -0
  40. scientific_method_engine/x86/values.py +123 -0
  41. scientific_method_engine-0.1.0.dist-info/METADATA +110 -0
  42. scientific_method_engine-0.1.0.dist-info/RECORD +45 -0
  43. scientific_method_engine-0.1.0.dist-info/WHEEL +4 -0
  44. scientific_method_engine-0.1.0.dist-info/entry_points.txt +2 -0
  45. scientific_method_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,547 @@
1
+ """Bounded control flow and interprocedural path reports."""
2
+ from copy import deepcopy
3
+ from capstone.x86 import X86_OP_IMM, X86_OP_REG, X86_OP_MEM
4
+ from .image import integer
5
+ from .machine import (State, StopPath, ordinary, predicate, REGISTERS, ALIASES, string_instruction, string_count,
6
+ string_effect, check_string_form)
7
+ from .values import const, unknown, sources, op, Value
8
+
9
+ # Synonymous and complementary branches on one flag producer share a single assumption.
10
+ BRANCH_CONDITIONS = {}
11
+ for names, condition in ((("je", "jz"), "z"), (("jb", "jc", "jnae"), "c"), (("jbe", "jna"), "be"),
12
+ (("jl", "jnge"), "l"), (("jle", "jng"), "le"), (("js",), "s"),
13
+ (("jo",), "o"), (("jp", "jpe"), "p")):
14
+ for name in names:
15
+ BRANCH_CONDITIONS[name] = (condition, False)
16
+ for names, condition in ((("jne", "jnz"), "z"), (("jae", "jnb", "jnc"), "c"), (("ja", "jnbe"), "be"),
17
+ (("jge", "jnl"), "l"), (("jg", "jnle"), "le"), (("jns",), "s"),
18
+ (("jno",), "o"), (("jnp", "jpo"), "p")):
19
+ for name in names:
20
+ BRANCH_CONDITIONS[name] = (condition, True)
21
+
22
+
23
+ def call_target(image, site, ins):
24
+ if ins.mnemonic in ("lcall", "ljmp"):
25
+ return image.far_target(site, ins)
26
+ if ins.operands and ins.operands[0].type == X86_OP_IMM:
27
+ return image.near_target(site, ins.operands[0].imm), {"encoding": "relative32" if image.flat else "relative16", "loadedTarget": ins.operands[0].imm & image.mask,
28
+ "mapping": "source PE section table" if image.config.get("peMetadata") else "declared region mapping"}
29
+ return None, {"reason": "computed transfer remains unresolved"}
30
+
31
+
32
+ OVERLAP_REASON = "overlapping entry-path instructions; boundary unresolved"
33
+ RETURNS = {"ret": "near return", "retf": "far return", "iret": "interrupt return", "iretd": "interrupt return"}
34
+ INTERRUPTS = ("int", "int1", "int3", "into")
35
+ PORTS = ("in", "out", "insb", "insw", "insd", "outsb", "outsw", "outsd")
36
+
37
+
38
+ def base_mnemonic(ins):
39
+ # Capstone names REP/REPNE/BND prefixes in the mnemonic ("repz ret", "rep insb", "bnd jmp").
40
+ return ins.mnemonic.split()[-1]
41
+ CONTESTED_REASON = "reached only through a rejected overlapping start"
42
+
43
+
44
+ def unsupported_transfer(image, ins):
45
+ """Operand-size overrides and flat-model far transfers fall outside the frame model."""
46
+ m = base_mnemonic(ins)
47
+ return ((0x66 in ins.prefix and (m in ("call", "lcall", "ret", "retf", "jmp", "ljmp") or m.startswith(("j", "loop"))))
48
+ or (image.flat and m in ("lcall", "ljmp", "retf")))
49
+
50
+
51
+ def counter_branch(state, ins):
52
+ """JCXZ/JECXZ and the LOOP family: CX (ECX with a 32-bit address size) decides the branch, with ZF for LOOPE/LOOPNE."""
53
+ m = ins.mnemonic
54
+ counter = "ecx" if ins.addr_size == 4 else "cx"
55
+ if m.startswith("loop"):
56
+ # LOOP decrements the counter without changing any flag.
57
+ state.setreg(counter, op("sub", state.reg(counter), const(1, 32 if counter == "ecx" else 16), state.at), state.at)
58
+ count = state.reg(counter)
59
+ info = {"predicate": m, "counter": counter, "count": count.report()}
60
+ if m in ("jcxz", "jecxz"):
61
+ answer = None if count.number is None else count.number == 0
62
+ return answer, info, repr(("counter-zero", count.term))
63
+ nonzero = None if count.number is None else count.number != 0
64
+ zero_flag = None
65
+ if m != "loop":
66
+ zero_flag, flag_info = predicate(state, "je")
67
+ info["zeroFlag"] = flag_info
68
+ if zero_flag is not None and m in ("loopne", "loopnz"):
69
+ zero_flag = not zero_flag
70
+ parts = [nonzero] if m == "loop" else [nonzero, zero_flag]
71
+ answer = False if False in parts else None if None in parts else True
72
+ flags = state.flags if state.flags is not None else ("unresolved", state.flag_epoch)
73
+ return answer, info, repr((m, count.term) if m == "loop" else (m, count.term, flags))
74
+
75
+
76
+ def walk(image, entries, limit=10000):
77
+ integer(limit, 1, 100000, "instruction limit")
78
+ pending, seen, gaps, edges = list(entries), {}, [], []
79
+ # Decoded successors of each instruction, so a proof can be checked for independence below.
80
+ # A call's return site is reached only if the callee returns, so it never proves an overlapping start.
81
+ successors, returns, supplied_edges = {}, set(), set()
82
+ while pending:
83
+ at = pending.pop()
84
+ if at in seen:
85
+ continue
86
+ if len(seen) >= limit:
87
+ gaps.append({"site": at, "reason": "instruction limit"})
88
+ break
89
+ ins = image.decode(at)
90
+ if ins is None:
91
+ gaps.append({"site": at, "reason": "undecoded or unmapped edge"})
92
+ continue
93
+ seen[at] = ins
94
+ successors[at] = following_sites = []
95
+ m, following = base_mnemonic(ins), at + ins.size
96
+ if unsupported_transfer(image, ins):
97
+ gaps.append({"site": at, "reason": "unsupported control-transfer frame encoding"})
98
+ continue
99
+ declaration = image.indirect_jumps.get(at)
100
+ if declaration is not None:
101
+ for row in declaration["rows"]:
102
+ target = row["target"]
103
+ edges.append({"site": at, "target": target, "kind": "jmp",
104
+ "provenance": {"encoding": "declared indirect jump table", **declaration}})
105
+ pending.append(target)
106
+ following_sites.append(target)
107
+ supplied_edges.add((at, target))
108
+ if not declaration["exhaustive"]:
109
+ gaps.append({"site": at, "reason": "indirect jump table is not declared exhaustive"})
110
+ edges.append({"site": at, "target": None, "kind": "jmp",
111
+ "provenance": {"reason": "indirect jump table is not declared exhaustive"}})
112
+ continue
113
+ if m in RETURNS:
114
+ continue
115
+ if m in ("call", "lcall", "jmp", "ljmp") or m.startswith("j") or m.startswith("loop"):
116
+ target, provenance = call_target(image, at, ins)
117
+ edges.append({"site": at, "target": target, "kind": m, "provenance": provenance})
118
+ if target is None:
119
+ gaps.append({"site": at, "reason": provenance.get("reason", "target outside declared regions")})
120
+ else:
121
+ pending.append(target)
122
+ following_sites.append(target)
123
+ if m in ("jmp", "ljmp"):
124
+ continue
125
+ if m in INTERRUPTS or m == "hlt" or m in PORTS:
126
+ gaps.append({"site": at, "reason": "hardware or interrupt boundary"})
127
+ continue
128
+ if m in ("call", "lcall") and following not in following_sites:
129
+ returns.add((at, following))
130
+ pending.append(following)
131
+ following_sites.append(following)
132
+ # An entry into another instruction is not a verified boundary. Retain both
133
+ # interpretations as gaps rather than choosing whichever was visited first.
134
+ active, conflicts, pairs = [], set(), []
135
+ for start, end in sorted((at, at + ins.size) for at, ins in seen.items()):
136
+ active = [(a, b) for a, b in active if b > start]
137
+ for a, b in active:
138
+ conflicts.update((a, start))
139
+ pairs.append((a, start))
140
+ active.append((start, end))
141
+ # A start is verified when it overlaps nothing, or when a verified instruction
142
+ # reaches it by a direct edge or by falling through (other than a call's return
143
+ # site). Each proving step must be reachable from the entries without passing
144
+ # through the start it proves, so raw candidates and conflicting declared
145
+ # entries never prove themselves. Rejected starts are excluded and the proof
146
+ # repeated until nothing changes.
147
+ rejected, reach = set(), {}
148
+
149
+ def reachable(inner=None):
150
+ # Instructions reachable from the entries without passing through inner or a rejected start.
151
+ if inner not in reach:
152
+ stack, visited = [x for x in entries if x != inner and x not in rejected], set()
153
+ while stack:
154
+ at = stack.pop()
155
+ if at in visited or at == inner or at in rejected or at not in successors:
156
+ continue
157
+ visited.add(at)
158
+ stack.extend(successors[at])
159
+ reach[inner] = visited
160
+ return reach[inner]
161
+
162
+ def independent(site, inner):
163
+ return site in reachable(inner)
164
+
165
+ while True:
166
+ reach.clear()
167
+ verified = {at for at in seen if at not in conflicts and at not in rejected}
168
+ frontier = list(verified)
169
+ while frontier:
170
+ at = frontier.pop()
171
+ for target in successors[at]:
172
+ if ((at, target) not in returns and (at, target) not in supplied_edges
173
+ and target in seen and target not in verified
174
+ and target not in rejected and independent(at, target)):
175
+ verified.add(target)
176
+ frontier.append(target)
177
+ unresolved = {at for pair in pairs if pair[1] not in verified for at in pair}
178
+ if unresolved <= rejected:
179
+ break
180
+ rejected |= unresolved
181
+ # Only what the accepted starts reach is established. An instruction reached only
182
+ # through a rejected start leaves seen with it, so no report confirms what the proof
183
+ # above refused to count; it is returned as contested instead of being lost.
184
+ # The reach cache still holds the final rejected set, as the last pass added nothing.
185
+ established = reachable()
186
+ contested = {}
187
+ for at in sorted(seen):
188
+ if at in established:
189
+ continue
190
+ if at not in unresolved:
191
+ contested[at] = seen[at]
192
+ del seen[at]
193
+ # Mark each direct edge from a surviving site that proves a surviving overlapping start.
194
+ overlapping = {inner for _, inner in pairs} - unresolved
195
+ # Supplied table edges never prove a boundary, even to a start another edge proves.
196
+ for e in edges:
197
+ if (e["target"] in overlapping and e["site"] in seen and e["site"] in verified
198
+ and (e["site"], e["target"]) not in supplied_edges and independent(e["site"], e["target"])):
199
+ e["overlappingTarget"] = True
200
+ e["boundaryEvidence"] = "direct edge from an independently verified instruction"
201
+ for at in sorted(unresolved):
202
+ gaps.append({"site": at, "reason": OVERLAP_REASON})
203
+ intervals = sorted((at, at + ins.size) for at, ins in seen.items())
204
+ undecoded = []
205
+ for r in image.regions:
206
+ inside = [(start, end) for start, end in intervals if r["start"] <= start < r["end"]]
207
+ undecoded.extend({**hole, "region": r["name"]} for hole in uncovered(r["start"], r["end"], inside))
208
+ return seen, gaps, edges, undecoded, contested
209
+
210
+
211
+ def uncovered(start, end, spans):
212
+ """The ranges of start..end that no span covers; spans are clipped to the bounds."""
213
+ missing, cursor = [], start
214
+ for a, b in sorted(spans):
215
+ a, b = max(a, start), min(b, end)
216
+ if a >= b:
217
+ continue
218
+ if a > cursor:
219
+ missing.append({"start": cursor, "end": a})
220
+ cursor = max(cursor, b)
221
+ if cursor < end:
222
+ missing.append({"start": cursor, "end": end})
223
+ return missing
224
+
225
+
226
+ def snapshot(state):
227
+ return {name: state.reg(name).report() for name in ALIASES}
228
+
229
+
230
+ def trace(image, config):
231
+ entry = integer(config.get("entry"), 0, len(image.data) - 1, "entry")
232
+ if not any(entry in r["entries"] for r in image.regions):
233
+ raise ValueError("Trace entry must be an established region entry")
234
+ max_steps = integer(config.get("maxSteps", 512), 1, 10000, "maxSteps")
235
+ max_paths = integer(config.get("maxPaths", 64), 1, 256, "maxPaths")
236
+ max_depth = integer(config.get("maxDepth", 8), 1, 32, "maxDepth")
237
+ integer(config.get("returnBytes", image.bits // 8), 2, 4, "returnBytes")
238
+ if config.get("returnBytes", image.bits // 8) not in ((4,) if image.flat else (2, 4)):
239
+ raise ValueError("returnBytes must agree with the selected near/far frame model")
240
+ models = config.get("callModels", [])
241
+ if not isinstance(models, list) or len(models) > 64:
242
+ raise ValueError("At most 64 explicit call models")
243
+ sites = set()
244
+ for model in models:
245
+ integer(model.get("site"), 0, len(image.data) - 1, "model site")
246
+ if model["site"] in sites or not model.get("evidence"):
247
+ raise ValueError("Call model requires a unique site and evidence")
248
+ sites.add(model["site"])
249
+ cases = model.get("cases", [])
250
+ if not isinstance(cases, list) or not 1 <= len(cases) <= 16:
251
+ raise ValueError("A model requires 1..16 return cases")
252
+ if "returnBytes" in model and (type(model["returnBytes"]) is not int or model["returnBytes"] not in (2, 4)):
253
+ raise ValueError("Modeled returnBytes must be 2 or 4")
254
+ if any(r not in REGISTERS for r in model.get("preserves", [])):
255
+ raise ValueError("Model preserves must name full registers")
256
+ for case in cases:
257
+ for r, n in case.get("registers", {}).items():
258
+ if r not in ALIASES or type(n) is not int or not 0 <= n < 1 << ALIASES[r][2]:
259
+ raise ValueError("Invalid model register")
260
+ pending, outputs, global_gaps = [State(entry, image, config)], [], []
261
+ created = 1
262
+ total_steps = 0
263
+ total_string_steps = 0
264
+ string_limit = integer(config.get("stringIterations", 4096), 0, 65536, "string iteration budget")
265
+ total_limit = integer(config.get("totalSteps", 20000), 1, 100000, "totalSteps")
266
+ checkpoints = set(config.get("checkpoints", []))
267
+ # How often one path may pass the same instruction; a loop with a known bound needs it raised.
268
+ visit_limit = integer(config.get("visitLimit", 4), 1, 4096, "visitLimit")
269
+
270
+ def string_step(s, ins, count):
271
+ # Reserve iterations only when they fit, so a rejected request never drains the shared budget.
272
+ nonlocal total_string_steps
273
+ remaining = string_limit - total_string_steps
274
+ if count.number is not None and count.number <= remaining:
275
+ total_string_steps += count.number
276
+ string_effect(s, ins, count, remaining)
277
+
278
+ def finish(s, reason=None, returned=False):
279
+ outputs.append({"returned": returned, "stop": reason, "stopSite": None if returned else s.at, "steps": s.steps,
280
+ "instructionPath": s.path, "guards": s.guards, "events": s.events,
281
+ "registers": snapshot(s), "conditionalModels": s.conditional})
282
+
283
+ while pending:
284
+ state = pending.pop()
285
+ try:
286
+ while True:
287
+ if state.steps >= max_steps:
288
+ raise StopPath("step limit; loop progress unresolved")
289
+ if total_steps >= total_limit:
290
+ raise StopPath("total instruction budget exhausted")
291
+ total_steps += 1
292
+ at = state.at
293
+ ins = image.decode(at)
294
+ if ins is None:
295
+ raise StopPath("undecoded or unmapped instruction")
296
+ state.steps += 1
297
+ state.path.append(at)
298
+ state.visits[at] = state.visits.get(at, 0) + 1
299
+ if state.visits[at] > visit_limit:
300
+ raise StopPath(f"instruction repeated more than {visit_limit} times; raise visitLimit or read the loop's bound")
301
+ if at in checkpoints:
302
+ state.event("checkpoint", registers=snapshot(state))
303
+ m, following = ins.mnemonic, at + ins.size
304
+ is_string = string_instruction(ins)
305
+ if (0xf2 in ins.prefix or 0xf3 in ins.prefix) and not is_string:
306
+ raise StopPath("repeat prefix requires a separate bounded string-operation reading")
307
+ if 0x66 in ins.prefix and (m in ("call", "lcall", "ret", "retf", "jmp", "ljmp") or m.startswith(("j", "loop"))):
308
+ raise StopPath("Operand-size control transfer override is outside the selected frame model")
309
+ if image.flat and m in ("lcall", "ljmp"):
310
+ raise StopPath("Far transfer is outside the PE32 flat model")
311
+ if is_string:
312
+ # Unsupported forms stop once, before any direction split.
313
+ check_string_form(state, ins)
314
+ count = string_count(state, ins)
315
+ direction = state.direction_flag
316
+ if count.number and direction.number is None and count.number <= string_limit - total_string_steps:
317
+ key = repr(("direction", direction.term))
318
+ if key in state.assumptions:
319
+ state.direction_flag = const(state.assumptions[key], 1, at)
320
+ state.event("flag-assumption", flag="DF", value=state.direction_flag.report(), producer=direction.report(),
321
+ evidence="conditional outcome of one unresolved direction producer")
322
+ else:
323
+ # Like a branch, the last case reuses this state, so a path limit never drops it.
324
+ for choice in (0, 1):
325
+ if choice == 0:
326
+ if created >= max_paths:
327
+ global_gaps.append({"site": at, "reason": "path limit at unknown direction flag"})
328
+ continue
329
+ child = deepcopy(state); created += 1
330
+ else:
331
+ child = state
332
+ child.assumptions[key] = choice
333
+ child.direction_flag = const(choice, 1, at)
334
+ child.event("flag-assumption", flag="DF", value=child.direction_flag.report(), producer=direction.report(),
335
+ evidence="conditional outcome of one unresolved direction producer")
336
+ if child is not state:
337
+ try:
338
+ string_step(child, ins, count)
339
+ child.at = following
340
+ pending.append(child)
341
+ except StopPath as error:
342
+ finish(child, str(error))
343
+ string_step(state, ins, count)
344
+ state.at = following
345
+ continue
346
+ if m in ("call", "lcall"):
347
+ target, provenance = call_target(image, at, ins)
348
+ indirect_value = None
349
+ if target is None and ins.operands and ins.operands[0].type in (X86_OP_REG, X86_OP_MEM):
350
+ indirect_value = state.get(ins, ins.operands[0], image)
351
+ guard_checks = []
352
+ if indirect_value is not None:
353
+ for g in state.guards:
354
+ if g.get("right", {}).get("value") == 0:
355
+ guard_checks.append({"site": g["site"], "predicate": g["predicate"], "taken": g["taken"],
356
+ "sameTargetValue": g.get("left", {}).get("expression") == indirect_value.term})
357
+ state.event("call", target=target, provenance=provenance, registers=snapshot(state),
358
+ indirectValue=indirect_value.report() if indirect_value is not None else None, guards=guard_checks)
359
+ previous = image.decode(state.path[-2]) if len(state.path) > 1 else None
360
+ # push cs + near call builds a far frame only in real mode; far transfers stop in the flat model.
361
+ push_cs = (not image.flat and m == "call" and previous is not None and previous.mnemonic == "push"
362
+ and previous.size + state.path[-2] == at and previous.operands[0].type == X86_OP_REG
363
+ and previous.reg_name(previous.operands[0].reg) == "cs" and previous.operands[0].size == 2)
364
+ model = next((x for x in models if x["site"] == at), None)
365
+ if model:
366
+ return_bytes = model.get("returnBytes", 4 if m == "lcall" else image.bits // 8)
367
+ # The encoding before the call decides validity; a path that reaches the call
368
+ # without executing that push only stops, it does not invalidate the model.
369
+ encoded_push_cs = at > 0 and image.region(at - 1) is image.region(at) and image.data[at - 1] == 0x0E
370
+ if (return_bytes != 4 if image.flat else
371
+ (m == "lcall" and return_bytes != 4) or (m == "call" and return_bytes == 4 and not encoded_push_cs)):
372
+ raise ValueError("Modeled return width differs from the encoded call frame")
373
+ if not image.flat and return_bytes == 4 and m == "call" and not push_cs:
374
+ raise StopPath("four-byte call model reached without an immediately executed push cs")
375
+ if push_cs and return_bytes != 4:
376
+ raise StopPath("push-CS/near-call model requires an explicit four-byte return contract")
377
+ if push_cs:
378
+ actual_cs = state.pop(2)
379
+ if actual_cs.term != state.reg("cs").term:
380
+ raise StopPath("modeled far return segment changed")
381
+ for case in model["cases"]:
382
+ if created >= max_paths:
383
+ global_gaps.append({"site": at, "reason": "path limit at modeled call"})
384
+ break
385
+ child = deepcopy(state)
386
+ created += 1
387
+ for r in REGISTERS:
388
+ if r not in model.get("preserves", []) and r not in ("esp", "cs"):
389
+ child.regs[r] = unknown(f"modeled-call:{at}:{r}", ALIASES[r][2], at)
390
+ child.clear_memory()
391
+ child.forget_flags()
392
+ child.direction_flag = unknown(f"modeled-call:{at}:DF:{child.flag_serial}", 1, at)
393
+ child.interrupt_flag = unknown(f"modeled-call:{at}:IF:{child.flag_serial}", 1, at)
394
+ for r, n in case.get("registers", {}).items():
395
+ child.setreg(r, const(n, ALIASES[r][2], at), at)
396
+ child.conditional.append({"site": at, "evidence": model["evidence"],
397
+ "assumption": "call returns with balanced stack; memory effects unresolved"})
398
+ child.event("call-return", callSite=at, registers=snapshot(child), modeled=True,
399
+ unknownMemoryEffects=True)
400
+ child.at = following
401
+ pending.append(child)
402
+ break
403
+ if target is None:
404
+ raise StopPath("unresolved call: " + provenance.get("reason", "outside mapped code"))
405
+ if len(state.frames) >= max_depth:
406
+ raise StopPath("call depth limit; recursion or callee remains unresolved")
407
+ target_region = image.region(target)
408
+ if target_region is None:
409
+ raise StopPath("call target outside declared code regions")
410
+ here = image.region(at)
411
+ return_ip = (here["ip"] + following - here["start"]) & image.mask
412
+ if m == "lcall":
413
+ state.push(state.reg("cs"))
414
+ state.push(const(return_ip, image.bits, at))
415
+ flags_frame = False
416
+ if push_cs:
417
+ # Inspect the word above CS without reporting a read the program never made.
418
+ try:
419
+ flags_word = state.peek(state.segment("ss"), op("add", state.reg(state.sp), const(4, 16), at), 2)
420
+ flags_frame = (16, flags_word.term) in state.saved_flags
421
+ except StopPath:
422
+ flags_frame = False
423
+ state.frames.append({"entry": target, "sp": state.reg(state.sp), "returnBytes": 4 if m == "lcall" or push_cs else image.bits // 8,
424
+ "frameSource": "push-CS/near-call; matching far return required" if push_cs else m,
425
+ "continuation": following, "returnIP": return_ip, "callSite": at,
426
+ "callerCS": state.reg("cs"), "localFlagsFrame": flags_frame})
427
+ if m == "lcall":
428
+ state.setreg("cs", const(target_region["segment"], 16, at), at)
429
+ state.at = target
430
+ continue
431
+ if m in ("iret", "iretd"):
432
+ if image.flat or 0x66 in ins.prefix or m != "iret":
433
+ raise StopPath("IRET requires an unprefixed segmented16 local frame")
434
+ frame = state.frames[-1]
435
+ if len(state.frames) == 1 or not frame.get("localFlagsFrame"):
436
+ raise StopPath("IRET requires a traced local push-CS call above saved FLAGS")
437
+ if state.reg(state.sp).term != frame["sp"].term:
438
+ raise StopPath("IRET stack balance differs from the local call")
439
+ actual_ip, actual_cs = state.pop(2), state.pop(2)
440
+ if actual_ip.number != frame["returnIP"] or actual_cs.term != frame["callerCS"].term:
441
+ raise StopPath("IRET return target or segment was overwritten or unresolved")
442
+ state.setreg("cs", actual_cs, at)
443
+ state.restore_flags(16)
444
+ state.event("local-iret", continuation=frame["continuation"],
445
+ interpretation="local stack/flags transfer only; no interrupt or hardware simulation")
446
+ state.frames.pop()
447
+ state.event("call-return", callSite=frame["callSite"], registers=snapshot(state), modeled=False)
448
+ state.at = frame["continuation"]
449
+ continue
450
+ if m in ("ret", "retf"):
451
+ if image.flat and m == "retf":
452
+ raise StopPath("Far return is outside the PE32 flat model")
453
+ frame = state.frames[-1]
454
+ roles = []
455
+ for contract in config.get("returnContracts", []):
456
+ if contract.get("entry") != frame["entry"]:
457
+ continue
458
+ register = contract.get("register")
459
+ if register not in ALIASES or not contract.get("evidence"):
460
+ raise ValueError("Return contract requires register and evidence")
461
+ value = state.reg(register)
462
+ failures = contract.get("failures", [])
463
+ if not isinstance(failures, list) or len(failures) > 256 or any(type(n) is not int or not 0 <= n < 1 << value.bits for n in failures):
464
+ raise ValueError("Failure encodings must fit the consumed return width")
465
+ roles.append({"register": register, "value": value.report(), "failureEncodings": failures,
466
+ "matchesFailureEncoding": None if value.number is None else value.number in failures,
467
+ "evidence": contract["evidence"]})
468
+ state.event("return", registers=snapshot(state), cleanupBytes=ins.operands[0].imm if ins.operands else 0, resultContracts=roles)
469
+ # The entry frame gets the same width and balance checks as a traced call.
470
+ expected = 4 if m == "retf" else image.bits // 8
471
+ if expected != frame["returnBytes"] or state.reg(state.sp).term != frame["sp"].term:
472
+ raise StopPath("return frame or stack balance differs from the call")
473
+ if len(state.frames) == 1:
474
+ finish(state, returned=True)
475
+ break
476
+ actual_ip = state.pop(image.bits // 8)
477
+ if actual_ip.number != frame["returnIP"]:
478
+ raise StopPath("return target was overwritten or has unknown provenance")
479
+ if m == "retf":
480
+ actual_cs = state.pop(2)
481
+ if actual_cs.term != frame["callerCS"].term:
482
+ raise StopPath("far return segment changed")
483
+ state.setreg("cs", actual_cs, at)
484
+ if ins.operands:
485
+ state.setreg(state.sp, op("add", state.reg(state.sp), const(ins.operands[0].imm, image.bits), at), at)
486
+ state.frames.pop()
487
+ state.event("call-return", callSite=frame["callSite"], registers=snapshot(state), modeled=False)
488
+ state.at = frame["continuation"]
489
+ continue
490
+ if m in ("jmp", "ljmp"):
491
+ target, provenance = call_target(image, at, ins)
492
+ if target is None:
493
+ raise StopPath("unresolved jump: " + provenance.get("reason", "outside mapped code"))
494
+ if m == "ljmp":
495
+ target_region = image.region(target)
496
+ if target_region is None:
497
+ raise StopPath("jump target outside declared code regions")
498
+ state.setreg("cs", const(target_region["segment"], 16, at), at)
499
+ state.at = target
500
+ continue
501
+ if m.startswith("j") or m.startswith("loop"):
502
+ target, _ = call_target(image, at, ins)
503
+ if m in ("jcxz", "jecxz") or m.startswith("loop"):
504
+ answer, info, key = counter_branch(state, ins)
505
+ negated = False
506
+ else:
507
+ answer, info = predicate(state, m)
508
+ condition, negated = BRANCH_CONDITIONS.get(m, (m, False))
509
+ if condition == "c":
510
+ # CF can outlive its producer (INC/DEC, CLC/STC, shifts), so key it by its own value.
511
+ key = repr((condition, state.carry_value().term))
512
+ else:
513
+ key = repr((condition, state.flags if state.flags is not None else ("unresolved", state.flag_epoch)))
514
+ if answer is None and key in state.assumptions:
515
+ answer = state.assumptions[key] != negated
516
+ choices = [answer] if answer is not None else [False, True]
517
+ branches = []
518
+ for index, taken in enumerate(choices):
519
+ # The last choice reuses this state; earlier ones copy it before it changes.
520
+ child = state if index == len(choices) - 1 else deepcopy(state)
521
+ guard = {"site": at, "taken": taken, **info}
522
+ child.guards.append(guard)
523
+ child.event("branch", **{k: v for k, v in guard.items() if k != "site"})
524
+ child.assumptions[key] = taken != negated
525
+ child.at = target if taken else following
526
+ if child.at is None:
527
+ finish(child, "branch target outside mapped code")
528
+ else:
529
+ branches.append(child)
530
+ if not branches:
531
+ break
532
+ state = branches.pop()
533
+ for child in branches:
534
+ if created >= max_paths:
535
+ global_gaps.append({"site": at, "reason": "path limit"})
536
+ else:
537
+ pending.append(child)
538
+ created += 1
539
+ continue
540
+ ordinary(state, ins, image)
541
+ state.at = following
542
+ except StopPath as error:
543
+ finish(state, str(error))
544
+ return {"paths": outputs, "gaps": global_gaps,
545
+ "completeWithinModel": not global_gaps and bool(outputs) and all(p["returned"] for p in outputs),
546
+ "nativeReachability": "unconfirmed", "stepsUsed": total_steps, "stringIterationsUsed": total_string_steps,
547
+ "limits": {"steps": max_steps, "paths": max_paths, "depth": max_depth}}