Chapter 186

Network Reconnaissance and Scanning

Network reconnaissance maps an environment from the inside — finding live hosts, open ports, services, and domain structure. Every technique leaves network-layer and host-layer telemetry. Detection engineering for recon relies heavily on anomaly over signature: a single port connection is normal; 254 connections to port 445 within 30 seconds from one host is a sweep. Understanding what's normal in your environment is the prerequisite.

Scenario

You have code execution on a workstation inside a /16 corporate network. You need to: enumerate live hosts, find all machines with SMB open (potential lateral movement targets), identify domain controllers (LDAP/Kerberos on 389/88), and find any web servers or VPNs reachable from this segment — all within the first 20 minutes, before the NOC's anomaly detection fires on sustained scanning activity.

Recon Phases and Telemetry

PhaseTechniqueNetwork artifactDetection source
Host discoveryARP sweep (local subnet)ARP requests to all .1–.254Network switch ARP table anomalies, NDR
Host discoveryICMP ping sweepICMP echo to subnet rangeFirewall logs, NetFlow, NDR
Port scanTCP SYN scanSYN packets to many ports/hostsFirewall deny logs, NetFlow byte counts
Service IDBanner grabbingTCP connect + short readIDS, application logs
AD reconLDAP queriesLDAP traffic to DC:389DC Event 4662, Defender for Identity
Share enumNetShareEnum via SMBSMB tree connects to multiple hostsEvent 5140 (share access), NetFlow

TCP Port Scanner in C

// Async TCP SYN-style scanner using non-blocking connect() + select().
// Scans a list of ports across a target IP range with configurable thread count.
// Faster than serial connect; avoids raw socket privileges needed for true SYN scan.

#include <winsock2.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib")

BOOL PortOpen(DWORD ip, USHORT port, int timeoutMs) {
    SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s == INVALID_SOCKET) return FALSE;

    // Set non-blocking
    u_long nb = 1;
    ioctlsocket(s, FIONBIO, &nb);

    struct sockaddr_in sa = {0};
    sa.sin_family = AF_INET;
    sa.sin_port   = htons(port);
    sa.sin_addr.s_addr = ip;

    connect(s, (struct sockaddr*)&sa, sizeof(sa));

    fd_set wfds; FD_ZERO(&wfds); FD_SET(s, &wfds);
    struct timeval tv = { timeoutMs / 1000, (timeoutMs % 1000) * 1000 };
    int r = select(0, NULL, &wfds, NULL, &tv);

    BOOL open = FALSE;
    if (r > 0) {
        int err; int len = sizeof(err);
        getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&err, &len);
        open = (err == 0);
    }
    closesocket(s);
    return open;
}

VOID ScanSubnet(BYTE baseA, BYTE baseB, BYTE baseC) {
    WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);

    static USHORT targets[] = {
        22, 80, 88, 135, 139, 389, 443,
        445, 3389, 5985, 8080, 8443
    };
    int nPorts = sizeof(targets) / sizeof(targets[0]);

    for (int h = 1; h <= 254; h++) {
        DWORD ip = inet_addr("");
        BYTE ipBytes[4] = { baseA, baseB, baseC, (BYTE)h };
        memcpy(&ip, ipBytes, 4);
        for (int p = 0; p < nPorts; p++) {
            if (PortOpen(ip, targets[p], 300)) {
                printf("%d.%d.%d.%d:%d OPEN\n",
                    baseA, baseB, baseC, h, targets[p]);
            }
        }
    }
    WSACleanup();
}

Internal AD Recon via NetAPI

// Windows NetAPI32 functions enumerate domain structure without LDAP.
// NetServerEnum: list all servers by type in a domain.
// NetShareEnum: list shares on a host. Both require only domain user auth.

#include <windows.h>
#include <lm.h>
#pragma comment(lib, "netapi32.lib")

VOID EnumDomainControllers(LPCWSTR domain) {
    SERVER_INFO_101* buf = NULL;
    DWORD total, resume = 0;

    // SV_TYPE_DOMAIN_CTRL | SV_TYPE_DOMAIN_BAKCTRL = all DCs
    NET_API_STATUS r = NetServerEnum(NULL, 101, (LPBYTE*)&buf,
        MAX_PREFERRED_LENGTH, &total, &total,
        SV_TYPE_DOMAIN_CTRL | SV_TYPE_DOMAIN_BAKCTRL,
        domain, &resume);
    if (r == NERR_Success || r == ERROR_MORE_DATA) {
        for (DWORD i = 0; i < total; i++)
            wprintf(L"DC: \\\\%s\n", buf[i].sv101_name);
        NetApiBufferFree(buf);
    }
}

VOID EnumShares(LPCWSTR server) {
    SHARE_INFO_1* buf = NULL;
    DWORD read, total, resume = 0;
    NET_API_STATUS r = NetShareEnum((LPWSTR)server, 1,
        (LPBYTE*)&buf, MAX_PREFERRED_LENGTH,
        &read, &total, &resume);
    if (r == NERR_Success) {
        for (DWORD i = 0; i < read; i++)
            wprintf(L"  %s\\%s (%s)\n",
                server, buf[i].shi1_netname, buf[i].shi1_remark);
        NetApiBufferFree(buf);
    }
}

ARP Sweep

// ARP sweep: send ARP requests to all hosts in a subnet.
// Responses identify live hosts (even if they block ICMP).
// Requires raw socket (admin) or use SendARP() — no raw socket needed.
// SendARP: Windows built-in; fires ARP for each IP; live hosts respond with MAC.

#include <winsock2.h>
#include <iphlpapi.h>
#pragma comment(lib, "iphlpapi.lib")
#pragma comment(lib, "ws2_32.lib")

VOID ArpSweep(DWORD networkBase, DWORD mask) {
    // networkBase = e.g., inet_addr("192.168.1.0")
    DWORD hostMin = (ntohl(networkBase) & ntohl(mask)) + 1;
    DWORD hostMax = hostMin | (~ntohl(mask)) - 1;

    for (DWORD h = hostMin; h <= hostMax; h++) {
        DWORD ip = htonl(h);
        ULONG mac[2] = {0};
        ULONG macLen = 6;
        DWORD r = SendARP(ip, 0, mac, &macLen);
        if (r == NO_ERROR) {
            BYTE* m = (BYTE*)mac;
            IN_ADDR addr; addr.S_un.S_addr = ip;
            printf("%s  MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
                inet_ntoa(addr), m[0],m[1],m[2],m[3],m[4],m[5]);
        }
    }
}

Service Banner Grabbing

// Banner grabbing: connect to an open port, send a minimal probe,
// read the response to identify the service and version.
// HTTP: send "HEAD / HTTP/1.0\r\n\r\n" → Server: header reveals web server
// SMTP: connect, read 220 banner
// SSH: connect, read SSH-2.0-OpenSSH_X.Y banner
// SMB: read first 4 bytes of NetBIOS header then SMB negotiate response

BOOL GrabBanner(LPCSTR ipStr, USHORT port,
                 char* banner, int bannerSz) {
    SOCKET s = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in sa = {0};
    sa.sin_family = AF_INET;
    sa.sin_port = htons(port);
    sa.sin_addr.s_addr = inet_addr(ipStr);

    int to = 2000;
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&to, sizeof(to));
    setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (char*)&to, sizeof(to));

    if (connect(s, (struct sockaddr*)&sa, sizeof(sa)) != 0) {
        closesocket(s); return FALSE;
    }

    // Send HTTP probe for port 80/8080/443
    if (port == 80 || port == 8080)
        send(s, "HEAD / HTTP/1.0\r\n\r\n", 18, 0);

    int n = recv(s, banner, bannerSz - 1, 0);
    if (n > 0) banner[n] = 0;
    closesocket(s);
    return (n > 0);
}

Detection Engineering

title: Port Scan — Many Distinct Ports Contacted from Single Host
logsource:
  product: windows
  category: network_connection
detection:
  selection:
    EventID: 3   # Sysmon network connect
    Initiated: 'true'
  timeframe: 60s
  condition: selection | count(DestinationPort) by SourceIp > 20
level: high
tags: [attack.discovery, T1046]

title: SMB Sweep — Connections to Many Hosts on Port 445
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5156  # Windows Filtering Platform connection allowed
    DestPort: 445
  timeframe: 30s
  condition: selection | count(DestAddress) by SourceAddress > 10
level: high

-- MDE KQL: host scanning many IPs on privileged ports
DeviceNetworkEvents
| where Timestamp > ago(5m)
| where RemotePort in (22, 80, 135, 139, 389, 443, 445, 3389, 5985)
| where ActionType == "ConnectionAttempted"
| summarize
    distinct_ips   = dcount(RemoteIP),
    distinct_ports = dcount(RemotePort),
    total          = count()
  by DeviceName, InitiatingProcessFileName, bin(Timestamp, 1m)
| where distinct_ips > 15 or distinct_ports > 8
| order by distinct_ips desc

-- NetAPI share enum: many 5140 events (share access) from one source
SecurityEvent
| where EventID == 5140
| where ShareName != "\\*\\IPC$"
| summarize shares_accessed = dcount(ShareName), hosts = dcount(Computer)
    by SubjectAccount, bin(TimeGenerated, 5m)
| where shares_accessed > 5 or hosts > 5

Q&A

An attacker uses Living-off-the-Land tools like net.exe, nltest.exe, and arp.exe for reconnaissance instead of a custom scanner — why is this harder to detect, and what behavioral analytics still catch it?

LOLBin-based reconnaissance is harder to detect at the tool level because each individual execution of net view, arp -a, nltest /dclist, or ping is a completely normal, signed Windows operation. Process creation signatures for these binaries fire constantly in enterprise environments. A helpdesk ticket triggers net user. Domain login scripts run nltest. There is no "this binary is suspicious" signal.

The detection pivot is temporal and contextual correlation, not per-process signatures. Three behavioral analytics that reliably catch LOLBin recon: (1) Process chain anomaly: net.exe, nltest.exe, and arp.exe executing within a 5-minute window with the same parent process (especially if that parent is cmd.exe or powershell.exe spawned from an unusual grandparent like winword.exe) is anomalous regardless of what each child does. The sequence, not the individual process, is the signal. (2) Account context anomaly: if the user account running these commands has never historically run nltest or net view /domain, and suddenly runs six of them in sequence from a workstation at 2 AM, ML-based user behavior analytics (UEBA) will flag the deviation from the user's baseline. (3) Output-to-network correlation: LOLBin recon discovers IP addresses and hostnames; a short time later, new outbound SMB connections to those IPs from the same host is the "recon → lateral movement" kill-chain pattern. Correlating discovery-phase LOLBin execution with subsequent new network connections to hosts not previously contacted by that workstation is a high-fidelity detection chain that is independent of which specific recon tool was used.