network security

Detecting Lateral Movement with Zeek

Detect lateral movement in network traffic using Zeek (formerly Bro) log analysis. Parses conn.log, smb_mapping.log, smb_files.log, dce_rpc.log, kerberos.log, and ntlm.log to identify SMB file transfers, NTLM account spray activity, remote service execution, and anomalous internal connections.

dce-rpclateral-movementnetwork-forensicsntlm-spraysmbzeek
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Analyze Zeek network logs to identify lateral movement techniques including SMB admin share access, DCE/RPC remote service creation, NTLM account spray, Kerberos ticket anomalies, and large internal data transfers indicative of staging or exfiltration between hosts.

When to Use

  • Hunting for lateral movement after an initial compromise indicator is found on one endpoint
  • Investigating suspected NTLM account spray or Pass-the-Ticket attacks across the internal network
  • Monitoring SMB traffic for unauthorized file transfers to admin shares (C$, ADMIN$, IPC$)
  • Detecting remote service execution via DCE/RPC (PsExec, schtasks, WMI lateral patterns)
  • Building alerting rules for internal network anomalies in a Zeek-based NSMP deployment
  • Performing post-incident timeline reconstruction using Zeek logs as a network-level evidence source

Do not use as a standalone detection mechanism. Zeek sees network traffic only; combine with endpoint telemetry (Sysmon, EDR) for full visibility. Encrypted SMB3 traffic may limit Zeek's visibility into file-level details.

Prerequisites

  • Zeek 6.0+ deployed on a network tap or SPAN port monitoring internal VLAN traffic
  • Zeek SMB analyzer enabled (loaded by default: @load base/protocols/smb)
  • Zeek DCE/RPC analyzer enabled (@load base/protocols/dce-rpc)
  • Zeek Kerberos analyzer enabled (@load base/protocols/krb)
  • Python 3.8+ (standard library only)
  • Access to Zeek log directory (default: /opt/zeek/logs/current/)
  • Familiarity with Zeek TSV log format (fields separated by \t, header lines prefixed with #)

Workflow

Step 1: Verify Zeek Log Collection

Confirm that Zeek is producing the required log files for lateral movement detection:

# Check that all required analyzers are producing logs
ls -la /opt/zeek/logs/current/conn.log
ls -la /opt/zeek/logs/current/smb_mapping.log
ls -la /opt/zeek/logs/current/smb_files.log
ls -la /opt/zeek/logs/current/dce_rpc.log
ls -la /opt/zeek/logs/current/kerberos.log
ls -la /opt/zeek/logs/current/ntlm.log
 
# Quick field check on conn.log
zeek-cut id.orig_h id.resp_h id.resp_p proto service < /opt/zeek/logs/current/conn.log | head -20

Step 2: Parse conn.log for Internal Lateral Patterns

Identify connections between internal hosts on lateral-movement-associated ports:

# Extract SMB connections (port 445) between internal hosts
zeek-cut ts id.orig_h id.orig_p id.resp_h id.resp_p proto service duration orig_bytes resp_bytes \
  < /opt/zeek/logs/current/conn.log \
  | awk '$5 == 445 && $7 == "smb"'
 
# Extract DCE/RPC connections (port 135)
zeek-cut ts id.orig_h id.resp_h id.resp_p service \
  < /opt/zeek/logs/current/conn.log \
  | awk '$4 == 135'
 
# Extract WinRM connections (port 5985/5986)
zeek-cut ts id.orig_h id.resp_h id.resp_p service \
  < /opt/zeek/logs/current/conn.log \
  | awk '$4 == 5985 || $4 == 5986'

Step 3: Analyze SMB Admin Share Access

Detect access to administrative shares (C$, ADMIN$, IPC$) which is the primary vector for tools like PsExec:

# Check smb_mapping.log for admin share access
zeek-cut ts id.orig_h id.resp_h path share_type \
  < /opt/zeek/logs/current/smb_mapping.log \
  | grep -iE '(C\$|ADMIN\$|IPC\$)'
 
# Check smb_files.log for file writes to admin shares
zeek-cut ts id.orig_h id.resp_h action path name size \
  < /opt/zeek/logs/current/smb_files.log \
  | grep -i 'SMB::FILE_WRITE'

Deploy the following Zeek script to generate notice.log alerts on admin share access:

@load base/protocols/smb
@load base/frameworks/notice
 
redef enum Notice::Type += {
    Admin_Share_Access
};
 
event smb1_tree_connect_andx_request(c: connection, hdr: SMB1::Header, path: string, service: string) {
    if ( /\$/ in path )
        NOTICE([$note=Admin_Share_Access,
                $msg=fmt("Admin share access: %s -> %s (%s)", c$id$orig_h, c$id$resp_h, path),
                $conn=c]);
}

Step 4: Detect DCE/RPC Remote Service Operations

Monitor for remote service creation and scheduled task registration via DCE/RPC:

# Look for service control manager operations (PsExec pattern)
zeek-cut ts id.orig_h id.resp_h endpoint operation \
  < /opt/zeek/logs/current/dce_rpc.log \
  | grep -iE '(svcctl|atsvc|ITaskSchedulerService)'

Step 5: Detect NTLM Account Spray

Analyze ntlm.log for authentication anomalies indicating credential reuse. Zeek's ntlm.log does not expose password hashes, so this detection identifies a single account authenticating to many hosts in a short window — the network signature of credential spraying tools like CrackMapExec:

# Extract NTLM authentications
zeek-cut ts id.orig_h id.resp_h username domainname server_nb_computer_name success \
  < /opt/zeek/logs/current/ntlm.log
 
# Failed NTLM authentications (brute force or credential testing)
zeek-cut ts id.orig_h id.resp_h username success \
  < /opt/zeek/logs/current/ntlm.log \
  | awk '$5 == "F"'
 
# Sort by timestamp for timeline analysis
zeek-cut ts id.orig_h id.resp_h username success \
  < /opt/zeek/logs/current/ntlm.log \
  | sort -k1,1

Deploy the following Zeek script to generate notice.log alerts when a single account touches more hosts than the threshold in a rolling window:

@load base/protocols/ntlm
@load base/frameworks/notice
 
redef enum Notice::Type += {
    NTLM_Account_Spray
};
 
global ntlm_tracker: table[string] of set[addr] &create_expire=5min;
const spray_threshold = 3 &redef;
 
event ntlm_log(rec: NTLM::Info) {
    if ( ! rec?$username || rec$username == "-" )
        return;
    if ( rec$username !in ntlm_tracker )
        ntlm_tracker[rec$username] = set();
    add ntlm_tracker[rec$username][rec$id$resp_h];
    if ( |ntlm_tracker[rec$username]| >= spray_threshold )
        NOTICE([$note=NTLM_Account_Spray,
                $msg=fmt("NTLM account spray: %s -> %d hosts", rec$username, |ntlm_tracker[rec$username]|),
                $sub=rec$username,
                $conn=rec$id]);
}

Step 6: Run the Automated Analysis Agent

Use the provided agent.py for comprehensive lateral movement detection:

python3 agent.py /opt/zeek/logs/current/
python3 agent.py /opt/zeek/logs/2026-03-18/  # Analyze a specific date

Verification

  • Confirm conn.log captures internal SMB (port 445) and DCE/RPC (port 135) connections with correct field parsing
  • Verify smb_mapping.log correctly logs admin share paths (C$, ADMIN$, IPC$)
  • Test with a known PsExec execution in a lab: expect to see SMB FILE_WRITE of the service binary followed by DCE/RPC svcctl CreateService
  • Validate NTLM log parsing by performing a test authentication and confirming username, domain, and success fields are captured; verify the NTLM Account Spray Zeek script generates a notice.log entry when the spray threshold is exceeded
  • Cross-reference Zeek alerts with Sysmon Event ID 1 (Process Creation) on the target host to confirm end-to-end detection
  • Verify the agent correctly handles both TSV and JSON Zeek log formats
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 3

api-reference.md4.4 KB

API Reference: Detecting Lateral Movement with Zeek

CLI Usage

# Analyze current Zeek logs
python agent.py /opt/zeek/logs/current/
 
# Analyze specific date
python agent.py /opt/zeek/logs/2026-03-18/
 
# Pipe JSON output for further processing
python agent.py /opt/zeek/logs/current/ 2>/dev/null | python -m json.tool

Zeek Log Files Analyzed

Log File Fields Used Detection Purpose
conn.log ts, id.orig_h, id.resp_h, id.resp_p, service, orig_bytes, resp_bytes Internal lateral-port connections (SMB 445, RDP 3389, WinRM 5985)
smb_mapping.log ts, id.orig_h, id.resp_h, path, share_type Admin share access (C$, ADMIN$, IPC$)
smb_files.log ts, id.orig_h, id.resp_h, action, path, name, size Executable file writes to network shares
dce_rpc.log ts, id.orig_h, id.resp_h, endpoint, operation, named_pipe Remote service creation (svcctl), scheduled tasks (atsvc)
ntlm.log ts, id.orig_h, id.resp_h, username, domainname, success Pass-the-Hash detection, NTLM brute force
kerberos.log ts, id.orig_h, id.resp_h, request_type, client, service, error_msg Pass-the-Ticket, Kerberos pre-auth failures

Lateral Movement Ports Tracked

Port Service ATT&CK Technique
445 SMB T1021.002 - SMB/Windows Admin Shares
135 DCE/RPC T1021.003 - Distributed Component Object Model
139 NetBIOS-SSN T1021.002 - SMB/Windows Admin Shares
3389 RDP T1021.001 - Remote Desktop Protocol
5985 WinRM-HTTP T1021.006 - Windows Remote Management
5986 WinRM-HTTPS T1021.006 - Windows Remote Management
22 SSH T1021.004 - SSH

Suspicious DCE/RPC Endpoints

Endpoint Description Severity
svcctl Service Control Manager (PsExec pattern) CRITICAL
atsvc AT Scheduler Service (at.exe / schtasks) CRITICAL
ITaskSchedulerService Task Scheduler v2 (schtasks) CRITICAL
winreg Remote Registry manipulation HIGH
samr SAM Remote Protocol (user enumeration) HIGH
lsarpc LSA Remote Protocol (policy enumeration) HIGH
srvsvc Server Service (share/session enumeration) HIGH
wkssvc Workstation Service (user enumeration) HIGH

Detection Types in Output

Finding Type Severity Description
lateral_port_connection INFO Internal connection on a lateral-movement-associated port
admin_share_access HIGH Access to C$, ADMIN$, or IPC$ administrative share
smb_file_write MEDIUM/CRITICAL File write to SMB share (CRITICAL if executable)
suspicious_dce_rpc HIGH/CRITICAL DCE/RPC call to remote execution endpoint
multi_source_ntlm_auth HIGH Single user NTLM authenticating from 3+ source IPs
ntlm_brute_force HIGH 5+ failed NTLM auth attempts from same source
multi_source_tgt_request HIGH Kerberos TGT requested from 3+ source IPs
kerberos_preauth_failure MEDIUM Kerberos pre-authentication failure
psexec_pattern CRITICAL Correlated SMB exe write + svcctl service creation

Report Output Schema

{
  "summary": {
    "total_findings": 42,
    "by_severity": {"CRITICAL": 3, "HIGH": 15, "MEDIUM": 24},
    "by_type": {"admin_share_access": 8, "suspicious_dce_rpc": 5}
  },
  "top_connection_pairs": [
    {"pair": "10.0.1.50->10.0.1.100:445", "connections": 287}
  ],
  "top_data_transfer_pairs": [
    {"pair": "10.0.1.50->10.0.1.100:445", "bytes": 104857600, "megabytes": 100.0}
  ],
  "findings": []
}

Zeek CLI Commands

# Install BZAR package for ATT&CK detections
zkg install zeek/mitre-attack/bzar
 
# Extract SMB admin share access
zeek-cut ts id.orig_h id.resp_h path share_type < smb_mapping.log | grep -iE '(C\$|ADMIN\$)'
 
# Extract DCE/RPC service creation
zeek-cut ts id.orig_h id.resp_h endpoint operation < dce_rpc.log | grep -i svcctl
 
# Extract failed NTLM authentications
zeek-cut ts id.orig_h id.resp_h username success < ntlm.log | awk '$5 == "F"'

References

standards.md0.8 KB

Standards & References

MITRE ATT&CK — Lateral Movement (TA0008)

  • T1021.001 Remote Desktop Protocol
  • T1021.002 SMB/Windows Admin Shares
  • T1021.003 DCOM
  • T1021.006 Windows Remote Management
  • T1550.002 Pass the Hash
  • T1570 Lateral Tool Transfer
  • T1210 Exploitation of Remote Services

Zeek Documentation

Detection References

  • SANS: Detecting Lateral Movement with Zeek
  • Red Canary Threat Detection Report — Lateral Movement chapter
workflows.md4.7 KB

Detection Workflow — Lateral Movement with Zeek

Overview

This document describes the end-to-end workflow for detecting lateral movement using Zeek network logs, from data collection through investigation and response.

Workflow Stages

Stage 1: Data Collection

Network Traffic (Span/TAP)


    Zeek Sensor

         ├── conn.log          (all connections)
         ├── smb_mapping.log   (SMB share access)
         ├── dce_rpc.log       (DCE/RPC calls)
         ├── ntlm.log          (NTLM authentication)
         ├── files.log          (file transfers)
         └── notice.log        (Zeek-generated alerts)

Requirements:

  • Zeek deployed on network tap/span port covering internal segments
  • Protocol analyzers loaded: SMB, DCE/RPC, NTLM, RDP
  • Log rotation configured (recommended: daily rotation, 90-day retention)

Stage 2: Detection Rules

Apply detection logic via Zeek scripts and/or post-processing:

Detection Input Logs Method
Admin Share Access smb_mapping.log Pattern match on C$, ADMIN$, IPC$
PsExec Execution dce_rpc.log Match svcctl endpoint + CreateServiceW
RDP Pivoting conn.log Graph analysis: host is both RDP client and server
NTLM Account Spray ntlm.log Same user from N+ distinct sources in time window
DCSync dce_rpc.log drsuapi endpoint + opnum 3 from non-DC
Tool Transfer files.log PE MIME type between internal hosts

Stage 3: Alert Triage

Detection Fires


┌─────────────────┐
│  Initial Triage  │
│                   │
│ 1. Is source a    │
│    known admin    │──Yes──▶ Log & reduce priority
│    workstation?   │
│                   │
│ 2. Is activity    │
│    during change  │──Yes──▶ Verify change ticket
│    window?        │
│                   │
│ 3. Multiple       │
│    indicators?    │──Yes──▶ ESCALATE immediately
└─────────────────┘

         No match


   Standard investigation

Stage 4: Investigation

For each confirmed alert, follow the investigation checklist (see assets/template.md):

  1. Identify the source host

    • Query conn.log for all connections from the source in the alert timeframe
    • Check ntlm.log for authentication patterns
    • Look for preceding inbound connections (initial access vector)
  2. Map the movement chain

    # Build connection graph for suspect host
    cat conn.log | zeek-cut id.orig_h id.resp_h id.resp_p | \
        awk '$1 == "SUSPECT_IP" || $2 == "SUSPECT_IP"' | sort -u
  3. Identify transferred payloads

    # Find files transferred by suspect
    cat files.log | zeek-cut tx_hosts rx_hosts filename mime_type total_bytes | \
        grep "SUSPECT_IP"
  4. Check authentication anomalies

    # NTLM auth from suspect host
    cat ntlm.log | zeek-cut ts id.orig_h username domainname success | \
        grep "SUSPECT_IP"
  5. Timeline reconstruction

    • Correlate all log entries by timestamp
    • Build a chronological sequence of events
    • Identify initial compromise, lateral movement, and objectives

Stage 5: Response

Finding Response Action
Confirmed lateral movement Isolate affected hosts from network
NTLM Account Spray detected Force password reset for compromised accounts
DCSync detected Rotate krbtgt and affected credentials, audit DC access
Tool transfer identified Extract and analyze transferred files
RDP pivot chain Disable RDP on non-essential hosts, enforce NLA

Stage 6: Post-Incident

  1. Update baselines — Add legitimate admin share usage to allowlists
  2. Tune detections — Adjust thresholds based on false positive analysis
  3. Document findings — Update incident report with Zeek evidence
  4. Improve coverage — Deploy additional Zeek scripts for newly discovered TTPs

Automation Integration

SIEM Forwarding

# Forward Zeek logs to SIEM via syslog
# Add to local.zeek:
@load policy/tuning/json-logs.zeek
 
# Configure rsyslog/filebeat to ship JSON logs to SIEM

SOAR Playbook Triggers

  • Admin share access from non-admin workstation → Auto-isolate + ticket
  • DCSync from non-DC → Emergency alert + auto-isolate
  • NTLM Account Spray threshold exceeded → Auto-disable account + alert

Continuous Improvement

  • Review detection efficacy monthly
  • Test with red team exercises quarterly
  • Update MITRE ATT&CK mappings as new sub-techniques emerge
  • Correlate Zeek findings with endpoint telemetry (EDR) for higher fidelity

Scripts 2

agent.py19.0 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""Zeek lateral movement detection agent.

Parses Zeek conn.log, smb_mapping.log, smb_files.log, dce_rpc.log,
ntlm.log, and kerberos.log to detect lateral movement indicators:
admin share access, PsExec-style service creation, Pass-the-Hash,
and anomalous internal host-to-host connections.
"""

import csv
import json
import os
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone


# Zeek TSV log field definitions per log type
CONN_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "proto", "service", "duration", "orig_bytes", "resp_bytes",
    "conn_state", "local_orig", "local_resp", "missed_bytes", "history",
    "orig_pkts", "orig_ip_bytes", "resp_pkts", "resp_ip_bytes",
    "tunnel_parents",
]

SMB_MAPPING_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "path", "service", "native_file_system", "share_type",
]

SMB_FILES_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "fuid", "action", "path", "name", "size", "prev_name", "times.modified",
    "times.accessed", "times.created", "times.changed",
]

DCE_RPC_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "rtt", "named_pipe", "endpoint", "operation",
]

NTLM_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "username", "hostname", "domainname", "server_nb_computer_name",
    "server_dns_computer_name", "server_tree_name", "success",
]

KERBEROS_FIELDS = [
    "ts", "uid", "id.orig_h", "id.orig_p", "id.resp_h", "id.resp_p",
    "request_type", "client", "service", "success", "error_msg",
    "from", "till", "cipher", "forwardable", "renewable", "client_cert_subject",
    "client_cert_fuid", "server_cert_subject", "server_cert_fuid",
]

# Internal RFC1918 ranges
INTERNAL_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
                     "172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
                     "172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
                     "172.30.", "172.31.", "192.168.")

# Lateral movement ports
LATERAL_PORTS = {
    "445": "SMB",
    "135": "DCE/RPC",
    "139": "NetBIOS-SSN",
    "3389": "RDP",
    "5985": "WinRM-HTTP",
    "5986": "WinRM-HTTPS",
    "22": "SSH",
    "23": "Telnet",
}

# Admin shares indicating lateral movement
ADMIN_SHARES = re.compile(r"(C\$|ADMIN\$|IPC\$|D\$|E\$)", re.IGNORECASE)

# DCE/RPC endpoints associated with remote execution
SUSPICIOUS_ENDPOINTS = {
    "svcctl": "Service Control Manager (PsExec pattern)",
    "atsvc": "AT Scheduler Service (at.exe / schtasks)",
    "ITaskSchedulerService": "Task Scheduler (schtasks v2)",
    "winreg": "Remote Registry",
    "samr": "SAM Remote Protocol (user enumeration)",
    "lsarpc": "LSA Remote Protocol (policy enumeration)",
    "wkssvc": "Workstation Service (NetWkstaUserEnum)",
    "srvsvc": "Server Service (NetShareEnum/NetSessionEnum)",
    "epmapper": "Endpoint Mapper (RPC enumeration)",
}


def is_internal(ip):
    """Check if an IP address is in RFC1918 private ranges."""
    return any(ip.startswith(prefix) for prefix in INTERNAL_PREFIXES)


def parse_zeek_tsv(filepath, field_names):
    """Parse a Zeek TSV log file, skipping comment/header lines."""
    records = []
    if not os.path.exists(filepath):
        return records
    with open(filepath, "r", errors="replace") as f:
        for line in f:
            line = line.rstrip("\n")
            if line.startswith("#") or not line:
                continue
            parts = line.split("\t")
            record = {}
            for i, field in enumerate(field_names):
                record[field] = parts[i] if i < len(parts) else "-"
            records.append(record)
    return records


def parse_zeek_json(filepath):
    """Parse a Zeek JSON log file (one JSON object per line)."""
    records = []
    if not os.path.exists(filepath):
        return records
    with open(filepath, "r", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                records.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return records


def parse_zeek_log(filepath, field_names):
    """Auto-detect Zeek log format (TSV or JSON) and parse accordingly."""
    if not os.path.exists(filepath):
        return []
    with open(filepath, "r", errors="replace") as f:
        first_line = ""
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                first_line = line
                break
    if first_line.startswith("{"):
        return parse_zeek_json(filepath)
    return parse_zeek_tsv(filepath, field_names)


def ts_to_iso(ts_str):
    """Convert Zeek epoch timestamp string to ISO 8601."""
    try:
        epoch = float(ts_str)
        dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
        return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
    except (ValueError, TypeError, OSError):
        return ts_str


def analyze_conn_log(log_dir):
    """Analyze conn.log for internal lateral movement connections."""
    records = parse_zeek_log(os.path.join(log_dir, "conn.log"), CONN_FIELDS)
    findings = []
    host_pair_counts = defaultdict(int)
    host_pair_bytes = defaultdict(int)

    for rec in records:
        orig = rec.get("id.orig_h", "")
        resp = rec.get("id.resp_h", "")
        resp_p = rec.get("id.resp_p", "")
        service = rec.get("service", "-")

        if not (is_internal(orig) and is_internal(resp)):
            continue
        if orig == resp:
            continue

        pair_key = f"{orig}->{resp}:{resp_p}"
        host_pair_counts[pair_key] += 1

        orig_bytes = rec.get("orig_bytes", "0")
        resp_bytes = rec.get("resp_bytes", "0")
        try:
            total = int(orig_bytes) + int(resp_bytes)
        except ValueError:
            total = 0
        host_pair_bytes[pair_key] += total

        if resp_p in LATERAL_PORTS:
            findings.append({
                "type": "lateral_port_connection",
                "timestamp": ts_to_iso(rec.get("ts", "")),
                "src": orig,
                "dst": resp,
                "port": resp_p,
                "service_label": LATERAL_PORTS[resp_p],
                "zeek_service": service,
                "uid": rec.get("uid", ""),
            })

    return findings, host_pair_counts, host_pair_bytes


def analyze_smb_mapping(log_dir):
    """Analyze smb_mapping.log for admin share access."""
    records = parse_zeek_log(
        os.path.join(log_dir, "smb_mapping.log"), SMB_MAPPING_FIELDS
    )
    findings = []
    for rec in records:
        path = rec.get("path", "")
        if ADMIN_SHARES.search(path):
            findings.append({
                "type": "admin_share_access",
                "severity": "HIGH",
                "timestamp": ts_to_iso(rec.get("ts", "")),
                "src": rec.get("id.orig_h", ""),
                "dst": rec.get("id.resp_h", ""),
                "share_path": path,
                "share_type": rec.get("share_type", ""),
                "uid": rec.get("uid", ""),
            })
    return findings


def analyze_smb_files(log_dir):
    """Analyze smb_files.log for file writes to network shares."""
    records = parse_zeek_log(
        os.path.join(log_dir, "smb_files.log"), SMB_FILES_FIELDS
    )
    findings = []
    suspicious_extensions = (".exe", ".dll", ".bat", ".ps1", ".vbs", ".scr",
                             ".cmd", ".msi", ".hta", ".sys")
    for rec in records:
        action = rec.get("action", "")
        name = rec.get("name", "")
        if "WRITE" in action.upper():
            severity = "MEDIUM"
            if any(name.lower().endswith(ext) for ext in suspicious_extensions):
                severity = "CRITICAL"
            findings.append({
                "type": "smb_file_write",
                "severity": severity,
                "timestamp": ts_to_iso(rec.get("ts", "")),
                "src": rec.get("id.orig_h", ""),
                "dst": rec.get("id.resp_h", ""),
                "action": action,
                "path": rec.get("path", ""),
                "filename": name,
                "size": rec.get("size", "-"),
                "uid": rec.get("uid", ""),
            })
    return findings


def analyze_dce_rpc(log_dir):
    """Analyze dce_rpc.log for remote execution operations."""
    records = parse_zeek_log(
        os.path.join(log_dir, "dce_rpc.log"), DCE_RPC_FIELDS
    )
    findings = []
    for rec in records:
        endpoint = rec.get("endpoint", "").lower()
        operation = rec.get("operation", "")
        for sus_ep, description in SUSPICIOUS_ENDPOINTS.items():
            if sus_ep.lower() in endpoint:
                severity = "CRITICAL" if sus_ep in ("svcctl", "atsvc", "ITaskSchedulerService") else "HIGH"
                findings.append({
                    "type": "suspicious_dce_rpc",
                    "severity": severity,
                    "timestamp": ts_to_iso(rec.get("ts", "")),
                    "src": rec.get("id.orig_h", ""),
                    "dst": rec.get("id.resp_h", ""),
                    "endpoint": endpoint,
                    "operation": operation,
                    "description": description,
                    "named_pipe": rec.get("named_pipe", ""),
                    "uid": rec.get("uid", ""),
                })
                break
    return findings


def analyze_ntlm(log_dir):
    """Analyze ntlm.log for Pass-the-Hash and authentication anomalies."""
    records = parse_zeek_log(
        os.path.join(log_dir, "ntlm.log"), NTLM_FIELDS
    )
    findings = []
    user_source_map = defaultdict(set)
    failed_auths = defaultdict(int)

    for rec in records:
        username = rec.get("username", "-")
        orig = rec.get("id.orig_h", "")
        resp = rec.get("id.resp_h", "")
        success = rec.get("success", "")

        if username != "-" and username:
            user_source_map[username].add(orig)

        if success.upper() in ("F", "FALSE"):
            failed_auths[(orig, resp, username)] += 1

    # Detect one user authenticating from multiple source IPs (Pass-the-Hash indicator)
    for username, sources in user_source_map.items():
        if len(sources) > 2:
            findings.append({
                "type": "multi_source_ntlm_auth",
                "severity": "HIGH",
                "description": "Single user authenticating from multiple source IPs (possible PtH)",
                "username": username,
                "source_ips": sorted(sources),
                "source_count": len(sources),
            })

    # Detect brute force / credential spray
    for (orig, resp, username), count in failed_auths.items():
        if count >= 5:
            findings.append({
                "type": "ntlm_brute_force",
                "severity": "HIGH",
                "src": orig,
                "dst": resp,
                "username": username,
                "failed_attempts": count,
            })

    return findings


def analyze_kerberos(log_dir):
    """Analyze kerberos.log for ticket anomalies."""
    records = parse_zeek_log(
        os.path.join(log_dir, "kerberos.log"), KERBEROS_FIELDS
    )
    findings = []
    tgt_sources = defaultdict(set)

    for rec in records:
        request_type = rec.get("request_type", "")
        client = rec.get("client", "")
        orig = rec.get("id.orig_h", "")
        error_msg = rec.get("error_msg", "-")
        success = rec.get("success", "")

        if request_type == "AS":
            tgt_sources[client].add(orig)

        # Kerberos pre-auth failures (credential testing)
        if error_msg and "PREAUTH_FAILED" in error_msg.upper():
            findings.append({
                "type": "kerberos_preauth_failure",
                "severity": "MEDIUM",
                "timestamp": ts_to_iso(rec.get("ts", "")),
                "src": orig,
                "dst": rec.get("id.resp_h", ""),
                "client": client,
                "error": error_msg,
            })

    # Detect TGT requested from multiple IPs (possible Pass-the-Ticket)
    for client, sources in tgt_sources.items():
        if len(sources) > 2:
            findings.append({
                "type": "multi_source_tgt_request",
                "severity": "HIGH",
                "description": "TGT requested from multiple source IPs (possible Pass-the-Ticket)",
                "client": client,
                "source_ips": sorted(sources),
                "source_count": len(sources),
            })

    return findings


def detect_psexec_pattern(smb_findings, dce_findings):
    """Correlate SMB file writes with DCE/RPC svcctl to detect PsExec-style attacks."""
    correlated = []
    smb_writes = {}
    for f in smb_findings:
        if f["severity"] == "CRITICAL":
            key = (f["src"], f["dst"])
            smb_writes.setdefault(key, []).append(f)

    for f in dce_findings:
        if "svcctl" in f.get("endpoint", ""):
            key = (f["src"], f["dst"])
            if key in smb_writes:
                correlated.append({
                    "type": "psexec_pattern",
                    "severity": "CRITICAL",
                    "description": "SMB executable write followed by svcctl service creation (PsExec-style)",
                    "src": f["src"],
                    "dst": f["dst"],
                    "smb_writes": smb_writes[key],
                    "dce_rpc_operation": f["operation"],
                    "timestamp": f["timestamp"],
                })
    return correlated


def generate_report(all_findings, host_pair_counts, host_pair_bytes):
    """Generate a structured lateral movement report."""
    severity_counts = defaultdict(int)
    type_counts = defaultdict(int)
    for f in all_findings:
        severity_counts[f.get("severity", "INFO")] += 1
        type_counts[f.get("type", "unknown")] += 1

    # Top talkers by connection count
    top_pairs = sorted(host_pair_counts.items(), key=lambda x: x[1], reverse=True)[:20]
    # Top talkers by bytes
    top_bytes = sorted(host_pair_bytes.items(), key=lambda x: x[1], reverse=True)[:20]

    report = {
        "summary": {
            "total_findings": len(all_findings),
            "by_severity": dict(severity_counts),
            "by_type": dict(type_counts),
        },
        "top_connection_pairs": [
            {"pair": k, "connections": v} for k, v in top_pairs
        ],
        "top_data_transfer_pairs": [
            {"pair": k, "bytes": v, "megabytes": round(v / (1024 * 1024), 2)}
            for k, v in top_bytes
        ],
        "findings": all_findings,
    }
    return report


if __name__ == "__main__":
    print("=" * 60)
    print("Zeek Lateral Movement Detection Agent")
    print("conn.log, SMB, DCE/RPC, NTLM, Kerberos analysis")
    print("=" * 60)

    log_dir = sys.argv[1] if len(sys.argv) > 1 else "/opt/zeek/logs/current"

    if not os.path.isdir(log_dir):
        print(f"\n[ERROR] Log directory not found: {log_dir}")
        print("[DEMO] Usage: python agent.py <zeek_log_directory>")
        print("[*] Provide a directory containing Zeek log files (conn.log, smb_mapping.log, etc.)")
        sys.exit(1)

    print(f"\n[*] Analyzing Zeek logs in: {log_dir}")

    # Check which log files are available
    log_files = ["conn.log", "smb_mapping.log", "smb_files.log",
                 "dce_rpc.log", "ntlm.log", "kerberos.log"]
    for lf in log_files:
        path = os.path.join(log_dir, lf)
        status = "found" if os.path.exists(path) else "NOT FOUND"
        print(f"  {lf}: {status}")

    all_findings = []

    print("\n--- Connection Analysis (conn.log) ---")
    conn_findings, pair_counts, pair_bytes = analyze_conn_log(log_dir)
    lateral_conns = [f for f in conn_findings if f["type"] == "lateral_port_connection"]
    print(f"  Internal lateral-port connections: {len(lateral_conns)}")
    services = defaultdict(int)
    for f in lateral_conns:
        services[f["service_label"]] += 1
    for svc, count in sorted(services.items(), key=lambda x: -x[1]):
        print(f"    {svc}: {count}")

    print("\n--- SMB Admin Share Access (smb_mapping.log) ---")
    smb_map_findings = analyze_smb_mapping(log_dir)
    print(f"  Admin share accesses: {len(smb_map_findings)}")
    for f in smb_map_findings[:10]:
        print(f"  [!] {f['src']} -> {f['dst']} share={f['share_path']}")
    all_findings.extend(smb_map_findings)

    print("\n--- SMB File Writes (smb_files.log) ---")
    smb_file_findings = analyze_smb_files(log_dir)
    critical_writes = [f for f in smb_file_findings if f["severity"] == "CRITICAL"]
    print(f"  Total file writes: {len(smb_file_findings)}, Critical (executables): {len(critical_writes)}")
    for f in critical_writes[:10]:
        print(f"  [CRITICAL] {f['src']} -> {f['dst']} wrote {f['filename']} ({f['size']} bytes)")
    all_findings.extend(smb_file_findings)

    print("\n--- DCE/RPC Remote Execution (dce_rpc.log) ---")
    dce_findings = analyze_dce_rpc(log_dir)
    print(f"  Suspicious DCE/RPC operations: {len(dce_findings)}")
    for f in dce_findings[:10]:
        print(f"  [{f['severity']}] {f['src']} -> {f['dst']} "
              f"endpoint={f['endpoint']} op={f['operation']} ({f['description']})")
    all_findings.extend(dce_findings)

    print("\n--- NTLM Authentication Analysis (ntlm.log) ---")
    ntlm_findings = analyze_ntlm(log_dir)
    for f in ntlm_findings:
        if f["type"] == "multi_source_ntlm_auth":
            print(f"  [HIGH] PtH indicator: user '{f['username']}' from {f['source_count']} IPs: "
                  f"{', '.join(f['source_ips'][:5])}")
        elif f["type"] == "ntlm_brute_force":
            print(f"  [HIGH] Brute force: {f['src']} -> {f['dst']} "
                  f"user='{f['username']}' failures={f['failed_attempts']}")
    all_findings.extend(ntlm_findings)

    print("\n--- Kerberos Analysis (kerberos.log) ---")
    krb_findings = analyze_kerberos(log_dir)
    for f in krb_findings:
        if f["type"] == "multi_source_tgt_request":
            print(f"  [HIGH] PtT indicator: client '{f['client']}' TGT from {f['source_count']} IPs")
        elif f["type"] == "kerberos_preauth_failure":
            print(f"  [MEDIUM] Pre-auth failure: {f['src']} client={f['client']}")
    all_findings.extend(krb_findings)

    print("\n--- PsExec Pattern Correlation ---")
    psexec = detect_psexec_pattern(smb_file_findings, dce_findings)
    for p in psexec:
        print(f"  [CRITICAL] PsExec-style: {p['src']} -> {p['dst']} "
              f"(exe write + svcctl {p['dce_rpc_operation']})")
    all_findings.extend(psexec)

    report = generate_report(all_findings, pair_counts, pair_bytes)

    print(f"\n{'=' * 60}")
    print(f"SUMMARY: {report['summary']['total_findings']} findings")
    for sev, count in sorted(report["summary"]["by_severity"].items()):
        print(f"  {sev}: {count}")

    if report["top_connection_pairs"]:
        print("\nTop internal connection pairs:")
        for p in report["top_connection_pairs"][:5]:
            print(f"  {p['pair']}: {p['connections']} connections")

    print(f"\n[*] Full report: {json.dumps(report['summary'], indent=2)}")
process.py5.5 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""Parse Zeek logs to detect lateral movement indicators.

Usage:
    python process.py smb_mapping <log_file> [--internal-nets 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16]
    python process.py conn <log_file>
    python process.py ntlm <log_file> [--window 300]
    python process.py dce_rpc <log_file> [--dc-ips 10.0.1.1,10.0.1.2]
"""
import csv
import sys
import ipaddress
from collections import defaultdict

DEFAULT_INTERNAL = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]


def is_internal(ip_str, networks):
    """Check if IP is in internal networks."""
    try:
        ip = ipaddress.ip_address(ip_str)
        return any(ip in net for net in networks)
    except ValueError:
        return False


def parse_internal_nets(nets_str):
    """Parse comma-separated CIDR networks."""
    if not nets_str:
        return [ipaddress.ip_network(n) for n in DEFAULT_INTERNAL]
    return [ipaddress.ip_network(n.strip()) for n in nets_str.split(",")]


def parse_zeek_log(filepath):
    """Parse a Zeek TSV log file, skipping comment lines."""
    rows = []
    fields = []
    with open(filepath) as f:
        for line in f:
            if line.startswith('#fields'):
                fields = line.strip().split('\t')[1:]
            elif not line.startswith('#'):
                values = line.strip().split('\t')
                if fields and len(values) == len(fields):
                    rows.append(dict(zip(fields, values)))
    return rows


def detect_admin_shares(log_file, internal_nets):
    """Detect admin share access — only between internal hosts."""
    networks = parse_internal_nets(internal_nets)
    entries = parse_zeek_log(log_file)
    for entry in entries:
        src = entry.get('id.orig_h', '')
        dst = entry.get('id.resp_h', '')
        share = entry.get('path', '') or entry.get('share_type', '')
        if not (is_internal(src, networks) and is_internal(dst, networks)):
            continue
        share_upper = share.upper()
        if any(s in share_upper for s in ['ADMIN$', 'C$', 'IPC$']):
            severity = "HIGH" if 'ADMIN$' in share_upper or 'C$' in share_upper else "MEDIUM"
            print(f"[{severity}] ADMIN SHARE: {entry.get('ts', '')} {src} -> {dst} ({share})")


def detect_rdp_pivots(log_file, window_minutes=10):
    """Detect RDP pivot chains from conn.log."""
    entries = parse_zeek_log(log_file)
    rdp_sessions = [(float(e.get('ts', 0)), e.get('id.orig_h', ''), e.get('id.resp_h', ''))
                     for e in entries if e.get('id.resp_p') == '3389']
    rdp_sessions.sort()
    
    # Find chains: A->B then B->C within window
    dst_arrivals = defaultdict(list)
    for ts, src, dst in rdp_sessions:
        dst_arrivals[dst].append((ts, src))
    
    for ts, src, dst in rdp_sessions:
        for arrival_ts, arrival_src in dst_arrivals.get(src, []):
            if 0 < (ts - arrival_ts) < window_minutes * 60:
                print(f"[HIGH] RDP PIVOT: {arrival_src} -> {src} -> {dst} (delta: {int(ts - arrival_ts)}s)")


def detect_ntlm_spray(log_file, window_seconds=300, threshold=3):
    """Detect NTLM account spray via time-windowed burst analysis."""
    entries = parse_zeek_log(log_file)
    user_events = defaultdict(list)
    
    for entry in entries:
        user = entry.get('username', '')
        dst = entry.get('id.resp_h', '')
        ts = float(entry.get('ts', 0))
        if user and user != '-':
            user_events[user].append((ts, dst))
    
    for user, events in user_events.items():
        events.sort()
        # Sliding window analysis
        for i, (ts_start, _) in enumerate(events):
            window_hosts = set()
            for j in range(i, len(events)):
                ts_j, dst_j = events[j]
                if ts_j - ts_start > window_seconds:
                    break
                window_hosts.add(dst_j)
            if len(window_hosts) >= threshold:
                print(f"[CRITICAL] NTLM ACCOUNT SPRAY: {user} authenticated to {len(window_hosts)} "
                      f"hosts within {window_seconds}s: {', '.join(sorted(window_hosts))}")
                break  # One alert per user


def detect_dcsync(log_file, dc_ips=None):
    """Detect DCSync attacks via DRS replication calls — requires DC IPs."""
    if not dc_ips:
        print("[WARN] DCSync detection skipped: --dc-ips not provided. "
              "Specify domain controller IPs to enable this detector.")
        return
    
    dc_set = set(dc_ips.split(","))
    entries = parse_zeek_log(log_file)
    for entry in entries:
        src = entry.get('id.orig_h', '')
        dst = entry.get('id.resp_h', '')
        operation = entry.get('operation', '')
        if dst in dc_set and src not in dc_set:
            if 'DrsReplicaAdd' in operation or 'DrsGetNCChanges' in operation:
                print(f"[CRITICAL] DCSYNC: {src} -> {dst} ({operation})")


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)
    
    log_type, log_file = sys.argv[1], sys.argv[2]
    
    # Parse optional args
    args = {sys.argv[i]: sys.argv[i+1] for i in range(3, len(sys.argv)-1, 2) if sys.argv[i].startswith('--')}
    
    if log_type == "smb_mapping":
        detect_admin_shares(log_file, args.get('--internal-nets'))
    elif log_type == "conn":
        detect_rdp_pivots(log_file, int(args.get('--window', 10)))
    elif log_type == "ntlm":
        detect_ntlm_spray(log_file, int(args.get('--window', 300)), int(args.get('--threshold', 3)))
    elif log_type == "dce_rpc":
        detect_dcsync(log_file, args.get('--dc-ips'))
    else:
        print(f"Unknown log type: {log_type}")
        sys.exit(1)

Assets 1

template.mdtext/markdown · 4.3 KB
Keep exploring