Lesson 7.2 · Malware Behaviours· 45 min
Persistence Mechanisms
Recognise persistence in a sample, detect it in Sysmon and Windows event logs, and remove every foothold in the right order during incident response.
Objectives
- Explain why persistence is the main remediation lever and how it maps to the ATT&CK Persistence tactic
- Map each persistence family to its static clues in a sample, the telemetry that records it and its ATT&CK ID
- Confirm persistence in the lab with snapshot and Autoruns comparisons, and express detections as Sigma-style logic
- Run remediation in the right order: contain, collect, remove every persistence point, verify and watch for re-infection
The Registry, Services and Scheduled Tasks showed you where Windows keeps the instructions it follows at boot and logon, and how to read them live or from a disk image. This lesson looks at the same ground from the other side of an incident: how you spot persistence while analysing a sample, how you write detections that fire when it is installed, and how you remove it so the intruder does not simply come back after the next reboot.
Persistence is ATT&CK tactic TA0003: every technique an attacker uses to keep access across reboots, logoffs and changed credentials. Most malware does it once, seconds after first execution, which makes it one of the most reliably observable things a sample does.
Why persistence is your remediation lever
Much of what a sample does is gone once the process ends: the beacon stops, memory is freed, temporary files are deleted. Persistence is what remains on disk and has to be removed. It is also the point where the attacker has to write something to a location Windows reads on its own, and those locations are limited, documented and usually logged.
That gives persistence three roles in your work:
- In analysis, it is a question every report must answer: how does this survive a reboot? A sample with no persistence is either a one-shot (a stealer that exfiltrates and exits), a stage run by something else (a loader, a macro, a service it was installed as), or something that only lives in memory until the host restarts. Each answer changes the response.
- In detection, persistence writes to a small set of keys, folders and stores, so a handful of good rules covers a large share of commodity malware.
- In incident response, the list of persistence points is the cleanup plan. Miss one and the implant comes back. Remove them in the wrong order and a watchdog reinstalls what you just deleted.
A persistence entry has two parts: the trigger (the Run value, the task, the service, the WMI binding) and the payload it points to (an EXE, a DLL, a script, or an encoded command line). Your report, your detection and your cleanup must cover both.
A family-level map
The techniques catalogue has a page for each mechanism, with its internals and variants. The table below is the analyst's summary: what to look for in a sample before running it, what records the change on a host, and which ATT&CK ID to cite. Autostart locations and the fields to read were covered in the previous module; here the focus is on the evidence each family leaves.
| Family | Static clue in a sample | Telemetry that records it | ATT&CK |
|---|---|---|---|
| Run / RunOnce keys | RegCreateKeyExW / RegSetValueExW imports; the string Software\Microsoft\Windows\CurrentVersion\Run; reg add ... /v in a command | Sysmon 12/13; key last-write time | T1547.001 |
| Startup folder | SHGetKnownFolderPath / SHGetFolderPathW (Startup folder IDs), IShellLinkW for .lnk creation, Start Menu\Programs\Startup strings | Sysmon 11 in the Startup paths | T1547.001 |
| Windows services | OpenSCManagerW, CreateServiceW, ChangeServiceConfigW; sc create, New-Service strings; a ServiceMain export in a DLL | System 7045, Security 4697, Sysmon 12/13 under Services | T1543.003 |
| Scheduled tasks | schtasks /create command lines; taskschd.dll or CoCreateInstance with the Schedule.Service class; embedded task XML (<Triggers>, <Exec>) | Security 4698, TaskScheduler Operational 106, Sysmon 1 for schtasks.exe, Sysmon 11 in System32\Tasks | T1053.005 |
| WMI event subscriptions | Strings __EventFilter, CommandLineEventConsumer, ActiveScriptEventConsumer, __FilterToConsumerBinding, root\subscription; mofcomp or Set-WmiInstance / New-CimInstance in scripts | Sysmon 19/20/21, WMI-Activity Operational 5861 | T1546.003 |
| Winlogon values | Strings Winlogon, Userinit, Shell next to registry-write imports | Sysmon 13 on the Winlogon key | T1547.004 |
| DLL search-order hijacking | A DLL named like a system library (version.dll, dbghelp.dll) that forwards exports to the real one; a dropper that writes a legitimate signed EXE and a DLL side by side | Sysmon 11 (DLL written beside an EXE), Sysmon 7 (unsigned image loaded from a non-system path) | T1574.001 |
| COM hijacking | CLSID strings and Software\Classes\CLSID\{...}\InprocServer32 under HKCU | Sysmon 12/13 under HKU\<SID>_Classes\CLSID or HKCU\Software\Classes\CLSID | T1546.015 |
| Linux: cron, systemd, shell profiles | crontab -, /etc/cron.d/, /var/spool/cron; [Service] / ExecStart= unit text, systemctl enable; .bashrc, /etc/profile.d/ | auditd file watches on those paths, journal entries for new units, file integrity monitoring | T1053.003, T1543.002, T1546.004 |
Several less common mechanisms follow the same pattern and have their own
pages: Image File Execution Options
(T1546.012), AppInit DLLs (T1546.010) and
BITS jobs (T1197). Logon scripts set through the
UserInitMprLogonScript value (T1037.001) are another quiet variant worth
knowing.
Reading the static clues correctly
A clue is a lead, not a verdict. RegSetValueExW is imported by almost every
Windows program, so the import alone tells you nothing; the import plus
a Run-key path in the decoded strings is a strong lead. Three habits keep
static reading honest:
- Decode before you search. Persistence paths are exactly the strings
malware authors encrypt or build on the stack. A sample that imports
RegSetValueExWand has no visible registry path is a reason to run the string decryption you learned in Scripting String Decryption, not a reason to rule out Run keys. - Look for resolved imports. A sample that resolves APIs by hash hides
CreateServiceWfrom the IAT. Capability tools and a short look at the resolver often recover them, as covered in Reading Imports. - Follow the command lines. Much persistence is delegated to built-in
tools:
schtasks,sc,reg,powershell,wmic. The clue is then a string passed toCreateProcessWorShellExecuteW, and the telemetry is a Sysmon 1 event with that command line.
Record which mechanism, which name, which payload path and which privilege. A per-user Run value needs no administrator rights; a service, a machine-wide task or a WMI subscription does, which tells responders how far the intruder got.
Confirming persistence in the lab
Static clues say what a sample can install. Confirmation needs a run under the routine from Behavioural Monitoring: clean snapshot, baseline, run, diff. For persistence, three comparisons do most of the work:
- Autoruns before and after. Save an
.arnfile or anautorunscCSV on the clean snapshot, run the sample, collect again and compare. Every new entry is a candidate, and Autoruns resolves each one to a file and a signature status. Include the Scheduled Tasks, Services and WMI tabs; they are easy to forget when the Logon tab already shows a hit. - Registry and file snapshot. Regshot or a similar diff catches changes
Autoruns does not treat as autostarts, such as a COM
InprocServer32override for a rarely used class, or a configuration value the implant reads at start-up. - Sysmon for attribution. The diffs tell you what changed; Sysmon 1, 11, 12/13 and 19–21 tell you which process made each change and in which order. That order matters later: if a scheduled task recreates a Run value every thirty minutes, removing the Run value first is wasted effort.
Then reboot the snapshot and log on again. Persistence that is installed but does not fire (a wrong path, an expired trigger, a task that needs a network) is still a finding, but it is a different one from a working implant. Many samples also install persistence only after a successful check-in with their server, so a quiet run in a disconnected lab does not prove the sample has none; the static clues and a simulated network, as in Network Simulation, help close that gap.
Tip: Keep the post-run Autoruns output as an evidence file, not just a screenshot. The CSV holds the exact value names, command lines and hashes you will need for detections and for the cleanup checklist.
Writing detections
Detections for persistence work best as a small set of rules that each ask one question about one kind of event. Four patterns cover a large share of what commodity malware does. Written as Sigma-style logic, in prose:
- Run-key write to a user-writable path. Select Sysmon 13 events whose
TargetObjectcontains\CurrentVersion\Run\or\CurrentVersion\RunOnce\(including theWOW6432NodeandPolicies\Explorer\Runvariants), and whoseDetailscontains\AppData\,\ProgramData\,\Users\Public\or an interpreter such aspowershell.exeormshta.exe. Filter known writers by image path and value name. Remember that Sysmon writesHKCUasHKU\<SID>, so match the subpath, not the root. - Task creation by an unusual parent. Select Sysmon 1 events for
schtasks.exewith/createin the command line, and alert when the parent is not one of your deployment tools or when/trpoints to a user-writable path. The task's XML file underSystem32\Tasksis written by the Task Scheduler service itself, so Sysmon 11 always showssvchost.exeas the writer; use it for context, not for parent logic. Security 4698 covers tasks registered through the COM interface, which never startschtasks.exe. - Service installation with an odd binary. Select System 7045 (or Security
4697) where the image path is in a user-writable directory, uses
cmd.exe /corpowershell, or where the service name is random-looking. Services installed by remote-execution tools deserve their own rule. - Any WMI permanent subscription. Sysmon 19, 20 and 21 are rare on
a normal workstation. Alert on all of them, raise severity for
ActiveScriptEventConsumeror a command-line consumer that starts an interpreter, and allowlist the few management products you have reviewed.
SigmaHQ already has mature rules for most of these patterns; their filter sections record the false positives other teams have hit.
Tip: Community Sysmon configurations already include the common persistence keys and folders, but check yours for the less common ones in the table above (COM
InprocServer32underHKCU,Winlogon, IFEO). A rule on an event your sensor never collects will never fire, and nothing will tell you so.
Tuning without going blind
Every persistence rule meets legitimate software. Per-user installers (browser
updaters, chat clients, sync tools) live under AppData by design, and
IT management agents register WMI subscriptions. Tune with narrow, reviewed
exceptions: the writer's full path and the value name, ideally joined with
the writer's signature from its Sysmon 1 or 7 event. An exception on the
value name alone is an invitation: an attacker who names their Run value
OneDrive walks straight through it.
Remediation during incident response
Once analysis and hunting have produced a list of persistence points, the cleanup order decides whether it holds.
- Contain. Isolate the host from the network through your EDR or a switch port, but keep it powered on. A reboot fires every persistence mechanism, destroys memory evidence and may trigger payloads that only run at start-up.
- Collect evidence first. Before changing anything: a memory image, an
Autoruns CSV with hashes, the registry hives with their transaction logs,
the
System32\Tasksfolder, the WMI repository (wbem\Repository), the event logs and every payload file the entries point to. Once you delete a Run value, its key last-write time changes and the original evidence is gone. - Scope before removing. Turn the findings into hunts across the fleet: the value names, task names, service names, payload hashes and paths. If ten hosts carry the same task, cleaning one tells the attacker you have noticed and leaves nine.
- Remove every persistence point in one planned window. Stop or suspend the implant's processes first, then remove triggers and payloads together, across all affected hosts at once. Samples often install two or three mechanisms so that each can restore the others; a scheduled task that rewrites a Run value is common. Remove what the attacker used to get in as well, and reset the credentials they held; persistence cleanup is not the same as eviction.
- Verify with a fresh baseline. Collect Autoruns again and compare it with your gold image or a clean host of the same build. Rerun the hunts. Reboot and check again, since some mechanisms only reappear after a start-up.
- Watch for re-infection. Keep the specific indicators on high-priority alerting for weeks, not days. A persistence entry that comes back means you missed a mechanism, a host, or the initial access path.
When the implant ran with administrative or SYSTEM rights, installed a driver, or you cannot be sure you found every mechanism, rebuilding the host from a known-good image is usually cheaper than proving a cleanup complete.
Reporting persistence
The triage report puts persistence under capabilities and host indicators. For incident response, give each persistence point its own row so it can be used as a checklist:
| Field | Example |
|---|---|
| Mechanism and ATT&CK ID | Scheduled task, T1053.005 |
| Location and name | \DemoUpdateCheck in System32\Tasks and TaskCache |
| Trigger and payload | Every 30 minutes; %APPDATA%\DemoCorp\demoupd.exe (SHA-256) |
| Privilege and scope | Current user; one host confirmed, fleet hunt pending |
| Created by and when | DemoInvoice.exe, 10:14:10 UTC, Sysmon 1 and 11 |
| Detection | Rule name or query that fires on it |
| Removal and verification | schtasks /delete, delete payload; Autoruns compare after reboot |
State which mechanisms were observed in a run and which were inferred from code: "can install a service when run as administrator (code only)" is not the same finding as "installed service X".
Lab: detect persistence in Sysmon-style events
In this lab you generate a small file of synthetic Sysmon-style events for one workstation, including a simulated intrusion that sets a Run value and registers a scheduled task, and write a Python script that applies four detection rules to it. Nothing in this lab creates persistence anywhere: the events are JSON lines, and every name and path is invented.
-
Set up a working folder and virtual environment (standard library only):
bash mkdir m7p && cd m7p python3 -m venv .venv .venv/bin/python --versiontext Python 3.14.7 -
Generate the events. The fields follow Sysmon's names (
Image,ParentImage,TargetObject,Details,TargetFilename, and the WMI fields of events 19–21). The normal activity includes an installer that adds a machine-wide Run value and a task, OneDrive registering itself, and an IT inventory agent's WMI subscription:python # make_events.py - writes events.jsonl: synthetic Sysmon-style events for one workstation. # Nothing here touches the registry, the Task Scheduler or WMI; it only writes JSON. import json HOST = "WS-DEMO-07" SID = "S-1-5-21-1111111111-2222222222-3333333333-1001" HKU_RUN = rf"HKU\{SID}\Software\Microsoft\Windows\CurrentVersion\Run" HKLM_RUN = r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" APPDATA = r"C:\Users\demo\AppData\Roaming" LOCAL = r"C:\Users\demo\AppData\Local" DROPPER = r"C:\Users\demo\Downloads\DemoInvoice.exe" IMPLANT = APPDATA + r"\DemoCorp\demoupd.exe" def ev(t, eid, **f): return {"UtcTime": f"2026-09-29 {t}", "EventID": eid, "Computer": HOST, **f} events = [ # --- normal workstation activity --- ev("08:58:02.114", 1, Image=r"C:\Program Files\DemoBrowser\browser.exe", ParentImage=r"C:\Windows\explorer.exe", CommandLine=r'"C:\Program Files\DemoBrowser\browser.exe"'), ev("08:58:40.301", 13, EventType="SetValue", Image=r"C:\Windows\explorer.exe", TargetObject=rf"HKU\{SID}\Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs\MRUListEx", Details="Binary Data"), ev("09:01:15.870", 1, Image=r"C:\Windows\System32\msiexec.exe", ParentImage=r"C:\Windows\System32\services.exe", CommandLine=r"C:\Windows\system32\msiexec.exe /V"), ev("09:01:19.442", 13, EventType="SetValue", Image=r"C:\Windows\System32\msiexec.exe", TargetObject=HKLM_RUN + r"\DemoVendorTray", Details=r'"C:\Program Files\DemoVendor\tray.exe" /minimized'), ev("09:01:20.013", 1, Image=r"C:\Windows\System32\schtasks.exe", ParentImage=r"C:\Windows\System32\msiexec.exe", CommandLine=r'schtasks.exe /create /tn "DemoVendor\Update" /tr "\"C:\Program Files\DemoVendor\updater.exe\"" /sc daily /st 03:00 /ru SYSTEM /f'), ev("09:01:20.391", 11, Image=r"C:\Windows\system32\svchost.exe", TargetFilename=r"C:\Windows\System32\Tasks\DemoVendor\Update"), ev("09:05:33.508", 13, EventType="SetValue", Image=LOCAL + r"\Microsoft\OneDrive\OneDrive.exe", TargetObject=HKU_RUN + r"\OneDrive", Details=rf'"{LOCAL}\Microsoft\OneDrive\OneDrive.exe" /background'), # A management agent's WMI subscription (fictional product, installed by IT) ev("09:12:00.250", 19, EventType="WmiFilterEvent", Operation="Created", User=r"NT AUTHORITY\SYSTEM", EventNamespace='"root\\cimv2"', Name='"DemoInventoryFilter"', Query='"SELECT * FROM __InstanceModificationEvent WITHIN 3600 WHERE TargetInstance ISA \'Win32_LocalTime\' AND TargetInstance.Hour = 2"'), ev("09:12:00.262", 20, EventType="WmiConsumerEvent", Operation="Created", User=r"NT AUTHORITY\SYSTEM", Name='"DemoInventoryConsumer"', Type="Command Line", Destination=r'"\"C:\Program Files\DemoInventory\collect.exe\" --quiet"'), ev("09:12:00.271", 21, EventType="WmiBindingEvent", Operation="Created", User=r"NT AUTHORITY\SYSTEM", Consumer='"CommandLineEventConsumer.Name=\\"DemoInventoryConsumer\\""', Filter='"__EventFilter.Name=\\"DemoInventoryFilter\\""'), # --- simulated intrusion (all names fictional) --- ev("10:14:07.906", 1, Image=DROPPER, ParentImage=r"C:\Windows\explorer.exe", CommandLine=f'"{DROPPER}"'), ev("10:14:09.118", 12, EventType="CreateKey", Image=DROPPER, TargetObject=rf"HKU\{SID}\Software\DemoCorp"), ev("10:14:09.552", 11, Image=DROPPER, TargetFilename=IMPLANT), ev("10:14:09.804", 13, EventType="SetValue", Image=DROPPER, TargetObject=HKU_RUN + r"\DemoUpdater", Details=IMPLANT), ev("10:14:10.230", 1, Image=r"C:\Windows\System32\schtasks.exe", ParentImage=DROPPER, CommandLine=f'schtasks.exe /create /tn "DemoUpdateCheck" /tr "{IMPLANT}" /sc minute /mo 30 /f'), ev("10:14:10.611", 11, Image=r"C:\Windows\system32\svchost.exe", TargetFilename=r"C:\Windows\System32\Tasks\DemoUpdateCheck"), ] with open("events.jsonl", "w") as f: for e in events: f.write(json.dumps(e) + "\n") print(f"wrote {len(events)} events")bash .venv/bin/python make_events.py sed -n 14p events.jsonltext wrote 16 events {"UtcTime": "2026-09-29 10:14:09.804", "EventID": 13, "Computer": "WS-DEMO-07", "EventType": "SetValue", "Image": "C:\\Users\\demo\\Downloads\\DemoInvoice.exe", "TargetObject": "HKU\\S-1-5-21-1111111111-2222222222-3333333333-1001\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\DemoUpdater", "Details": "C:\\Users\\demo\\AppData\\Roaming\\DemoCorp\\demoupd.exe"}The registry path starts with
HKU\<SID>, notHKCU: that is how Sysmon records per-user keys. -
Write the detection script. Each rule looks at one event and returns nothing (not its business), an ignore with a reason (a candidate that failed a condition or matched an exception), or an alert with its ATT&CK ID. The task rule adds the task XML file written by the Task Scheduler service within five seconds as context:
python # detect_persistence.py - apply persistence detection rules to Sysmon-style JSON lines. # Usage: detect_persistence.py events.jsonl [--explain] [--no-tuning] import json, re, sys from datetime import datetime USER_WRITABLE = re.compile(r"\\users\\[^\\]+\\(appdata|downloads|desktop|documents)\\|\\programdata\\|\\windows\\temp\\|\\users\\public\\", re.I) INTERPRETERS = re.compile(r"(^|[\\\s\"])(powershell|pwsh|cmd|wscript|cscript|mshta|rundll32|regsvr32)\.exe", re.I) RUN_KEY = re.compile(r"\\(Software\\(WOW6432Node\\)?Microsoft\\Windows\\CurrentVersion\\(Run|RunOnce)" r"|Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer\\Run)\\", re.I) STARTUP_DIR = re.compile(r"\\Start Menu\\Programs\\Startup\\", re.I) TASKS_DIR = re.compile(r"^C:\\Windows\\System32\\Tasks\\", re.I) EXPECTED_TASK_PARENTS = {"msiexec.exe", "svchost.exe", "services.exe", "ccmexec.exe"} # Tuning: known-good (writer image suffix, value name) pairs for Run-key writes. ALLOWLIST_RUN = [(r"\microsoft\onedrive\onedrive.exe", "onedrive")] ATTACK = {"RUN": "T1547.001", "STARTUP": "T1547.001", "TASK": "T1053.005", "WMI": "T1546.003"} def base(p): return p.lower().rsplit("\\", 1)[-1] def ts(e): return datetime.strptime(e["UtcTime"], "%Y-%m-%d %H:%M:%S.%f") def rule_run(e, tuned): if e["EventID"] != 13 or not RUN_KEY.search(e.get("TargetObject", "")): return None name = e["TargetObject"].rsplit("\\", 1)[-1] data, writer = e.get("Details", ""), e.get("Image", "") if not (USER_WRITABLE.search(data) or INTERPRETERS.search(data)): return ("ignore", f"Run value '{name}': data not in a user-writable path or interpreter") if tuned and any(writer.lower().endswith(w) and name.lower() == n for w, n in ALLOWLIST_RUN): return ("ignore", f"Run value '{name}': allowlisted writer {base(writer)}") sev = "HIGH" if USER_WRITABLE.search(writer) else "MEDIUM" return ("alert", "RUN", sev, f"Run value '{name}' -> {data}", f"written by {writer}") def rule_startup(e, tuned): if e["EventID"] == 11 and STARTUP_DIR.search(e.get("TargetFilename", "")): return ("alert", "STARTUP", "MEDIUM", f"file in Startup folder: {e['TargetFilename']}", f"written by {e.get('Image')}") return None def rule_task(e, tuned, events): if e["EventID"] != 1 or base(e.get("Image", "")) != "schtasks.exe" or "/create" not in e["CommandLine"].lower(): return None parent, cmd = e.get("ParentImage", ""), e["CommandLine"] reasons = [] if base(parent) not in EXPECTED_TASK_PARENTS: reasons.append(f"unusual parent {base(parent)}") if USER_WRITABLE.search(cmd): reasons.append("action in user-writable path") m = re.search(r'/tn\s+"?([^"]+?)"?\s+/', cmd, re.I) task = m.group(1) if m else "?" if not reasons: return ("ignore", f"task '{task}': created by {base(parent)}, action outside user-writable paths") # correlate: the Task Scheduler service writes the XML definition shortly afterwards xml = [x for x in events if x["EventID"] == 11 and x["Computer"] == e["Computer"] and TASKS_DIR.search(x.get("TargetFilename", "")) and 0 <= (ts(x) - ts(e)).total_seconds() <= 5] ctx = [f"parent {parent}", f"cmd: {cmd}"] + [f"task file {x['TargetFilename']} (by {base(x['Image'])})" for x in xml] return ("alert", "TASK", "HIGH" if len(reasons) > 1 else "MEDIUM", f"schtasks /create '{task}': " + "; ".join(reasons), *ctx) def rule_wmi(e, tuned): if e["EventID"] not in (19, 20, 21): return None what = {19: f"filter {e.get('Name')} query={e.get('Query')}", 20: f"consumer {e.get('Name')} type={e.get('Type')} dest={e.get('Destination')}", 21: f"binding {e.get('Consumer')} <- {e.get('Filter')}"}[e["EventID"]] dest = e.get("Destination", "") sev = "HIGH" if (USER_WRITABLE.search(dest) or INTERPRETERS.search(dest) or e.get("Type") == "Script") else "MEDIUM" return ("alert", "WMI", sev, f"WMI {e.get('Operation', '').lower()} {what}", f"user {e.get('User')}") def main(path, explain, tuned): events = [json.loads(l) for l in open(path, encoding="utf-8-sig") if l.strip()] alerts = ignored = 0 for e in events: for r in (rule_run(e, tuned), rule_startup(e, tuned), rule_task(e, tuned, events), rule_wmi(e, tuned)): if r is None: continue if r[0] == "ignore": ignored += 1 if explain: print(f"[ignored] {e['UtcTime']} EID {e['EventID']:<2} {r[1]}") continue _, rid, sev, msg, *ctx = r alerts += 1 print(f"[{sev:<6}] {e['UtcTime']} {e['Computer']} EID {e['EventID']:<2} {ATTACK[rid]} {msg}") for c in ctx: print(f" {c}") print(f"-- {len(events)} events, {alerts} alerts, {ignored} candidates ignored (tuning {'on' if tuned else 'off'})") if __name__ == "__main__": args = sys.argv[1:] main(args[0], "--explain" in args, "--no-tuning" not in args) -
Run it with
--explain, which also prints the candidates the rules looked at and dismissed:bash .venv/bin/python detect_persistence.py events.jsonl --explaintext [ignored] 2026-09-29 09:01:19.442 EID 13 Run value 'DemoVendorTray': data not in a user-writable path or interpreter [ignored] 2026-09-29 09:01:20.013 EID 1 task 'DemoVendor\Update': created by msiexec.exe, action outside user-writable paths [ignored] 2026-09-29 09:05:33.508 EID 13 Run value 'OneDrive': allowlisted writer onedrive.exe [MEDIUM] 2026-09-29 09:12:00.250 WS-DEMO-07 EID 19 T1546.003 WMI created filter "DemoInventoryFilter" query="SELECT * FROM __InstanceModificationEvent WITHIN 3600 WHERE TargetInstance ISA 'Win32_LocalTime' AND TargetInstance.Hour = 2" user NT AUTHORITY\SYSTEM [MEDIUM] 2026-09-29 09:12:00.262 WS-DEMO-07 EID 20 T1546.003 WMI created consumer "DemoInventoryConsumer" type=Command Line dest="\"C:\Program Files\DemoInventory\collect.exe\" --quiet" user NT AUTHORITY\SYSTEM [MEDIUM] 2026-09-29 09:12:00.271 WS-DEMO-07 EID 21 T1546.003 WMI created binding "CommandLineEventConsumer.Name=\"DemoInventoryConsumer\"" <- "__EventFilter.Name=\"DemoInventoryFilter\"" user NT AUTHORITY\SYSTEM [HIGH ] 2026-09-29 10:14:09.804 WS-DEMO-07 EID 13 T1547.001 Run value 'DemoUpdater' -> C:\Users\demo\AppData\Roaming\DemoCorp\demoupd.exe written by C:\Users\demo\Downloads\DemoInvoice.exe [HIGH ] 2026-09-29 10:14:10.230 WS-DEMO-07 EID 1 T1053.005 schtasks /create 'DemoUpdateCheck': unusual parent demoinvoice.exe; action in user-writable path parent C:\Users\demo\Downloads\DemoInvoice.exe cmd: schtasks.exe /create /tn "DemoUpdateCheck" /tr "C:\Users\demo\AppData\Roaming\DemoCorp\demoupd.exe" /sc minute /mo 30 /f task file C:\Windows\System32\Tasks\DemoUpdateCheck (by svchost.exe) -- 16 events, 5 alerts, 3 candidates ignored (tuning on)The two HIGH alerts are the intrusion: a Run value written by a program in
Downloads, pointing to a new EXE underRoaming, and a task created one second later by the same program for the same payload. The installer'sDemoVendorTrayRun value is correctly ignored: it was written bymsiexec.exeand points intoProgram Files. The installer's task is ignored for the same reasons. The inventory agent's WMI subscription raises three MEDIUM alerts, as intended for a rare event type: an analyst reviews it once and records a decision. -
See what the tuning is doing. Run again with the allowlist disabled:
bash .venv/bin/python detect_persistence.py events.jsonl --no-tuning | grep -A1 OneDrive .venv/bin/python detect_persistence.py events.jsonl --no-tuning | tail -1text [HIGH ] 2026-09-29 09:05:33.508 WS-DEMO-07 EID 13 T1547.001 Run value 'OneDrive' -> "C:\Users\demo\AppData\Local\Microsoft\OneDrive\OneDrive.exe" /background written by C:\Users\demo\AppData\Local\Microsoft\OneDrive\OneDrive.exe [MEDIUM] 2026-09-29 09:12:00.250 WS-DEMO-07 EID 19 T1546.003 WMI created filter "DemoInventoryFilter" query="SELECT * FROM __InstanceModificationEvent WITHIN 3600 WHERE TargetInstance ISA 'Win32_LocalTime' AND TargetInstance.Hour = 2" -- 16 events, 6 alerts, 2 candidates ignored (tuning off)Tuning note: without the exception, OneDrive's own Run value fires as HIGH, because both the payload and the writer live under
AppData. Every per-user application does this, so in production the rule would bury the real alerts. The exception inALLOWLIST_RUNpins both the writer path and the value name, but it is still only a path: a file namedOneDrive.exedropped in the same place would pass. A stronger version joins each Sysmon 13 event to the writer's Sysmon 1 event throughProcessGuidand checks the signer. -
Try it on real telemetry. In an analysis VM with Sysmon installed, export recent events to the same JSON-lines shape and run the script on them. This PowerShell flattens each event's
EventData:powershell Get-WinEvent -LogName 'Microsoft-Windows-Sysmon/Operational' -MaxEvents 5000 | Where-Object Id -in 1, 11, 12, 13, 19, 20, 21 | ForEach-Object { $h = [ordered]@{ EventID = $_.Id; Computer = $_.MachineName } ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $h[$_.Name] = $_.'#text' } $h | ConvertTo-Json -Compress } | Set-Content -Encoding utf8 C:\analysis\events.jsonlThe script reads UTF-8 with or without a byte-order mark. Install a per-user application and check which rule fires and why.
Questions to answer: The Run rule looks for \AppData\ in the value
data; what happens if a sample stores %APPDATA%\DemoCorp\demoupd.exe as a
REG_EXPAND_SZ, and how would you fix the pattern? Why can the task rule not
use the parent of the Sysmon 11 event for the XML file, and which Windows event
covers tasks registered through COM without schtasks.exe? Which single
extra rule would have caught the dropper writing demoupd.exe before any
persistence existed? During cleanup of WS-DEMO-07, in what order would you
remove the task, the Run value and the payload, and what would you collect
before touching any of them?
Key takeaways
- Persistence is what an intrusion leaves behind on disk and what cleanup has to remove; every analysis should state how a sample survives a reboot, or why it does not.
- Each persistence family has static clues (APIs, strings, command lines), telemetry that records it (Sysmon 1/11/12/13/19–21, System 7045, Security 4697/4698) and an ATT&CK ID; decode strings and follow delegated command lines before concluding a sample has none.
- Confirm in the lab with Autoruns and snapshot diffs, use Sysmon to attribute each change to a process, and reboot to see which mechanisms actually fire.
- Good persistence detections ask one narrow question each; tune them with reviewed exceptions that pin the writer and the name, not the name alone.
- In incident response, contain, collect evidence, scope across the fleet, remove every mechanism and its payload in one window, verify against a clean baseline and keep watching for re-infection.
- Report each persistence point as a checklist row: mechanism, location, payload, privilege, origin, detection, removal and verification.