memlapse 0.1.1__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.
memlapse/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """memlapse, a Windows memory forensics tool.
2
+
3
+ Process Explorer / System Informer-style monitoring with recording and
4
+ playback of a process's memory map over time, and a per-region injection
5
+ score. Per-thread attribution via ETW is planned (see docs/ARCHITECTURE.md).
6
+ """
7
+
8
+ __version__ = "0.1.1"
memlapse/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run memlapse with ``python -m memlapse`` (add --elevate for admin)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
memlapse/analytics.py ADDED
@@ -0,0 +1,633 @@
1
+ """Dependency-free analysis helpers: dashboard statistics and region scoring.
2
+
3
+ Pure Python (no numpy) to match the project's minimal-dependency model layer,
4
+ and unit-testable without Qt. The first half is small: a ring buffer for time
5
+ series, a least-squares slope for leak detection, a z-score for anomaly
6
+ spikes, and a top-movers diff, all for the dashboard's interpret strip. The
7
+ second half, from "in-memory injection heuristics" below, scores a region for
8
+ signs of injected code and is shared by the live region view and playback.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import math
15
+ from bisect import bisect_right
16
+ from collections import Counter, deque
17
+ from dataclasses import dataclass
18
+ from typing import Collection, Sequence
19
+
20
+ from .model.region import (
21
+ MEM_COMMIT,
22
+ MEM_IMAGE,
23
+ MEM_MAPPED,
24
+ MEM_PRIVATE,
25
+ PAGE_EXECUTE,
26
+ PAGE_EXECUTE_READ,
27
+ PAGE_EXECUTE_READWRITE,
28
+ PAGE_EXECUTE_WRITECOPY,
29
+ PAGE_GUARD,
30
+ Region,
31
+ )
32
+
33
+
34
+ class SeriesBuffer:
35
+ """Fixed-capacity ring buffer of ``(ts_us, value)`` points."""
36
+
37
+ def __init__(self, capacity: int) -> None:
38
+ if capacity < 1:
39
+ raise ValueError("capacity must be >= 1")
40
+ self._pts: deque[tuple[int, float]] = deque(maxlen=capacity)
41
+
42
+ def append(self, ts_us: int, value: float) -> None:
43
+ self._pts.append((int(ts_us), float(value)))
44
+
45
+ def __len__(self) -> int:
46
+ return len(self._pts)
47
+
48
+ def times(self) -> list[int]:
49
+ return [t for t, _ in self._pts]
50
+
51
+ def values(self) -> list[float]:
52
+ return [v for _, v in self._pts]
53
+
54
+ def latest(self) -> float | None:
55
+ return self._pts[-1][1] if self._pts else None
56
+
57
+
58
+ def linreg_slope(xs: Sequence[float], ys: Sequence[float]) -> float:
59
+ """Least-squares slope ``dy/dx``.
60
+
61
+ Returns 0.0 for fewer than two points, mismatched lengths, or when ``xs``
62
+ has no spread (vertical, undefined slope).
63
+ """
64
+ n = len(xs)
65
+ if n < 2 or n != len(ys):
66
+ return 0.0
67
+ mean_x = sum(xs) / n
68
+ mean_y = sum(ys) / n
69
+ denom = sum((x - mean_x) ** 2 for x in xs)
70
+ if denom == 0.0:
71
+ return 0.0
72
+ num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
73
+ return num / denom
74
+
75
+
76
+ def leak_rate_bytes_per_sec(
77
+ times_us: Sequence[int], used_bytes: Sequence[float]
78
+ ) -> float:
79
+ """Growth rate of used bytes in **bytes/second** over the given window."""
80
+ if len(times_us) < 2:
81
+ return 0.0
82
+ t0 = times_us[0]
83
+ xs = [(t - t0) / 1_000_000 for t in times_us] # seconds
84
+ return linreg_slope(xs, used_bytes)
85
+
86
+
87
+ def zscore(values: Sequence[float], latest: float | None = None) -> float:
88
+ """Z-score of ``latest`` (default: the last value) vs sample mean/stdev.
89
+
90
+ Returns 0.0 for fewer than two points or zero variance.
91
+ """
92
+ n = len(values)
93
+ if n < 2:
94
+ return 0.0
95
+ x = values[-1] if latest is None else latest
96
+ mean = sum(values) / n
97
+ var = sum((v - mean) ** 2 for v in values) / n
98
+ if var <= 0.0:
99
+ return 0.0
100
+ return (x - mean) / (var ** 0.5)
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class WindowStats:
105
+ count: int
106
+ minimum: float
107
+ maximum: float
108
+ average: float
109
+
110
+
111
+ def window_stats(values: Sequence[float]) -> WindowStats:
112
+ """min / max / mean over a value window (all zeros for an empty window)."""
113
+ n = len(values)
114
+ if n == 0:
115
+ return WindowStats(0, 0.0, 0.0, 0.0)
116
+ return WindowStats(n, min(values), max(values), sum(values) / n)
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class Mover:
121
+ pid: int
122
+ name: str
123
+ delta_bytes: int
124
+
125
+
126
+ def top_movers(
127
+ prev: dict[int, tuple[str, int]],
128
+ curr: dict[int, tuple[str, int]],
129
+ n: int = 5,
130
+ ) -> list[Mover]:
131
+ """Processes whose working set changed most since the previous snapshot.
132
+
133
+ ``prev``/``curr`` map ``pid -> (name, wset_bytes)``. Only pids present in
134
+ both are considered; result is sorted by absolute delta, descending.
135
+ """
136
+ movers: list[Mover] = []
137
+ for pid, (name, cur_ws) in curr.items():
138
+ if pid in prev:
139
+ delta = cur_ws - prev[pid][1]
140
+ if delta != 0:
141
+ movers.append(Mover(pid, name, delta))
142
+ movers.sort(key=lambda m: abs(m.delta_bytes), reverse=True)
143
+ return movers[:n]
144
+
145
+
146
+ # --- in-memory injection heuristics ----------------------------------------
147
+ # Structural, thread, content and temporal signals for code-injection
148
+ # detection, in the spirit of Volatility's malfind and the "unbacked
149
+ # executable memory" indicator EDRs use. Everything here is a pure function of
150
+ # a Region plus optional bytes, so it runs against live samples *and* replayed
151
+ # recordings, and unit-tests without Win32.
152
+
153
+ #: The protection bits that grant execute. Public because a query that
154
+ #: pre-filters rows for the content detector asks the same question in SQL
155
+ #: (see ``Dao.region_samples``), and two spellings of "executable" would
156
+ #: drift the first time a constant was added to one of them.
157
+ EXEC_MASK = (
158
+ PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
159
+ )
160
+ _WRITE_EXEC = PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
161
+
162
+ #: bits/byte above which a buffer looks packed or encrypted (max is 8.0).
163
+ ENTROPY_PACKED = 7.2
164
+ #: bits/byte at or below which a buffer looks like plain code rather than a
165
+ #: packed payload. Compiled x86 sits well under this; the gap between it and
166
+ #: ENTROPY_PACKED is deliberate, so a small wobble is not a decryption.
167
+ ENTROPY_CODE_MAX = 6.5
168
+ #: minimum run of 0x90 bytes to count as a shellcode NOP sled.
169
+ NOP_SLED_MIN = 16
170
+ #: points for a region whose head fell from packed entropy to code-like
171
+ #: entropy between two looks: a payload that decrypted itself in place.
172
+ UNPACKED_POINTS = 20
173
+
174
+ #: points for a committed, executable region that is not image-backed and
175
+ #: that a thread starts in. Every legitimate thread starts inside a mapped
176
+ #: image, so a start anywhere else is the shellcode-with-a-thread case.
177
+ THREAD_START_POINTS = 25
178
+
179
+ #: Score at or above which a region is worth a second look, and the points
180
+ #: floor for the band worth acting on. The floor is necessary and not
181
+ #: sufficient: reaching :data:`LIKELY_SCORE` earns the top band only with a
182
+ #: point from outside :data:`MAP_SHAPE_RULES` as well, and 50 + 25 on private
183
+ #: RWX is exactly the case that does not qualify. See
184
+ #: :attr:`RegionVerdict.band`, which is the only thing that decides a band.
185
+ #: The lower edge is deliberately low, because a signal that scores 30 and is
186
+ #: never shown as anything but a number is a signal nobody triages.
187
+ REVIEW_SCORE = 30
188
+ LIKELY_SCORE = 75
189
+
190
+ #: MITRE ATT&CK technique each reason maps to, appended to the reason string
191
+ #: so the tooltip names the technique as ATT&CK does, and any later export of
192
+ #: the same finding will too. Signals with no honest mapping carry none.
193
+ ATTACK_INJECTION = "T1055" # Process Injection
194
+ ATTACK_REFLECTIVE = "T1620" # Reflective Code Loading
195
+ ATTACK_PACKING = "T1027.002" # Obfuscated Files or Information: Software Packing
196
+
197
+ #: points for an executable region whose head bytes changed between two looks
198
+ #: while its protection and size did not (see :func:`rewritten_regions`).
199
+ REWRITTEN_POINTS = 15
200
+ #: the same, for an image-backed region. Legitimate code is not rewritten in
201
+ #: place; an inline hook or module stomping is, so this carries more weight.
202
+ IMAGE_REWRITTEN_POINTS = 40
203
+
204
+ #: Stable identifier for each scoring rule. An allowlist entry names one of
205
+ #: these to exempt a process from that rule and no other, and they will key
206
+ #: rows in a recording, so treat them as schema: a shipped id is never
207
+ #: renamed. The prose beside them can be reworded freely; the id cannot.
208
+ RULE_PRIVATE_EXEC = "private-exec"
209
+ RULE_MAPPED_EXEC = "mapped-exec"
210
+ RULE_RWX = "rwx"
211
+ RULE_THREAD_START = "thread-start"
212
+ RULE_PE_HEADER = "pe-header"
213
+ RULE_NOP_SLED = "nop-sled"
214
+ RULE_HIGH_ENTROPY = "high-entropy"
215
+ RULE_UNPACKED = "unpacked"
216
+ RULE_REWRITTEN = "rewritten"
217
+ RULE_IMAGE_REWRITTEN = "image-rewritten"
218
+
219
+ #: The rules a single VirtualQueryEx answers on its own, with no read, no
220
+ #: thread query and no second look in time. They describe the shape of the
221
+ #: map and nothing about what is in the memory or what it did, which is why
222
+ #: they cannot carry a region into the top band by themselves: see
223
+ #: :attr:`RegionVerdict.band`.
224
+ MAP_SHAPE_RULES = frozenset({RULE_PRIVATE_EXEC, RULE_MAPPED_EXEC, RULE_RWX})
225
+
226
+ #: Band for a region that scored only on rules an allowlist entry excused.
227
+ #: Named rather than spelled out at each use, since the UI switches on it.
228
+ ALLOWLISTED = "allowlisted"
229
+
230
+
231
+ @dataclass(frozen=True, slots=True)
232
+ class Reason:
233
+ """One scoring rule that fired, with what it contributed.
234
+
235
+ ``text`` is the sentence an analyst reads. ``rule`` is the identifier
236
+ an allowlist entry names, and ``points`` is what the rule added, which
237
+ is what lets a suppressed rule be subtracted without scoring twice.
238
+ """
239
+
240
+ rule: str
241
+ text: str
242
+ points: int
243
+ #: an allowlist entry named this rule for this process, so it still
244
+ #: fired and still shows, but it does not count towards the band
245
+ allowed: bool = False
246
+
247
+
248
+ @dataclass(frozen=True, slots=True)
249
+ class AllowlistEntry:
250
+ """One exemption: a process, the single rule it excuses, and why.
251
+
252
+ ``note`` is the analyst's reason for the entry. It is not decoration:
253
+ an exemption nobody can justify later is one nobody dares delete.
254
+ """
255
+
256
+ image_name: str
257
+ rule: str
258
+ note: str = ""
259
+
260
+
261
+ class Allowlist:
262
+ """Which rules are exempted for which processes.
263
+
264
+ Keyed on the process image name, which the bulk process query already
265
+ returns for every process without needing a handle and which a recording
266
+ already stores, so a replay on another machine reads the same key. The
267
+ name is matched case-insensitively, since Windows treats it that way.
268
+
269
+ An image path plus its publisher would be a stronger key: a name alone
270
+ excuses anything that adopts it, which is a real evasion and the reason
271
+ this is a triage aid rather than a control. That upgrade is the intended
272
+ next step. A pid is never a key, since Windows reuses those in minutes.
273
+ """
274
+
275
+ def __init__(self, entries: Collection[AllowlistEntry] = ()) -> None:
276
+ #: The entries as given, kept so a caller can show what was excused
277
+ #: and on whose say-so rather than applying it silently. The lookup
278
+ #: below throws the notes away, and an allowlist nobody can read back
279
+ #: is the kind that quietly hides a finding.
280
+ self.entries = tuple(entries)
281
+ self._by_image: dict[str, frozenset[str]] = {}
282
+ for entry in self.entries:
283
+ key = entry.image_name.casefold()
284
+ self._by_image[key] = self._by_image.get(
285
+ key, frozenset()) | {entry.rule}
286
+
287
+ def rules_for(self, image_name: str) -> frozenset[str]:
288
+ """The rule ids exempted for this process, empty when none are."""
289
+ return self._by_image.get(image_name.casefold(), frozenset())
290
+
291
+ def __bool__(self) -> bool:
292
+ return bool(self._by_image)
293
+
294
+
295
+ def is_executable(protect: int) -> bool:
296
+ """True if ``protect`` grants execute and the page is not a guard page."""
297
+ return bool(protect & EXEC_MASK) and not (protect & PAGE_GUARD)
298
+
299
+
300
+ def shannon_entropy(data: bytes) -> float:
301
+ """Shannon entropy in bits/byte (0.0..8.0); 0.0 for empty input.
302
+
303
+ High values (see :data:`ENTROPY_PACKED`) suggest packed or encrypted
304
+ payloads rather than plain code or data.
305
+ """
306
+ if not data:
307
+ return 0.0
308
+ n = len(data)
309
+ return -sum((c / n) * math.log2(c / n) for c in Counter(data).values())
310
+
311
+
312
+ def head_hash(data: bytes) -> bytes:
313
+ """SHA-256 digest of a captured region head, the key it is stored under.
314
+
315
+ Thirty-two bytes that identify the content exactly, so equal heads share
316
+ one row and a changed head is a changed hash.
317
+ """
318
+ return hashlib.sha256(data).digest()
319
+
320
+
321
+ def longest_nop_run(data: bytes) -> int:
322
+ """Length of the longest run of ``0x90`` bytes (shellcode NOP sled)."""
323
+ best = run = 0
324
+ for b in data:
325
+ run = run + 1 if b == 0x90 else 0
326
+ if run > best:
327
+ best = run
328
+ return best
329
+
330
+
331
+ @dataclass(frozen=True, slots=True)
332
+ class RegionVerdict:
333
+ """Suspicion score (0..100) and human-readable reasons for one region.
334
+
335
+ ``score`` is the sum of its reasons' points, capped. Keeping that true
336
+ is what lets an allowlisted rule be subtracted from the band without a
337
+ second set of books, so a verdict built by hand should honour it.
338
+ """
339
+
340
+ base_addr: int
341
+ size: int
342
+ score: int
343
+ reasons: tuple[Reason, ...]
344
+
345
+ @property
346
+ def suspicious(self) -> bool:
347
+ return self.score > 0
348
+
349
+ @property
350
+ def effective_score(self) -> int:
351
+ """The score with the allowlisted rules taken out.
352
+
353
+ This is what bands the region. :attr:`score` stays raw so the table
354
+ still shows what the heuristics said, which is the one thing an
355
+ analyst reviewing a false positive needs to see. Nothing is
356
+ recomputed or discarded, so deleting an allowlist entry restores
357
+ the original verdict on the spot.
358
+ """
359
+ return min(sum(r.points for r in self.reasons if not r.allowed), 100)
360
+
361
+ @property
362
+ def map_shape_only(self) -> bool:
363
+ """Every rule still counting came from the memory map alone.
364
+
365
+ This is about what counts, not about what was observed. A content or
366
+ temporal rule that fired and was then excused by an allowlist entry
367
+ leaves the region map-shape-only just as surely as one that never
368
+ fired, because the band follows the points that are left. Read it as
369
+ "nothing outside the map is still counting", never as "nothing
370
+ outside the map was found". See :data:`MAP_SHAPE_RULES`.
371
+
372
+ It also cannot say why a rule stayed silent, and the reasons are not
373
+ equivalent. A head that was read and matched nothing is evidence; a
374
+ head that could not be read is the absence of it. Where no bytes are
375
+ available at all, which is an unelevated target that denies
376
+ ``PROCESS_VM_READ`` and any recording made against one, no content or
377
+ temporal rule can fire for any region. The top band is not out of
378
+ reach even then: :data:`RULE_THREAD_START` needs no bytes, only a
379
+ thread query, so private memory with a thread starting in it still
380
+ reaches 75 without the map carrying it. What is lost is every rule
381
+ that depends on the content. The caller knows whether it got bytes,
382
+ and the region view says so on the row.
383
+ """
384
+ counting = {r.rule for r in self.reasons if not r.allowed}
385
+ return bool(counting) and counting <= MAP_SHAPE_RULES
386
+
387
+ @property
388
+ def band(self) -> str:
389
+ """Triage band: "", "low", "review", "likely injection", "allowlisted".
390
+
391
+ The empty string is for a region that scored nothing at all, which
392
+ is most of them. "low" is a region that tripped something without
393
+ reaching :data:`REVIEW_SCORE`: still shown, still tinted, but not
394
+ asking for the analyst's time. "allowlisted" is a region with no
395
+ points left once the excused rules are subtracted: the row and the
396
+ number stay, the verdict does not. Since every rule scores something,
397
+ that is the same as every rule that fired having been excused.
398
+
399
+ The top band asks for one thing more than the points. A region whose
400
+ whole case is :data:`MAP_SHAPE_RULES` stops at "review" however far
401
+ it clears :data:`LIKELY_SCORE`, because the map alone cannot tell a
402
+ JIT arena from a payload: both are private, both are executable, and
403
+ a great many of the first exist on an ordinary machine. Reaching
404
+ "likely injection" takes a signal from somewhere else: bytes that
405
+ matched a content rule, a thread found starting in the region, or a
406
+ change between two looks at it. Measured on this machine on
407
+ 2026-09-08, across the processes whose memory could be read, that is
408
+ the whole of the difference: every region in the top band scored on
409
+ nothing but private plus RWX. Where nothing can be read every
410
+ content and temporal rule stays silent, but the thread tier asks the
411
+ thread list rather than the memory, so the top band stays reachable;
412
+ see :attr:`map_shape_only`.
413
+ """
414
+ if self.score <= 0:
415
+ return ""
416
+ effective = self.effective_score
417
+ if effective == 0:
418
+ return ALLOWLISTED
419
+ if effective >= LIKELY_SCORE and not self.map_shape_only:
420
+ return "likely injection"
421
+ if effective >= REVIEW_SCORE:
422
+ return "review"
423
+ return "low"
424
+
425
+
426
+ def score_region(region: Region, *, head: bytes = b"",
427
+ rewritten: bool = False,
428
+ thread_start: bool = False,
429
+ unpacked: bool = False,
430
+ allowed: Collection[str] = ()) -> RegionVerdict:
431
+ """Heuristic injection score for a single region.
432
+
433
+ ``head`` is the first bytes of the region (from ReadProcessMemory) when
434
+ available; pass ``b""`` to run structural checks only. ``rewritten`` says
435
+ the head changed between two looks at the region, a previous sample in
436
+ playback or a previous refresh while watching live, with the region
437
+ otherwise unchanged (see :func:`rewritten_regions`).
438
+ ``thread_start`` says a thread's Win32 start address falls inside this
439
+ region (see :func:`regions_with_thread_starts`); it only scores when the
440
+ region is not image-backed, since that is where threads normally start.
441
+ ``unpacked`` says the head's entropy fell from packed to code-like
442
+ between the same two looks (see :func:`unpacked_regions`). It stacks with
443
+ ``rewritten``, deliberately: the bytes changing is one fact and what they
444
+ changed into is another, and a private region that did both reaches 85.
445
+ ``allowed`` is the rule ids an allowlist entry exempts for the process
446
+ this region belongs to (see :class:`Allowlist`). A rule named there still
447
+ fires and still appears in the reasons, marked; it just does not count
448
+ towards :attr:`RegionVerdict.effective_score`, which is what bands the
449
+ region. Suppressing the verdict rather than the row is deliberate: a JIT
450
+ host exempted from the executable-private rule still scores on an ``MZ``
451
+ header or a NOP sled, so a stomped CLR is not hidden by its own entry.
452
+ Scores are additive and capped at 100. A non-executable or non-committed
453
+ region always scores 0.
454
+ """
455
+ if region.state != MEM_COMMIT or not is_executable(region.protect):
456
+ return RegionVerdict(region.base_addr, region.size, 0, ())
457
+
458
+ reasons: list[Reason] = []
459
+
460
+ def fired(rule: str, points: int, text: str) -> None:
461
+ reasons.append(Reason(rule, text, points, rule in allowed))
462
+
463
+ # Structural: executable memory that is not backed by an image file is the
464
+ # core injection tell (reflective loading, hollowing, raw shellcode).
465
+ if region.type == MEM_PRIVATE:
466
+ fired(RULE_PRIVATE_EXEC, 50,
467
+ f"executable private (unbacked) memory [{ATTACK_INJECTION}]")
468
+ elif region.type == MEM_MAPPED:
469
+ fired(RULE_MAPPED_EXEC, 30,
470
+ "executable mapped memory (possible module stomping) "
471
+ f"[{ATTACK_INJECTION}]")
472
+
473
+ if region.protect & _WRITE_EXEC:
474
+ fired(RULE_RWX, 25, "writable + executable (RWX)")
475
+
476
+ # Not structural, whatever its place in this function: the map does not
477
+ # answer it, and a thread executing in unbacked memory is the one
478
+ # single-snapshot signal strong enough to reach the top band on its own
479
+ # (see MAP_SHAPE_RULES).
480
+ if thread_start and region.type != MEM_IMAGE:
481
+ fired(RULE_THREAD_START, THREAD_START_POINTS,
482
+ f"a thread starts here, in memory no image backs "
483
+ f"[{ATTACK_INJECTION}]")
484
+
485
+ # Content: only meaningful when the region's head was actually read.
486
+ if head[:2] == b"MZ":
487
+ fired(RULE_PE_HEADER, 20,
488
+ f"PE header (MZ) in memory, reflective DLL [{ATTACK_REFLECTIVE}]")
489
+ if longest_nop_run(head) >= NOP_SLED_MIN:
490
+ fired(RULE_NOP_SLED, 10, "NOP sled")
491
+ if head and shannon_entropy(head) >= ENTROPY_PACKED:
492
+ fired(RULE_HIGH_ENTROPY, 10,
493
+ f"high entropy (packed/encrypted) [{ATTACK_PACKING}]")
494
+
495
+ # Temporal: the bytes changed but nothing about the region did. A loader
496
+ # that overwrites an existing executable region never allocates and never
497
+ # flips a protection, so this is the only signal it leaves. JIT engines
498
+ # rewrite private code legitimately; image code is not rewritten at all.
499
+ if unpacked:
500
+ fired(RULE_UNPACKED, UNPACKED_POINTS,
501
+ "entropy fell from packed to code-like, unpacked in place "
502
+ f"[{ATTACK_PACKING}]")
503
+
504
+ if rewritten:
505
+ if region.type == MEM_IMAGE:
506
+ fired(RULE_IMAGE_REWRITTEN, IMAGE_REWRITTEN_POINTS,
507
+ "image code rewritten in memory (inline hook or module "
508
+ f"stomping) [{ATTACK_INJECTION}]")
509
+ else:
510
+ fired(RULE_REWRITTEN, REWRITTEN_POINTS,
511
+ "executable memory rewritten in place "
512
+ f"[{ATTACK_INJECTION}]")
513
+
514
+ score = sum(r.points for r in reasons)
515
+ return RegionVerdict(
516
+ region.base_addr, region.size, min(score, 100), tuple(reasons)
517
+ )
518
+
519
+
520
+ def region_identity(region: Region) -> tuple[int, int, int, int]:
521
+ """What makes two samples show one allocation rather than two.
522
+
523
+ Base, size, protection and state together. A content comparison needs all
524
+ four to match before it will call a difference in the bytes a rewrite,
525
+ because a region that grew, changed protection, or was freed and
526
+ re-allocated at the same base is a different thing carrying whatever it
527
+ carries. Anything that files a rewrite under a region has to ask the same
528
+ question, or it will hand one allocation's history to another that merely
529
+ inherited its address. Windows reuses addresses freely, so that is
530
+ ordinary rather than exotic.
531
+ """
532
+ return (region.base_addr, region.size, region.protect, region.state)
533
+
534
+
535
+ def rewritten_regions(prev_regions: Sequence[Region],
536
+ prev_digests: dict[int, bytes],
537
+ curr_regions: Sequence[Region],
538
+ curr_digests: dict[int, bytes]) -> set[int]:
539
+ """Base addresses of executable regions rewritten between two looks.
540
+
541
+ The two looks are consecutive samples in playback and consecutive live
542
+ refreshes while watching; the comparison is the same either way. A digest
543
+ is whatever identifies a head's content: the stored SHA-256 in playback,
544
+ the head bytes themselves in live mode, where they are already in memory
545
+ for the entropy rule. Only equality is asked of it, so either works.
546
+
547
+ A region counts when it is committed and executable in both looks with
548
+ the same base, size and protection, both looks captured its head, and
549
+ the two digests differ. Anything else is not this detector's business: a
550
+ region that appeared, grew, or changed protection belongs to the
551
+ allocation and transition signals, and a head missing on either side
552
+ means the comparison cannot be made, not that the bytes changed.
553
+ """
554
+ before = {r.base_addr: r for r in prev_regions}
555
+ changed: set[int] = set()
556
+ for curr in curr_regions:
557
+ prev = before.get(curr.base_addr)
558
+ if prev is None:
559
+ continue
560
+ if region_identity(prev) != region_identity(curr):
561
+ continue
562
+ if curr.state != MEM_COMMIT or not is_executable(curr.protect):
563
+ continue
564
+ old = prev_digests.get(curr.base_addr)
565
+ new = curr_digests.get(curr.base_addr)
566
+ if old is None or new is None or old == new:
567
+ continue
568
+ changed.add(curr.base_addr)
569
+ return changed
570
+
571
+
572
+ def regions_with_thread_starts(regions: Sequence[Region],
573
+ starts) -> set[int]:
574
+ """Base addresses of the regions that a thread's start address falls in.
575
+
576
+ ``starts`` is any iterable of addresses (see
577
+ :func:`memlapse.win32.threads.start_addresses`). An address that matches no
578
+ region is ignored: the map and the thread list are read a moment apart, so
579
+ one can name memory the other has not got. Whether a hit means anything is
580
+ :func:`score_region`'s decision, not this function's.
581
+
582
+ Each address is placed by binary search rather than by scanning the map,
583
+ which matters because playback calls this on the GUI thread for every
584
+ seek and a busy process has thousands of regions and hundreds of threads.
585
+ Regions never overlap, so the last one starting at or below an address is
586
+ the only candidate. The sort is what makes that safe for any caller and
587
+ costs almost nothing for the ordered maps both sources already produce.
588
+ """
589
+ ordered = sorted(regions, key=lambda r: r.base_addr)
590
+ bases = [r.base_addr for r in ordered]
591
+ hits: set[int] = set()
592
+ for address in starts:
593
+ index = bisect_right(bases, address) - 1
594
+ if index < 0:
595
+ continue # below every region
596
+ region = ordered[index]
597
+ if address < region.base_addr + region.size:
598
+ hits.add(region.base_addr)
599
+ return hits
600
+
601
+
602
+ def unpacked_regions(prev_heads: dict[int, bytes],
603
+ curr_heads: dict[int, bytes],
604
+ changed) -> set[int]:
605
+ """Base addresses whose head fell from packed entropy to code-like entropy.
606
+
607
+ ``changed`` is the set of regions rewritten between the same two looks
608
+ (see :func:`rewritten_regions`), which is the only place this can happen:
609
+ a head whose bytes did not change cannot have changed entropy. Scanning
610
+ only those keeps the cost proportional to what moved rather than to the
611
+ size of the map, which is what makes the rule affordable once a second in
612
+ live mode. Pass the regions that changed on this look, not a set carried
613
+ over from an earlier one, or the two heads compared are the same bytes.
614
+
615
+ A payload that decrypts itself in place goes from close to eight bits per
616
+ byte to something a disassembler would recognise. The reverse, code turning
617
+ into noise, is not this signal: that is a region being overwritten with a
618
+ new packed payload, which :func:`rewritten_regions` already reports.
619
+
620
+ The two heads must be the same length to be compared at all. A head is
621
+ stored with however many bytes the read returned, so a full 256-byte
622
+ packed head followed by a short read would otherwise look like a collapse
623
+ in entropy when nothing changed but how much of the region could be read.
624
+ """
625
+ fell: set[int] = set()
626
+ for base in changed:
627
+ before, after = prev_heads.get(base), curr_heads.get(base)
628
+ if not before or not after or len(before) != len(after):
629
+ continue
630
+ if (shannon_entropy(before) >= ENTROPY_PACKED
631
+ and shannon_entropy(after) <= ENTROPY_CODE_MAX):
632
+ fell.add(base)
633
+ return fell
memlapse/app.py ADDED
@@ -0,0 +1,44 @@
1
+ """Application entry point.
2
+
3
+ Enables SeDebugPrivilege (best effort), then launches the Qt app. The process
4
+ list is complete either way; running elevated is what allows opening system
5
+ and other users' processes for memory maps and reads. Pass --elevate to
6
+ relaunch through UAC.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ from PySide6.QtWidgets import QApplication
14
+
15
+ from .ui import MainWindow
16
+ from .ui.theme import APP_QSS
17
+ from .win32 import privileges
18
+
19
+
20
+ def should_relaunch_elevated(argv: list[str]) -> bool:
21
+ """True if the user asked to elevate and we aren't already elevated."""
22
+ return "--elevate" in argv and not privileges.is_elevated()
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ argv = sys.argv if argv is None else argv
27
+
28
+ # Best-effort: enable SeDebugPrivilege if we already have the rights.
29
+ privileges.enable_se_debug_privilege()
30
+
31
+ if should_relaunch_elevated(argv) and privileges.relaunch_as_admin():
32
+ return 0 # elevated instance launched; this one exits
33
+
34
+ app = QApplication(argv)
35
+ app.setApplicationName("memlapse")
36
+ app.setStyleSheet(APP_QSS)
37
+
38
+ window = MainWindow()
39
+ window.show()
40
+ return app.exec()
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())