Skip to content

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.

FamilyStatic clue in a sampleTelemetry that records itATT&CK
Run / RunOnce keysRegCreateKeyExW / RegSetValueExW imports; the string Software\Microsoft\Windows\CurrentVersion\Run; reg add ... /v in a commandSysmon 12/13; key last-write timeT1547.001
Startup folderSHGetKnownFolderPath / SHGetFolderPathW (Startup folder IDs), IShellLinkW for .lnk creation, Start Menu\Programs\Startup stringsSysmon 11 in the Startup pathsT1547.001
Windows servicesOpenSCManagerW, CreateServiceW, ChangeServiceConfigW; sc create, New-Service strings; a ServiceMain export in a DLLSystem 7045, Security 4697, Sysmon 12/13 under ServicesT1543.003
Scheduled tasksschtasks /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\TasksT1053.005
WMI event subscriptionsStrings __EventFilter, CommandLineEventConsumer, ActiveScriptEventConsumer, __FilterToConsumerBinding, root\subscription; mofcomp or Set-WmiInstance / New-CimInstance in scriptsSysmon 19/20/21, WMI-Activity Operational 5861T1546.003
Winlogon valuesStrings Winlogon, Userinit, Shell next to registry-write importsSysmon 13 on the Winlogon keyT1547.004
DLL search-order hijackingA 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 sideSysmon 11 (DLL written beside an EXE), Sysmon 7 (unsigned image loaded from a non-system path)T1574.001
COM hijackingCLSID strings and Software\Classes\CLSID\{...}\InprocServer32 under HKCUSysmon 12/13 under HKU\<SID>_Classes\CLSID or HKCU\Software\Classes\CLSIDT1546.015
Linux: cron, systemd, shell profilescrontab -, /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 monitoringT1053.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 RegSetValueExW and 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 CreateServiceW from 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 to CreateProcessW or ShellExecuteW, 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:

  1. Autoruns before and after. Save an .arn file or an autorunsc CSV 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.
  2. Registry and file snapshot. Regshot or a similar diff catches changes Autoruns does not treat as autostarts, such as a COM InprocServer32 override for a rarely used class, or a configuration value the implant reads at start-up.
  3. 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 TargetObject contains \CurrentVersion\Run\ or \CurrentVersion\RunOnce\ (including the WOW6432Node and Policies\Explorer\Run variants), and whose Details contains \AppData\, \ProgramData\, \Users\Public\ or an interpreter such as powershell.exe or mshta.exe. Filter known writers by image path and value name. Remember that Sysmon writes HKCU as HKU\<SID>, so match the subpath, not the root.
  • Task creation by an unusual parent. Select Sysmon 1 events for schtasks.exe with /create in the command line, and alert when the parent is not one of your deployment tools or when /tr points to a user-writable path. The task's XML file under System32\Tasks is written by the Task Scheduler service itself, so Sysmon 11 always shows svchost.exe as the writer; use it for context, not for parent logic. Security 4698 covers tasks registered through the COM interface, which never start schtasks.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 /c or powershell, 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 ActiveScriptEventConsumer or 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 InprocServer32 under HKCU, 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.

  1. 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.
  2. Collect evidence first. Before changing anything: a memory image, an Autoruns CSV with hashes, the registry hives with their transaction logs, the System32\Tasks folder, 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

FieldExample
Mechanism and ATT&CK IDScheduled task, T1053.005
Location and name\DemoUpdateCheck in System32\Tasks and TaskCache
Trigger and payloadEvery 30 minutes; %APPDATA%\DemoCorp\demoupd.exe (SHA-256)
Privilege and scopeCurrent user; one host confirmed, fleet hunt pending
Created by and whenDemoInvoice.exe, 10:14:10 UTC, Sysmon 1 and 11
DetectionRule name or query that fires on it
Removal and verificationschtasks /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.

  1. Set up a working folder and virtual environment (standard library only):

    bash
    mkdir m7p && cd m7p
    python3 -m venv .venv
    .venv/bin/python --version
    text
    Python 3.14.7
  2. 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.jsonl
    text
    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>, not HKCU: that is how Sysmon records per-user keys.

  3. 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)
  4. Run it with --explain, which also prints the candidates the rules looked at and dismissed:

    bash
    .venv/bin/python detect_persistence.py events.jsonl --explain
    text
    [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 under Roaming, and a task created one second later by the same program for the same payload. The installer's DemoVendorTray Run value is correctly ignored: it was written by msiexec.exe and points into Program 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.

  5. 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 -1
    text
    [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 in ALLOWLIST_RUN pins both the writer path and the value name, but it is still only a path: a file named OneDrive.exe dropped in the same place would pass. A stronger version joins each Sysmon 13 event to the writer's Sysmon 1 event through ProcessGuid and checks the signer.

  6. 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.jsonl

    The 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.