PyrsistenceSniper: Offline Windows Persistence Detection
We took PersistenceSniper, merged it with Python, and misspelled it on purpose. Meet PyrsistenceSniper.
Point it at a KAPE dump, a Velociraptor collection, or a mounted disk image and get offline Windows persistence detection in seconds. No live system access, no admin privileges, no PowerShell. Runs on Windows, Linux, and macOS.
🚀 Key Features
- Wide persistence coverage: 130 checks across 11 MITRE ATT&CK techniques, covering Run keys, services, COM hijacking, scheduled tasks, WMI subscriptions, Office add-ins, IFEO injection, accessibility backdoors, startup folders, LSA packages, and more.
- Signature-based filtering: Authenticode signatures separate real persistence from OS defaults, instead of value-based whitelists that miss swapped binaries and DLL proxying.
- Custom detection profiles: YAML allow and block rules, globally or per check.
- Finding context: Every finding carries file existence, SHA-256, signer, LOLBin classification, and an approximate last-change timestamp.
- Flexible output: Console, CSV, HTML, and XLSX.
- Extensible plugin system: A new persistence check is one file, usually declarative.
- Speed: Native registry parsing via libregf. Scans complete in roughly 10 to 30 seconds on heavily used systems.
📋 Prerequisites
Python 3.10+ required (3.10 to 3.12 recommended). PyrsistenceSniper depends on libregf-python, a C extension for offline registry hive parsing.
| Platform | Requirement |
|---|---|
| Windows | None. Pre-built wheels are installed automatically. |
| Linux | gcc, make, and Python headers (sudo apt install build-essential python3-dev). |
| macOS | Xcode Command Line Tools (xcode-select --install). |
Note: Without a pre-built wheel for your platform and Python version, pip builds libregf from source (up to a minute). Windows users may also need the Microsoft C++ Build Tools with the "Desktop development with C++" workload.
📦 Installation
From PyPI
pip install pyrsistencesniper
From source
git clone https://github.com/Hexastrike/PyrsistenceSniper.git
cd PyrsistenceSniper
poetry install
Docker
# Build the image
docker build -t pyrsistencesniper .
# Scan a triage collection
docker run --rm -v /path/to/triage:/evidence:ro pyrsistencesniper /evidence
# Export as CSV (evidence stays read-only, reports go to a separate writable mount)
docker run --rm -v /path/to/triage:/evidence:ro -v "$PWD/out":/out pyrsistencesniper /evidence --format csv --output /out/results.csv
# Full HTML report with no filtering
docker run --rm -v /path/to/triage:/evidence:ro -v "$PWD/out":/out pyrsistencesniper /evidence --min-severity info --format html --output /out/report.html
🎯 Usage
The path argument is the root of your forensic collection, wherever the Windows/ directory lives: KAPE output, Velociraptor collections, mounted E01s, raw directory copies. As long as the hives and filesystem artifacts sit at their expected paths relative to the root, PyrsistenceSniper will find them.
python -m pyrsistencesniper [-h] [--hostname HOSTNAME] [--format {console,csv,html,xlsx}]
[--output OUTPUT] [--profile PROFILE]
[--technique TECHNIQUE [TECHNIQUE ...]] [--list-checks]
[--update-lolbins] [--min-severity {info,low,medium,high}]
[--mft MFT] [--no-timeline] [-v] [path]
| Flag | Description |
|---|---|
--format {console,csv,html,xlsx} |
Output format (default: console) |
--output FILE |
Write output to file instead of stdout |
--profile FILE |
YAML detection profile for allow/block overrides |
--technique ID [...] |
Filter by MITRE ATT&CK technique or check ID |
--hostname NAME |
Override hostname (otherwise read from SYSTEM hive) |
--list-checks |
List all available checks and exit |
--update-lolbins |
Download the latest LOLBin list from the LOLBAS project |
--min-severity {info,low,medium,high} |
Minimum severity to include (default: medium). Use info to show everything |
--mft FILE |
Externally collected $MFT for change timestamps. Takes precedence over one auto-discovered in the collection |
--no-timeline |
Skip last-change timestamp resolution |
-v, --verbose |
Enable debug logging to stderr |
Examples
# Scan a KAPE collection
python -m pyrsistencesniper /mnt/case042/C
# Export as CSV for stacking across multiple systems
python -m pyrsistencesniper /mnt/case042/C --format csv --output host1.csv
# Generate an HTML report
python -m pyrsistencesniper /mnt/case042/C --format html --output report.html
# Show everything, including OS defaults
python -m pyrsistencesniper /mnt/case042/C --min-severity info
# Only check specific MITRE ATT&CK techniques
python -m pyrsistencesniper /mnt/case042/C --technique T1547 T1546
# Apply a custom detection profile
python -m pyrsistencesniper /mnt/case042/C --profile ./profiles/customer_baseline.yaml
# Correlate change times against an externally collected $MFT
python -m pyrsistencesniper /mnt/case042/C --mft /mnt/case042/'$MFT' --format html --output report.html
# List all available persistence checks
python -m pyrsistencesniper --list-checks
Standalone artifact scanning
Pass a single hive file directly, no directory structure needed:
# Scan a standalone NTUSER.DAT
python -m pyrsistencesniper /path/to/NTUSER.DAT
# Scan a standalone SYSTEM hive with verbose output
python -m pyrsistencesniper /path/to/SYSTEM -v
# Scan a standalone SOFTWARE hive with CSV output
python -m pyrsistencesniper /path/to/SOFTWARE --format csv --output results.csv
Supported standalone artifacts: SYSTEM, SOFTWARE, SAM, SECURITY, NTUSER.DAT, UsrClass.dat, DEFAULT, Amcache.hve.
PyrsistenceSniper auto-detects standalone mode and runs only the checks that apply to the given hive. Resolution features (file existence, hashes, signatures) are unavailable there, since there is no filesystem to cross-reference.
🔍 How It Works
PyrsistenceSniper runs each finding through a multi-stage pipeline:
- Plugin execution: Each check scans registry hives, filesystem artifacts, scheduled task XMLs, or WMI repositories for persistence indicators.
- Resolution: Findings are enriched with file existence, SHA-256 hash, Authenticode signer, LOLBin classification, and OS directory detection.
- Severity classification: Each finding is classified as
HIGH(block rule match),MEDIUM(no rules match),LOW(partial allow match), orINFO(full allow match), then filtered by--min-severity. Plugins also reject invalid data (empty values, non-executable flags) unconditionally. In most environments this cuts output by 80 to 90%. - Change timeline: Each finding gets an approximate last-change timestamp from
$MFTrecords and event logs. - Enrichment: Optional enrichment plugins attach additional metadata before output.
- Output: Findings are rendered in the requested format.
Each finding carries:
| Field | Description |
|---|---|
path |
Registry key or file path |
value |
Registry value, command line, or DLL path |
technique |
Human-readable technique name |
mitre_id |
MITRE ATT&CK technique ID |
access_gained |
USER or SYSTEM |
severity |
INFO, LOW, MEDIUM, or HIGH |
last_change |
Approximate UTC time the mechanism last changed |
change_source |
Which evidence set last_change ($MFT or event log) |
change_evidence |
Why last_change holds what it holds |
sha256 |
SHA-256 hash of the referenced binary |
signer |
Authenticode signer name |
is_lolbin |
Whether the entry executes via a known LOLBin, as launcher or payload |
launcher |
The launcher the value proxies through, empty when it runs its image directly |
exists |
Whether the referenced file exists on disk |
is_lolbin describes how the entry executes, not just what it points at. A value like
rundll32.exe C:\ProgramData\evil.dll,Start resolves its payload to the DLL, which is what
gets hashed and signer-checked, while is_lolbin stays true and launcher records
rundll32.exe. Adding an argument cannot make a finding look less suspicious than the bare
tool would.
The LOLBin set is the LOLBAS project's list plus the shells LOLBAS deliberately omits
(powershell.exe, pwsh.exe, cmd.exe). LOLBAS excludes them because a shell is not a
binary repurposed to do something it was not built for; for persistence triage a payload
launched through PowerShell is exactly as notable as one launched through mshta.
Console output groups findings by MITRE technique and warns when a hive or check produced nothing because it failed. CSV and XLSX include all fields plus dynamic enrichment columns. HTML produces a standalone report: searchable, sortable, with a chevron on every row that expands the full finding and every timestamp candidate that was considered.
Last change timestamps
The Last Change and Change Source columns approximate when each persistence mechanism was last modified. A timestamp appears only when it comes from evidence that copying cannot rewrite: a file's $MFT record, or a matching event log entry (task creation 4698/106, service install 7045). Plugins declare which artifact and which events date their findings; otherwise a file-backed finding falls back to its own $MFT record, and a registry finding stays blank.
Two sources are excluded on purpose. Filesystem modification times can be copy times in a repacked collection, indistinguishable from the originals. Registry key write times stamp the key, not the value, so any value in a shared key bumps them. An empty cell is the honest answer in both cases.
Change Evidence says which kind of empty a blank cell is. NOT_APPLICABLE: nothing could ever date this kind of finding. NO_ARTIFACT: evidence was declared, but the $MFT or event log it needs was absent or cleared, so going back for that artifact would fill the cell. NO_MATCH: the artifact was read and had nothing to say about this entry. REJECTED: times were found and every one of them was implausible.
Supply an externally collected $MFT with --mft (it wins over one auto-discovered in the collection), or turn the pass off with --no-timeline.
🛡️ Supported Checks
130 persistence checks across 11 MITRE ATT&CK techniques. Run python -m pyrsistencesniper --list-checks for the same list in the terminal.
| MITRE ID | Technique | Checks |
|---|---|---|
| T1037 | Boot/Logon Initialization Scripts | gp_scripts, logon_scripts |
| T1053 | Scheduled Task/Job | ghost_task, hidden_scheduled_task, scheduled_task_files |
| T1098 | Account Manipulation | admin_group_membership, rid_hijacking, rid_suborner |
| T1137 | Office Application Startup | office_addins, office_ai_hijack, office_dll_override, office_templates, office_test_dll, outlook_home_page, vba_monitors |
| T1197 | BITS Jobs | bits_notify_command |
| T1543 | Create or Modify System Process | service_failure_command, windows_service_dll, windows_service_image_path |
| T1546 | Event Triggered Execution | accessibility_tools, ae_debug, ae_debug_protected, amsi_providers, app_paths, appcert_dlls, appinit_dlls, assistive_technology, cmd_autorun, com_treat_as, disk_cleanup_handler, dotnet_dbg_managed_debugger, error_handler_cmd, explorer_clsid_hijack, file_association_hijack, ifeo_debugger, ifeo_delegated_ntdll, ifeo_silent_process_exit, installed_sdb, lsm_debugger, netsh_helper, power_automate, powershell_profiles, protocol_handler_hijack, recycle_bin_com_extension, screensaver, search_protocol_handler, shared_task_scheduler, shell_execute_hooks, shim_custom, telemetry_controller, typelib_hijack, wer_debugger, wer_hangs, wer_reflect_debugger, wer_runtime_exception, windows_terminal, wmi_event_subscription |
| T1547 | Boot/Logon Autostart Execution | active_setup, authentication_packages, boot_execute, boot_verification_program, dsrm_backdoor, explorer_app_key, explorer_bho, explorer_context_menu, explorer_load, font_drivers, lsa_cfg_flags, lsa_run_as_ppl, platform_execute, print_monitors, print_processors, rdp_clx_dll, rdp_virtual_channel, rdp_wds_startup, run_keys, run_services, run_services_once, s0_initial_command, scm_extension, security_packages, session_manager_execute, session_manager_subsystems, setup_execute, shell_folders_startup, shell_launcher, startup_folder, time_providers, ts_initial_program, winlogon_appsetup, winlogon_gina_dll, winlogon_mpnotify, winlogon_notify_packages, winlogon_shell, winlogon_system, winlogon_taskman, winlogon_userinit, winlogon_vmapplet |
| T1556 | Modify Authentication Process | lsa_password_filter, network_provider_dll |
| T1564 | Hide Artifacts | hidden_account |
| T1574 | Hijack Execution Flow | appdomain_manager, autodial_dll, chm_helper_dll, content_index_dll, cor_profiler, coreclr_profiler, crypto_expo_offload, diagtrack_dll, diagtrack_listener_dll, direct3d_dll, dll_search_mode, dotnet_framework_profiler, dotnet_startup_hooks, exclude_from_known_dlls, gp_extension_dlls, hhctrl_ocx_dll, known_dlls, known_managed_debugging_dlls, lsa_extensions, mapi32_dll_path, minidump_auxiliary_dlls, msdtc_xa_dll, nldp_dll, rdp_test_dvc_plugin, search_indexer_dll, server_level_plugin_dll, snmp_extension_agent, winsock_auto_proxy, wu_service_startup_dll |
⚙️ Detection Profiles
Detection profiles suppress known-good findings or force-flag specific values. Rules are written in YAML and apply globally or per check.
# Global allow rules, applied to all checks. Keep these narrow: a global rule
# merges into every check, so it cannot be anchored to any one artifact.
allow:
- path_matches: "\\\\Contoso\\\\"
reason: "Known enterprise software"
# Global block rules, force-flag regardless of other rules
block:
- value_matches: "suspicious\\.exe"
reason: "Known malicious binary"
# Per-check overrides
checks:
run_keys:
allow:
- value_matches: "SecurityHealthSystray"
reason: "Built-in Windows Security tray icon"
ghost_task:
enabled: false # Disable this check entirely
Rule fields
All fields are optional. Comparisons are case-insensitive.
path_matches, value_matches, hash and not_lolbin are hard conditions: every one
present must match or the rule does not apply at all. signer is a soft condition. A rule
whose hard conditions all match but whose signer does not still matches partially, which
lowers the finding to LOW rather than clearing it to INFO. Offline, a binary the
collection never captured has no signer to compare, and it must not lose its allowlist match
entirely.
| Field | Match Type | Description |
|---|---|---|
signer |
substring | Authenticode signer name |
path_matches |
regex | Registry key or file path (case-insensitive) |
value_matches |
regex | Registry value or command line (case-insensitive) |
hash |
exact | SHA-256 hash of the referenced file |
not_lolbin |
boolean | Only match if the entry does not execute via a LOLBin. Evaluated on the resolved payload and its launcher, so it rejects rundll32.exe <payload> as well as a bare rundll32.exe. The shipped profile no longer uses it: on a legitimate default that happens to be a LOLBin (msedge.exe, wab.exe) it silently prevents the rule from ever matching. |
reason |
n/a | Human-readable justification (shown in verbose output) |
Rule evaluation
Rules come from three layers, all evaluated together:
| Layer | Source | Scope |
|---|---|---|
| Plugin built-in | Hardcoded in each check's definition | That check only |
| Profile global | Top-level allow/block in the YAML |
All checks |
| Profile per-check | checks.<id>.allow/block in the YAML |
That check only |
Per-check profile rules add to global rules, they don't replace them. A check with one per-check allow rule and two global allow rules has three allow rules total.
Block rules win. The pipeline evaluates block rules first. If any block rule from any layer matches, the finding is HIGH and allow rules are not considered. Allow rules only affect findings that no block rule matched.
🛠️ Development
Poetry for dependency management, ruff for linting and formatting, mypy in strict mode, pytest for testing.
poetry install # Install with dev dependencies
poetry run pytest # Run tests
poetry run ruff check # Lint
poetry run ruff format # Format
poetry run mypy --strict # Type check
make all # All of the above
make cov # Tests with coverage report
Project layout
pyrsistencesniper/
cli.py # Entry point and argument parsing
config/ # Default detection profile
core/ # Domain models, offline artifact I/O, path normalization,
# metadata resolution, SAM and shell link parsing,
# detection profiles, logging
data/ # Bundled data files (LOLBin list)
detection/ # Pipeline orchestration and the declarative check engine
enrichment/ # Optional enrichment plugins
output/ # Console, CSV, HTML, XLSX renderers
plugins/ # Detection plugins, grouped by MITRE ATT&CK technique
__init__.py # Plugin discovery and registration
base.py # PersistencePlugin base class
T1547/ # Boot/logon autostart execution
T1546/ # Event-triggered execution
T1574/ # Hijack execution flow
T1543/ # Services
...
timeline/ # Last-change evidence: $MFT parser, event log correlation
ui/ # CLI presentation: banner, progress display
Adding a plugin
Plugins live in pyrsistencesniper/plugins/, organized by technique ID. Most checks are fully declarative:
from pyrsistencesniper.core.models import CheckDefinition, HiveScope, RegistryTarget
from pyrsistencesniper.plugins import register_plugin
from pyrsistencesniper.plugins.base import PersistencePlugin
@register_plugin
class LogonScripts(PersistencePlugin):
definition = CheckDefinition(
id="logon_scripts",
technique="Logon Scripts (UserInitMprLogonScript)",
mitre_id="T1037.001",
description=(
"UserInitMprLogonScript runs a script at user logon "
"before the desktop loads."
),
references=("https://attack.mitre.org/techniques/T1037/001/",),
targets=(
RegistryTarget(
path=r"Environment",
values="UserInitMprLogonScript",
scope=HiveScope.HKU,
),
),
)
The base class handles registry scanning, value extraction, and finding creation. For custom logic (filesystem walking, cross-referencing multiple hives), override run() and return a list[Finding]. The plugin gets self.context, self.registry and self.filesystem. High-level registry operations live on the context: load_subtree, open_hive_by_name, iter_user_hives, iter_usrclass_hives, resolve_clsid_default and resolve_clsid_inproc.
Pass time_evidence to _make_finding to tell the timeline stage what dates a finding: a FileWriteTime for the artifact's $MFT record, an EventLogTime to correlate against a log channel, or both.
🗺️ Roadmap
PyrsistenceSniper today is an offline Windows registry and filesystem hunter. These are the directions being considered, roughly in the order they are likely to land.
| Area | What it would add | Notes |
|---|---|---|
| Live mode | Scan the running host directly instead of a mounted image | The engine is deliberately offline-only today: every read goes through pyregf against hive files. Live mode means a second registry backend behind the same AnalysisContext API, plus the privilege and locked-hive handling that comes with it. |
| Linux persistence | systemd units and timers, cron, shell profiles, PAM, LD_PRELOAD, udev rules |
A separate artifact model and check family. The pipeline, profile and output layers are OS-agnostic already; the access layer is not. |
| macOS persistence | Launch agents and daemons, login items, emond, configuration profiles, TCC abuse |
Same shape as the Linux work, sharing whatever cross-platform artifact model that establishes. |
| Entra ID | Tenant-side persistence: app registrations and consent grants, federation trust changes, long-lived refresh tokens, role assignments | Identity persistence rather than host persistence. Would read exported tenant data rather than a disk image, so it is the furthest from the current architecture. |
📖 Background
PersistenceSniper by Federico Lagrasta and Autoruns by Sysinternals are the two tools that come up every time someone talks about Windows persistence detection. Both are great. Both were direct inspiration for this project.
Where we kept running into friction was the workflow. Autoruns is a Windows binary. If your analysis box runs Linux, you're out of luck. PersistenceSniper is PowerShell, which is powerful on live systems but awkward when you have twenty KAPE collections on a SIFT workstation. And when a new persistence technique drops, adding a check means working through a larger codebase rather than dropping in a single file.
We kept writing one-off scripts to cover the gaps, and at some point it made more sense to build something purpose-built.
🙏 Credits
- PersistenceSniper by Federico Lagrasta
- Autoruns by Sysinternals
- libregf by Joachim Metz
- MITRE ATT&CK
⚖️ License
Distributed under the MIT License. See LICENSE.
Metadata
Release files for pyrsistencesniper 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pyrsistencesniper-0.9.0.tar.gz | 149.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyrsistencesniper-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 361.8 kB
Release files / pyrsistencesniper-0.9.0.tar.gz
| Download URL | pyrsistencesniper-0.9.0.tar.gz |
|---|---|
| Size | 149.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
01401db7ed77a1a51a5be651d024173ffc9e95cdeab226183536ac3f34e015b8
|
|
BLAKE2b-256 checksum How to use checksums |
d7cdd0d8df2f90010f7c52d1399adfc758987c6373907f8715637d8ed7c4f05c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / pyrsistencesniper-0.9.0-py3-none-any.whl
| Download URL | pyrsistencesniper-0.9.0-py3-none-any.whl |
|---|---|
| Size | 212.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b18ea00f763aaf909f7229e14e82b1fe06ea056817e4da31f4aa6d723164b4ce
|
|
BLAKE2b-256 checksum How to use checksums |
1dcfe9147d92df14cf36fcdea4d1e0aaa3b68c6acce6ff1f2ddb2f15b4a89c8b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|