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 +8 -0
- memlapse/__main__.py +8 -0
- memlapse/analytics.py +633 -0
- memlapse/app.py +44 -0
- memlapse/collectors/__init__.py +5 -0
- memlapse/collectors/base.py +95 -0
- memlapse/collectors/process.py +57 -0
- memlapse/collectors/region.py +157 -0
- memlapse/collectors/system.py +34 -0
- memlapse/model/__init__.py +4 -0
- memlapse/model/process.py +36 -0
- memlapse/model/region.py +97 -0
- memlapse/model/system.py +32 -0
- memlapse/services/__init__.py +4 -0
- memlapse/services/playback.py +516 -0
- memlapse/services/recording.py +79 -0
- memlapse/storage/__init__.py +3 -0
- memlapse/storage/dao.py +449 -0
- memlapse/storage/db.py +73 -0
- memlapse/storage/schema.sql +118 -0
- memlapse/ui/__init__.py +3 -0
- memlapse/ui/dashboard.py +393 -0
- memlapse/ui/gauges.py +113 -0
- memlapse/ui/hexdump.py +16 -0
- memlapse/ui/main_window.py +387 -0
- memlapse/ui/process_view.py +175 -0
- memlapse/ui/region_view.py +691 -0
- memlapse/ui/theme.py +132 -0
- memlapse/ui/timeline.py +181 -0
- memlapse/win32/__init__.py +1 -0
- memlapse/win32/memory.py +207 -0
- memlapse/win32/privileges.py +136 -0
- memlapse/win32/processes.py +142 -0
- memlapse/win32/threads.py +146 -0
- memlapse-0.1.1.dist-info/METADATA +154 -0
- memlapse-0.1.1.dist-info/RECORD +40 -0
- memlapse-0.1.1.dist-info/WHEEL +5 -0
- memlapse-0.1.1.dist-info/entry_points.txt +2 -0
- memlapse-0.1.1.dist-info/licenses/LICENSE +21 -0
- memlapse-0.1.1.dist-info/top_level.txt +1 -0
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
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())
|