PyMemoryEditor 3.1.0__py3-none-any.whl → 3.2.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.
- PyMemoryEditor/__init__.py +1 -1
- PyMemoryEditor/app/_widgets.py +52 -1
- PyMemoryEditor/app/application.py +13 -6
- PyMemoryEditor/app/main_window.py +13 -8
- PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
- PyMemoryEditor/linux/functions.py +112 -5
- PyMemoryEditor/linux/process.py +96 -12
- PyMemoryEditor/macos/functions.py +365 -163
- PyMemoryEditor/macos/libsystem.py +15 -0
- PyMemoryEditor/macos/types.py +47 -1
- PyMemoryEditor/mcp/__init__.py +4 -2
- PyMemoryEditor/mcp/config.py +17 -10
- PyMemoryEditor/mcp/server.py +40 -22
- PyMemoryEditor/mcp/session.py +224 -42
- PyMemoryEditor/mcp/toolset.py +218 -67
- PyMemoryEditor/process/abstract.py +27 -23
- PyMemoryEditor/process/pointer_scan.py +92 -13
- PyMemoryEditor/process/scanning.py +210 -96
- PyMemoryEditor/process/thread_info.py +6 -4
- PyMemoryEditor/util/pattern.py +38 -6
- PyMemoryEditor/util/scan.py +11 -4
- PyMemoryEditor/util/scan_numpy.py +50 -26
- PyMemoryEditor/win32/functions.py +120 -73
- PyMemoryEditor/win32/process.py +12 -2
- PyMemoryEditor/win32/types.py +23 -16
- {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.2.0.dist-info}/METADATA +3 -1
- {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.2.0.dist-info}/RECORD +30 -30
- {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.2.0.dist-info}/WHEEL +0 -0
- {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.2.0.dist-info}/entry_points.txt +0 -0
- {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.2.0.dist-info}/licenses/LICENSE +0 -0
PyMemoryEditor/__init__.py
CHANGED
PyMemoryEditor/app/_widgets.py
CHANGED
|
@@ -6,9 +6,10 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
|
|
|
6
6
|
previously appeared duplicated across several dialog modules.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import threading
|
|
9
10
|
from typing import Any, Callable, Iterable, List, Optional, Tuple
|
|
10
11
|
|
|
11
|
-
from PySide6.QtCore import QObject, Qt, QThread
|
|
12
|
+
from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QTimer
|
|
12
13
|
from PySide6.QtGui import QStandardItem
|
|
13
14
|
|
|
14
15
|
|
|
@@ -84,6 +85,56 @@ def _reap_detached_worker(worker: QThread) -> None:
|
|
|
84
85
|
worker.deleteLater()
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
def detached_workers_running() -> bool:
|
|
89
|
+
"""Whether any worker that outlived its owner is still running."""
|
|
90
|
+
return any(worker.isRunning() for worker in _DETACHED_WORKERS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_after_detached_workers(callback: Callable[[], None]) -> None:
|
|
94
|
+
"""Run ``callback`` once every currently detached worker has finished.
|
|
95
|
+
|
|
96
|
+
A detached worker can still be inside a backend syscall on the process it
|
|
97
|
+
was given, so releasing that process (``close()``) must wait for it: on
|
|
98
|
+
Windows the handle value can be recycled for an unrelated object the moment
|
|
99
|
+
it is closed, and a late write from the cheat poller would land there.
|
|
100
|
+
With no worker still running, ``callback`` runs immediately.
|
|
101
|
+
"""
|
|
102
|
+
pending = [worker for worker in _DETACHED_WORKERS if worker.isRunning()]
|
|
103
|
+
if not pending:
|
|
104
|
+
callback()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
remaining = set(pending)
|
|
108
|
+
done = False
|
|
109
|
+
# `finished` can be delivered from the worker's own thread while the
|
|
110
|
+
# isFinished() sweep below runs on this one: guard the bookkeeping so the
|
|
111
|
+
# callback (a handle close) runs exactly once.
|
|
112
|
+
guard = threading.Lock()
|
|
113
|
+
# Whichever thread notices the last worker finishing, the callback is
|
|
114
|
+
# posted to the GUI thread (the application object lives there), so it
|
|
115
|
+
# never runs concurrently with the GUI setting up the next process.
|
|
116
|
+
gui = QCoreApplication.instance()
|
|
117
|
+
|
|
118
|
+
def _on_finished(worker: QThread) -> None:
|
|
119
|
+
nonlocal done
|
|
120
|
+
with guard:
|
|
121
|
+
remaining.discard(worker)
|
|
122
|
+
if remaining or done:
|
|
123
|
+
return
|
|
124
|
+
done = True
|
|
125
|
+
if gui is None:
|
|
126
|
+
callback()
|
|
127
|
+
else:
|
|
128
|
+
QTimer.singleShot(0, gui, callback)
|
|
129
|
+
|
|
130
|
+
for worker in pending:
|
|
131
|
+
worker.finished.connect(lambda w=worker: _on_finished(w))
|
|
132
|
+
# Same race as detach_worker: one may have finished before its connect.
|
|
133
|
+
for worker in pending:
|
|
134
|
+
if worker.isFinished():
|
|
135
|
+
_on_finished(worker)
|
|
136
|
+
|
|
137
|
+
|
|
87
138
|
class TearsDownOnClose:
|
|
88
139
|
"""Mixin that runs a dialog's teardown on *every* way out, not just close.
|
|
89
140
|
|
|
@@ -561,6 +561,7 @@ def main(argv=None):
|
|
|
561
561
|
from PySide6.QtCore import QSettings
|
|
562
562
|
from PySide6.QtWidgets import QApplication
|
|
563
563
|
|
|
564
|
+
from ._widgets import detached_workers_running
|
|
564
565
|
from .main_window import MainWindow
|
|
565
566
|
from .open_process_dialog import OpenProcessDialog
|
|
566
567
|
|
|
@@ -591,12 +592,18 @@ def main(argv=None):
|
|
|
591
592
|
with _scoped_excepthook(_qt_excepthook):
|
|
592
593
|
app.exec()
|
|
593
594
|
finally:
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
595
|
+
# A worker that blew its join on close is detached, not stopped, and
|
|
596
|
+
# may still be inside a syscall on this handle; closing it now could
|
|
597
|
+
# hand its value to an unrelated object mid-write. The process is
|
|
598
|
+
# exiting, so leave it to the OS, which releases every handle then.
|
|
599
|
+
if not detached_workers_running():
|
|
600
|
+
try:
|
|
601
|
+
# `process` is only the handle the picker opened; File →
|
|
602
|
+
# Change Process… released that one and attached the window
|
|
603
|
+
# to another.
|
|
604
|
+
window.process.close()
|
|
605
|
+
except Exception:
|
|
606
|
+
pass
|
|
600
607
|
|
|
601
608
|
|
|
602
609
|
def main_cli(argv=None):
|
|
@@ -46,7 +46,7 @@ from PySide6.QtWidgets import (
|
|
|
46
46
|
from PyMemoryEditor import AbstractProcess, __version__
|
|
47
47
|
|
|
48
48
|
from ._icon import app_icon
|
|
49
|
-
from ._widgets import detach_worker
|
|
49
|
+
from ._widgets import detach_worker, run_after_detached_workers
|
|
50
50
|
from .application import DEFAULT_THEME_ID, THEMES, apply_theme
|
|
51
51
|
from .cheat_entry import CheatEntry
|
|
52
52
|
from .cheat_table import CheatTable
|
|
@@ -910,13 +910,11 @@ class MainWindow(QMainWindow):
|
|
|
910
910
|
old_cheat.shutdown()
|
|
911
911
|
except Exception:
|
|
912
912
|
pass
|
|
913
|
-
#
|
|
914
|
-
#
|
|
915
|
-
#
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
except Exception:
|
|
919
|
-
pass
|
|
913
|
+
# A worker that blew its join is detached, not stopped, so it can still
|
|
914
|
+
# be inside a syscall on the old handle. Release the handle only once
|
|
915
|
+
# every detached worker has finished; closing it now could hand its
|
|
916
|
+
# value to an unrelated object while the cheat poller is mid-write.
|
|
917
|
+
run_after_detached_workers(lambda p=old_process: _close_quietly(p))
|
|
920
918
|
old_index = self._right_splitter.indexOf(old_cheat)
|
|
921
919
|
self._cheat = CheatTable(self._process)
|
|
922
920
|
self._cheat.pointer_scan_for_address.connect(self._open_pointer_scan_dialog)
|
|
@@ -1049,6 +1047,13 @@ class MainWindow(QMainWindow):
|
|
|
1049
1047
|
worker.deleteLater()
|
|
1050
1048
|
|
|
1051
1049
|
|
|
1050
|
+
def _close_quietly(process) -> None:
|
|
1051
|
+
try:
|
|
1052
|
+
process.close()
|
|
1053
|
+
except Exception: # noqa: BLE001 — the target may already be gone
|
|
1054
|
+
pass
|
|
1055
|
+
|
|
1056
|
+
|
|
1052
1057
|
def _safe_for_json(value) -> object:
|
|
1053
1058
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
1054
1059
|
return value
|
|
@@ -50,7 +50,10 @@ from PySide6.QtWidgets import (
|
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
from PyMemoryEditor import AbstractProcess, PointerPath
|
|
53
|
-
from PyMemoryEditor.process.pointer_scan import
|
|
53
|
+
from PyMemoryEditor.process.pointer_scan import (
|
|
54
|
+
first_module_bases,
|
|
55
|
+
intersect_pointer_paths,
|
|
56
|
+
)
|
|
54
57
|
|
|
55
58
|
from ._widgets import (
|
|
56
59
|
MONOSPACE_FAMILY,
|
|
@@ -141,6 +144,9 @@ class PointerScanWorker(QThread):
|
|
|
141
144
|
writable_only=req.writable_only,
|
|
142
145
|
max_results=req.max_results,
|
|
143
146
|
progress_callback=self._on_map_progress,
|
|
147
|
+
# The walk can run a long time between two paths; this lets
|
|
148
|
+
# Stop end it there too, not only at the next yield.
|
|
149
|
+
should_stop=lambda: self._cancelled,
|
|
144
150
|
)
|
|
145
151
|
|
|
146
152
|
batch: List[_ResultRow] = []
|
|
@@ -246,7 +252,7 @@ class PointerRescanWorker(QThread):
|
|
|
246
252
|
def run(self) -> None:
|
|
247
253
|
try:
|
|
248
254
|
# One name→base lookup so rebasing each saved path is O(1).
|
|
249
|
-
module_bases =
|
|
255
|
+
module_bases = first_module_bases(self._process.get_modules())
|
|
250
256
|
|
|
251
257
|
batch: List[_ResultRow] = []
|
|
252
258
|
total = len(self._paths) or 1
|
|
@@ -284,16 +290,8 @@ class PointerRescanWorker(QThread):
|
|
|
284
290
|
def _rebase(self, saved: PointerPath, module_bases: dict) -> Optional[PointerPath]:
|
|
285
291
|
"""Move a saved path onto the live module base, or None if not rebasable."""
|
|
286
292
|
if saved.module is not None and saved.module_offset is not None:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return None # module not loaded in this run — drop the path
|
|
290
|
-
return PointerPath(
|
|
291
|
-
base_address=base + saved.module_offset,
|
|
292
|
-
offsets=saved.offsets,
|
|
293
|
-
module=saved.module,
|
|
294
|
-
module_offset=saved.module_offset,
|
|
295
|
-
ptr_size=saved.ptr_size,
|
|
296
|
-
)
|
|
293
|
+
# None when the module isn't loaded in this run — drop the path.
|
|
294
|
+
return saved._rebased_onto(module_bases)
|
|
297
295
|
# No module: the absolute base is only a best-effort guess post-restart.
|
|
298
296
|
return saved
|
|
299
297
|
|
|
@@ -979,23 +977,23 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
|
|
|
979
977
|
# For display, rebase each survivor onto the current process and read its
|
|
980
978
|
# value — best effort; a path that can't resolve now is still a valid
|
|
981
979
|
# member of the intersection and is kept (shown dimmed / "—").
|
|
982
|
-
|
|
980
|
+
unavailable = ""
|
|
981
|
+
try:
|
|
982
|
+
module_bases = first_module_bases(self._process.get_modules())
|
|
983
|
+
except Exception as exc: # noqa: BLE001 — display only; the target may be gone
|
|
984
|
+
# Without the module list every survivor is shown unresolved ("—"),
|
|
985
|
+
# which is what the per-path guard below does for a single one —
|
|
986
|
+
# but say why, or a table of dimmed rows reads as "nothing works".
|
|
987
|
+
module_bases = {}
|
|
988
|
+
unavailable = (
|
|
989
|
+
f" Values not shown: the target's modules could not be listed "
|
|
990
|
+
f"({type(exc).__name__}: {exc})."
|
|
991
|
+
)
|
|
983
992
|
spec, length = self._current_spec()
|
|
984
993
|
|
|
985
994
|
rows: List[_ResultRow] = []
|
|
986
995
|
for saved in survivors:
|
|
987
|
-
|
|
988
|
-
live = (
|
|
989
|
-
PointerPath(
|
|
990
|
-
base_address=base + saved.module_offset,
|
|
991
|
-
offsets=saved.offsets,
|
|
992
|
-
module=saved.module,
|
|
993
|
-
module_offset=saved.module_offset,
|
|
994
|
-
ptr_size=saved.ptr_size,
|
|
995
|
-
)
|
|
996
|
-
if base is not None and saved.module_offset is not None
|
|
997
|
-
else saved
|
|
998
|
-
)
|
|
996
|
+
live = saved._rebased_onto(module_bases) or saved
|
|
999
997
|
try:
|
|
1000
998
|
resolved = live.resolve(self._process)
|
|
1001
999
|
value = self._process.read_process_memory(resolved, spec.pytype, length)
|
|
@@ -1008,6 +1006,7 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
|
|
|
1008
1006
|
self._append_rows(rows)
|
|
1009
1007
|
self._count_label.setText(
|
|
1010
1008
|
f"{len(survivors):,} path(s) common to {len(filenames)} scans."
|
|
1009
|
+
+ unavailable
|
|
1011
1010
|
)
|
|
1012
1011
|
self._export_btn.setEnabled(True)
|
|
1013
1012
|
|
|
@@ -10,6 +10,7 @@ import ctypes
|
|
|
10
10
|
import errno as errno_mod
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
import signal
|
|
13
14
|
from ctypes import addressof, sizeof
|
|
14
15
|
from typing import Dict, Generator, Optional, Sequence, Tuple, Type, TypeVar, Union
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ from ..process.region import (
|
|
|
20
21
|
MemoryRegion,
|
|
21
22
|
default_address_filter,
|
|
22
23
|
default_scan_filter,
|
|
24
|
+
MemoryRegionSnapshot,
|
|
23
25
|
make_region,
|
|
24
26
|
)
|
|
25
27
|
from ..process.scanning import (
|
|
@@ -84,6 +86,11 @@ _PTRACE_SCOPE_HINT = (
|
|
|
84
86
|
)
|
|
85
87
|
|
|
86
88
|
|
|
89
|
+
def _errno_or(exc: OSError, default: int) -> int:
|
|
90
|
+
"""``exc.errno``, or ``default`` when the exception was raised without one."""
|
|
91
|
+
return exc.errno if exc.errno is not None else default
|
|
92
|
+
|
|
93
|
+
|
|
87
94
|
def _syscall_error(op: str, errno: int, remote_address: int, length: int) -> OSError:
|
|
88
95
|
"""
|
|
89
96
|
Build the ``OSError`` for a failed ``process_vm_readv`` / ``process_vm_writev``.
|
|
@@ -225,10 +232,11 @@ def get_memory_regions(pid: int) -> Generator["MemoryRegion", None, None]:
|
|
|
225
232
|
# prctl(PR_SET_DUMPABLE, 0)). The ptrace_scope hint belongs to the
|
|
226
233
|
# EPERM from process_vm_readv/writev, not to this path.
|
|
227
234
|
raise PermissionError(
|
|
235
|
+
_errno_or(exc, errno_mod.EACCES),
|
|
228
236
|
"Cannot read %s: %s. The target process belongs to another user "
|
|
229
237
|
"or is not dumpable; run as root or as the owner of the target."
|
|
230
|
-
% (mapping_filename, exc)
|
|
231
|
-
)
|
|
238
|
+
% (mapping_filename, exc),
|
|
239
|
+
) from exc
|
|
232
240
|
|
|
233
241
|
with mapping_file:
|
|
234
242
|
for line in mapping_file:
|
|
@@ -552,7 +560,11 @@ def search_values_by_addresses(
|
|
|
552
560
|
memory_regions = [
|
|
553
561
|
region for region in get_memory_regions(pid) if default_address_filter(region)
|
|
554
562
|
]
|
|
555
|
-
|
|
563
|
+
elif not isinstance(memory_regions, MemoryRegionSnapshot):
|
|
564
|
+
# Materialize any other iterable (it is indexed and walked more than
|
|
565
|
+
# once), but pass a snapshot through untouched: `list(...)` would drop
|
|
566
|
+
# its type tag and make `_ensure_sorted_by_address` re-sort an
|
|
567
|
+
# already-sorted region map on every refine scan.
|
|
556
568
|
memory_regions = list(memory_regions)
|
|
557
569
|
|
|
558
570
|
yield from iter_values_for_addresses(
|
|
@@ -615,10 +627,11 @@ def get_threads(pid: int) -> Generator[ThreadInfo, None, None]:
|
|
|
615
627
|
return
|
|
616
628
|
except PermissionError as exc:
|
|
617
629
|
raise PermissionError(
|
|
630
|
+
_errno_or(exc, errno_mod.EACCES),
|
|
618
631
|
"Cannot list %s: %s. The target process belongs to another user "
|
|
619
632
|
"or is not dumpable; run as root or as the owner of the target."
|
|
620
|
-
% (task_dir, exc)
|
|
621
|
-
)
|
|
633
|
+
% (task_dir, exc),
|
|
634
|
+
) from exc
|
|
622
635
|
except OSError as exc:
|
|
623
636
|
_logger.debug("get_threads: could not list %s: %s", task_dir, exc)
|
|
624
637
|
return
|
|
@@ -701,3 +714,97 @@ def process_exists(pid: int) -> bool:
|
|
|
701
714
|
if pid < 0:
|
|
702
715
|
return False
|
|
703
716
|
return os.path.isdir("/proc/{}".format(pid))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# --- Target identity (PID-reuse guard) --------------------------------------
|
|
720
|
+
#
|
|
721
|
+
# Unlike a Windows process handle or a macOS task port, nothing on the Linux
|
|
722
|
+
# path pins the target: process_vm_readv / process_vm_writev and /proc take a
|
|
723
|
+
# bare pid. Once the target exits and is reaped, the kernel may hand that pid
|
|
724
|
+
# to an unrelated process, and every later call would silently act on it. The
|
|
725
|
+
# helpers below let LinuxProcess bind to the *original* process at open time
|
|
726
|
+
# and verify it before touching the target again.
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def open_pidfd(pid: int) -> Optional[int]:
|
|
730
|
+
"""
|
|
731
|
+
Return a pidfd for ``pid`` (``pidfd_open(2)``, Linux 5.3+), or ``None``
|
|
732
|
+
when pidfds are unavailable: the kernel predates them (``ENOSYS``), a
|
|
733
|
+
seccomp policy blocks the syscall (``EPERM``, e.g. older container
|
|
734
|
+
runtimes), ``pid`` is a non-leader thread id (``EINVAL``), the fd table is
|
|
735
|
+
full, or this Python was built without ``os.pidfd_open`` /
|
|
736
|
+
``signal.pidfd_send_signal``. Callers then fall back to
|
|
737
|
+
:func:`read_start_time`.
|
|
738
|
+
|
|
739
|
+
Raises :class:`~PyMemoryEditor.ProcessIDNotExistsError` when the process
|
|
740
|
+
is already gone (``ESRCH``) — it exited after the open-time existence
|
|
741
|
+
check.
|
|
742
|
+
"""
|
|
743
|
+
pidfd_open = getattr(os, "pidfd_open", None)
|
|
744
|
+
if pidfd_open is None or not hasattr(signal, "pidfd_send_signal"):
|
|
745
|
+
return None
|
|
746
|
+
try:
|
|
747
|
+
return pidfd_open(pid, 0)
|
|
748
|
+
except ProcessLookupError:
|
|
749
|
+
raise ProcessIDNotExistsError(pid)
|
|
750
|
+
except OSError as exc:
|
|
751
|
+
_logger.debug(
|
|
752
|
+
"open_pidfd: pidfd_open(%d) failed (%s); falling back to the "
|
|
753
|
+
"/proc start-time check for PID-reuse detection.",
|
|
754
|
+
pid,
|
|
755
|
+
exc,
|
|
756
|
+
)
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def pidfd_alive(pidfd: int) -> Optional[bool]:
|
|
761
|
+
"""
|
|
762
|
+
Whether the process ``pidfd`` refers to still exists, via a null signal
|
|
763
|
+
(``pidfd_send_signal(pidfd, 0, NULL, 0)``): one syscall, no side effect.
|
|
764
|
+
|
|
765
|
+
The answer is about the original process, not its pid number — after the
|
|
766
|
+
target is reaped the call fails with ``ESRCH`` even if the pid has already
|
|
767
|
+
been reused. A zombie still counts as alive (it keeps its pid until it is
|
|
768
|
+
reaped), matching the ``kill(pid, 0)`` semantics of the base class.
|
|
769
|
+
``EPERM`` (target owned by another user) also means "exists". Returns
|
|
770
|
+
``None`` when the probe itself cannot answer (e.g. ``EINVAL`` for a
|
|
771
|
+
target outside this pid namespace), so the caller can fall back.
|
|
772
|
+
"""
|
|
773
|
+
try:
|
|
774
|
+
signal.pidfd_send_signal(pidfd, 0)
|
|
775
|
+
except ProcessLookupError:
|
|
776
|
+
return False
|
|
777
|
+
except PermissionError:
|
|
778
|
+
return True
|
|
779
|
+
except OSError as exc:
|
|
780
|
+
_logger.debug("pidfd_alive: pidfd_send_signal failed: %s", exc)
|
|
781
|
+
return None
|
|
782
|
+
return True
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def read_start_time(pid: int) -> Optional[int]:
|
|
786
|
+
"""
|
|
787
|
+
Return the process start time (field 22 of ``/proc/<pid>/stat``, in clock
|
|
788
|
+
ticks since boot), or ``None`` if the file can't be read or parsed (the
|
|
789
|
+
process is gone, or ``/proc`` is mounted with ``hidepid``).
|
|
790
|
+
|
|
791
|
+
``(pid, start time)`` identifies a process uniquely — a recycled pid gets
|
|
792
|
+
a later start time — which makes it the PID-reuse guard on kernels
|
|
793
|
+
without pidfds.
|
|
794
|
+
"""
|
|
795
|
+
try:
|
|
796
|
+
with open("/proc/{}/stat".format(pid), "rb") as fh:
|
|
797
|
+
raw_stat = fh.read()
|
|
798
|
+
except OSError:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
# `comm` is parenthesised and may itself contain spaces or ')': split
|
|
802
|
+
# after the *last* ')'. rest[0] is field 3 (state), so field 22 is rest[19].
|
|
803
|
+
close_paren = raw_stat.rfind(b")")
|
|
804
|
+
if close_paren == -1:
|
|
805
|
+
return None
|
|
806
|
+
rest = raw_stat[close_paren + 1 :].split()
|
|
807
|
+
try:
|
|
808
|
+
return int(rest[19])
|
|
809
|
+
except (IndexError, ValueError):
|
|
810
|
+
return None
|
PyMemoryEditor/linux/process.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
3
5
|
import warnings
|
|
4
6
|
from typing import Generator, Optional, Sequence, Tuple, Type, TypeVar, Union
|
|
5
7
|
|
|
6
8
|
from ..enums import ScanTypesEnum
|
|
7
9
|
from ..process import AbstractProcess
|
|
8
|
-
from ..process.errors import ClosedProcess
|
|
10
|
+
from ..process.errors import ClosedProcess, ProcessIDNotExistsError
|
|
9
11
|
from ..util import (
|
|
10
12
|
UNSET,
|
|
11
13
|
prepare_write,
|
|
@@ -20,15 +22,22 @@ from .functions import (
|
|
|
20
22
|
get_memory_regions,
|
|
21
23
|
get_modules,
|
|
22
24
|
get_threads,
|
|
25
|
+
open_pidfd,
|
|
26
|
+
pidfd_alive,
|
|
27
|
+
process_exists,
|
|
23
28
|
read_process_memory,
|
|
24
29
|
read_process_memory_into,
|
|
25
30
|
search_addresses_by_pattern,
|
|
26
31
|
search_addresses_by_value,
|
|
27
32
|
search_values_by_addresses,
|
|
33
|
+
read_start_time,
|
|
28
34
|
write_process_memory,
|
|
29
35
|
)
|
|
30
36
|
|
|
31
37
|
|
|
38
|
+
_logger = logging.getLogger("PyMemoryEditor")
|
|
39
|
+
|
|
40
|
+
|
|
32
41
|
T = TypeVar("T")
|
|
33
42
|
|
|
34
43
|
|
|
@@ -69,6 +78,17 @@ class LinuxProcess(AbstractProcess):
|
|
|
69
78
|
exact_match=exact_match,
|
|
70
79
|
strict_bitness=strict_bitness,
|
|
71
80
|
)
|
|
81
|
+
|
|
82
|
+
# Bind to *this* process, not to its pid number. Linux I/O takes a
|
|
83
|
+
# bare pid (no handle / task port pins the target as on Windows and
|
|
84
|
+
# macOS), so once the target exits and is reaped the pid can be
|
|
85
|
+
# recycled and every later call would silently hit an unrelated
|
|
86
|
+
# process. A pidfd refers to the original process for its whole
|
|
87
|
+
# lifetime; where pidfds are unavailable, the /proc start time —
|
|
88
|
+
# unique per (pid, boot) — stands in. Both are verified before each
|
|
89
|
+
# operation (see `__require_target`).
|
|
90
|
+
self.__pidfd: Optional[int] = open_pidfd(self.pid)
|
|
91
|
+
self.__start_time: Optional[int] = read_start_time(self.pid)
|
|
72
92
|
self.__closed = False
|
|
73
93
|
|
|
74
94
|
# `permission` is accepted for cross-platform parity but has no effect
|
|
@@ -87,24 +107,88 @@ class LinuxProcess(AbstractProcess):
|
|
|
87
107
|
if self.__closed:
|
|
88
108
|
raise ClosedProcess()
|
|
89
109
|
|
|
110
|
+
def __require_target(self, *, hot_path: bool = False) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Raise ``ClosedProcess`` after ``close()``, and
|
|
113
|
+
:class:`~PyMemoryEditor.ProcessIDNotExistsError` once the process this
|
|
114
|
+
object was opened on has exited — even if its pid now belongs to
|
|
115
|
+
another process. One ``pidfd_send_signal`` per call on kernels with
|
|
116
|
+
pidfds (5.3+, ~0.3 us); a ``/proc/<pid>/stat`` read otherwise.
|
|
117
|
+
|
|
118
|
+
``hot_path`` marks single-value reads, which callers issue in tight
|
|
119
|
+
loops: they are verified only when the cheap pidfd probe is
|
|
120
|
+
available. Without a pidfd the start-time read would roughly
|
|
121
|
+
quadruple the cost of every read, so there they keep the pre-pidfd
|
|
122
|
+
behaviour. Writes, scans and listings are always verified.
|
|
123
|
+
"""
|
|
124
|
+
self.__require_open()
|
|
125
|
+
if hot_path and self.__pidfd is None:
|
|
126
|
+
return
|
|
127
|
+
if not self._target_alive():
|
|
128
|
+
raise ProcessIDNotExistsError(self.pid)
|
|
129
|
+
|
|
130
|
+
def _target_alive(self) -> bool:
|
|
131
|
+
pidfd = self.__pidfd
|
|
132
|
+
if pidfd is not None:
|
|
133
|
+
alive = pidfd_alive(pidfd)
|
|
134
|
+
if alive is not None:
|
|
135
|
+
return alive
|
|
136
|
+
if self.__start_time is not None:
|
|
137
|
+
current = read_start_time(self.pid)
|
|
138
|
+
if current is not None:
|
|
139
|
+
return current == self.__start_time
|
|
140
|
+
# Unreadable now: gone (no /proc entry) or hidden — ask by pid.
|
|
141
|
+
return process_exists(self.pid)
|
|
142
|
+
return super()._target_alive()
|
|
143
|
+
|
|
90
144
|
def close(self) -> bool:
|
|
145
|
+
if self.__closed:
|
|
146
|
+
return True
|
|
147
|
+
|
|
91
148
|
self.__closed = True
|
|
149
|
+
pidfd, self.__pidfd = self.__pidfd, None
|
|
150
|
+
if pidfd is not None:
|
|
151
|
+
try:
|
|
152
|
+
os.close(pidfd)
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
# Never raise from close() (`__exit__` / `__del__` call it);
|
|
155
|
+
# report through the return value like the Windows backend.
|
|
156
|
+
_logger.warning(
|
|
157
|
+
"Closing the pidfd of process %d failed: %s", self.pid, exc
|
|
158
|
+
)
|
|
159
|
+
return False
|
|
92
160
|
return True
|
|
93
161
|
|
|
162
|
+
def __del__(self) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Best-effort safety net for callers who forget to ``close()`` / use
|
|
165
|
+
the context manager: releases the pidfd, mirroring how the Windows and
|
|
166
|
+
macOS backends release their handle / task port. ``__del__`` is not
|
|
167
|
+
guaranteed to run, so this is only a fallback.
|
|
168
|
+
"""
|
|
169
|
+
# Construction failed before the pidfd was set: nothing to release.
|
|
170
|
+
if getattr(self, "_LinuxProcess__closed", True):
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
self.close()
|
|
174
|
+
except Exception:
|
|
175
|
+
# __del__ must not raise; the interpreter may be shutting down.
|
|
176
|
+
pass
|
|
177
|
+
|
|
94
178
|
def _detect_is_64bit(self) -> Optional[bool]:
|
|
95
|
-
self.
|
|
179
|
+
self.__require_target()
|
|
96
180
|
return _detect_process_64bit(self.pid)
|
|
97
181
|
|
|
98
182
|
def get_memory_regions(self) -> Generator[MemoryRegion, None, None]:
|
|
99
|
-
self.
|
|
183
|
+
self.__require_target()
|
|
100
184
|
return get_memory_regions(self.pid)
|
|
101
185
|
|
|
102
186
|
def get_threads(self) -> Generator[ThreadInfo, None, None]:
|
|
103
|
-
self.
|
|
187
|
+
self.__require_target()
|
|
104
188
|
return get_threads(self.pid)
|
|
105
189
|
|
|
106
190
|
def get_modules(self) -> Generator[ModuleInfo, None, None]:
|
|
107
|
-
self.
|
|
191
|
+
self.__require_target()
|
|
108
192
|
return get_modules(self.pid)
|
|
109
193
|
|
|
110
194
|
def read_process_memory(
|
|
@@ -113,13 +197,13 @@ class LinuxProcess(AbstractProcess):
|
|
|
113
197
|
pytype: Type[T],
|
|
114
198
|
bufflength: Optional[int] = None,
|
|
115
199
|
) -> T:
|
|
116
|
-
self.
|
|
200
|
+
self.__require_target(hot_path=True)
|
|
117
201
|
return read_process_memory(
|
|
118
202
|
self.pid, address, pytype, resolve_bufflength(pytype, bufflength)
|
|
119
203
|
)
|
|
120
204
|
|
|
121
205
|
def read_process_memory_into(self, address: int, buffer: object) -> int:
|
|
122
|
-
self.
|
|
206
|
+
self.__require_target(hot_path=True)
|
|
123
207
|
return read_process_memory_into(self.pid, address, buffer)
|
|
124
208
|
|
|
125
209
|
def search_by_addresses(
|
|
@@ -131,7 +215,7 @@ class LinuxProcess(AbstractProcess):
|
|
|
131
215
|
raise_error: bool = False,
|
|
132
216
|
memory_regions: Optional[Sequence[MemoryRegion]] = None,
|
|
133
217
|
) -> Generator[Tuple[int, Optional[T]], None, None]:
|
|
134
|
-
self.
|
|
218
|
+
self.__require_target()
|
|
135
219
|
if addresses is UNSET:
|
|
136
220
|
raise TypeError("addresses is required.")
|
|
137
221
|
return search_values_by_addresses(
|
|
@@ -155,7 +239,7 @@ class LinuxProcess(AbstractProcess):
|
|
|
155
239
|
memory_regions: Optional[Sequence[MemoryRegion]] = None,
|
|
156
240
|
aligned: bool = True,
|
|
157
241
|
) -> Generator[Union[int, Tuple[int, dict]], None, None]:
|
|
158
|
-
self.
|
|
242
|
+
self.__require_target()
|
|
159
243
|
|
|
160
244
|
if scan_type in [ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN]:
|
|
161
245
|
raise ValueError(
|
|
@@ -182,7 +266,7 @@ class LinuxProcess(AbstractProcess):
|
|
|
182
266
|
progress_information: bool = False,
|
|
183
267
|
memory_regions: Optional[Sequence[MemoryRegion]] = None,
|
|
184
268
|
) -> Generator[Union[int, Tuple[int, dict]], None, None]:
|
|
185
|
-
self.
|
|
269
|
+
self.__require_target()
|
|
186
270
|
return search_addresses_by_pattern(
|
|
187
271
|
self.pid,
|
|
188
272
|
pattern,
|
|
@@ -204,7 +288,7 @@ class LinuxProcess(AbstractProcess):
|
|
|
204
288
|
memory_regions: Optional[Sequence[MemoryRegion]] = None,
|
|
205
289
|
aligned: bool = True,
|
|
206
290
|
) -> Generator[Union[int, Tuple[int, dict]], None, None]:
|
|
207
|
-
self.
|
|
291
|
+
self.__require_target()
|
|
208
292
|
|
|
209
293
|
scan_type = (
|
|
210
294
|
ScanTypesEnum.NOT_VALUE_BETWEEN
|
|
@@ -230,7 +314,7 @@ class LinuxProcess(AbstractProcess):
|
|
|
230
314
|
bufflength: Optional[int] = None,
|
|
231
315
|
value: Union[bool, int, float, str, bytes] = UNSET,
|
|
232
316
|
) -> Union[bool, int, float, str, bytes]:
|
|
233
|
-
self.
|
|
317
|
+
self.__require_target()
|
|
234
318
|
w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value)
|
|
235
319
|
write_process_memory(self.pid, address, w_pytype, w_length, w_value)
|
|
236
320
|
return value
|