2025-12-05

Evasive C2: Avoiding Hooks

EvasionSyscallsWindows InternalsC++

We have a secure channel. Now we need to survive. To understand how to hide malware, you first need to understand how security tools (EDR/AV) try to find it.

The Old Way: Win32 Injection

In the early days, if you wanted to inject shellcode into another process, you used the standard Windows API (Win32):

  1. OpenProcess(): Get a handle to the target.
  2. VirtualAllocEx(): Allocate memory in the target.
  3. WriteProcessMemory(): Copy your shellcode.
  4. CreateRemoteThread(): Execute it.

This is noisy. EDRs hook these functions in user-mode (kernel32.dll). When you call OpenProcess, the EDR checks the arguments. "Oh, you're trying to open lsass.exe? Blocked."

The Bypass: System Calls (Syscalls)

To bypass these hooks, we go lower. We talk directly to the Kernel. The functions in ntdll.dll are just wrappers. All they really do is set up a "ticket number" (called a System Service Number or SSN) in the EAX register and execute the syscall instruction.

Finding the SSN: Walking the PEB

How do we know that NtOpenProcess is SSN 0x26? We can't hardcode it because it changes with every Windows update. We must find it dynamically.

We do this by manually parsing the Process Environment Block (PEB).

  1. Find NTDLL: We read the GS register (x64) to find the PEB, then walk the Ldr (Loader Data) list to find the base address of ntdll.dll.
  2. Parse Exports: We read the Export Directory of ntdll.dll to find the address of ZwOpenProcess.
  3. Read the Bytes: We look at the first few bytes of the function.
    mov r10, rcx
    mov eax, <SSN>  ; We steal this number!
    syscall
    

Our Custom Implementation

We didn't just copy-paste SysWhispers (the standard tool for this). We modified it heavily to break signatures.

1. Custom Hashing Algorithm

Standard tools use well-known hashing algorithms (like DJB2) to find function names. EDRs know these constants. We implemented a custom, rolling XOR-rotate hash.

// syscalls.cpp
DWORD ComputeFunctionHash(PCSTR FunctionName) {
    DWORD Hash = HASH_SEED; // Random seed per build
    // ... custom bitwise operations ...
    Hash = ROTATE_OP(Hash) ^ XOR_MAGIC;
    return Hash;
}

This means our agent's "Import Table" (hashes) looks completely random and different for every build.

2. Obfuscated PEB Walking

Standard shellcode walks the PEB in a predictable way. We added "junk code" and obfuscated logic. Instead of comparing strings like "ntdll.dll", we compare hashes of the DLL names, and we split the checks into multiple non-linear steps to confuse emulators.

3. Junk Assembly (Polymorphism)

EDRs scan for the syscall stub patterns. Standard SysWhispers stub:

mov eax, [SSN]
syscall
ret

Our stub injects random "junk" instructions that do nothing but change the byte signature:

mov [rsp+8], rcx    ; Save registers (Standard)
xor r11, r11        ; Junk
add r11, 1          ; Junk
dec r11             ; Junk
mov eax, [SSN]
syscall
nop                 ; Padding
ret

This breaks static signatures that look for the exact byte sequence of a syscall stub.

The Evolution: Indirect Syscalls

Even with all this, executing syscall in our own memory is suspicious ("Mark of the Syscall"). So we use Indirect Syscalls.

Instead of executing syscall ourselves, we find a syscall instruction inside ntdll.dll and JMP to it.

sequenceDiagram
    participant Agent
    participant NTDLL
    participant Kernel

    Note over Agent: 1. Set SSN (EAX)
    Note over Agent: 2. Set Arguments
    Agent->>NTDLL: 3. JMP to "syscall" instruction
    NTDLL->>Kernel: 4. CPU executes syscall
    Kernel-->>NTDLL: 5. Returns to NTDLL
    NTDLL-->>Agent: 6. Returns to Agent

This technique, popularized by tools like SysWhispers3 and research by KlezVirus, ensures that the call stack looks legitimate. The kernel sees the return address pointing to ntdll.dll, not our malware.

In the next post, we'll use these stealthy techniques to weaponize the agent.