Files
Anthropic-Cybersecurity-Skills/skills/detecting-lateral-movement-with-splunk/scripts/process.py
T

336 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Lateral Movement Detection Script
Analyzes Windows authentication logs to detect lateral movement patterns
including RDP, SMB, WinRM, PsExec, and WMI-based movement.
"""
import json
import csv
import argparse
import datetime
import re
from collections import defaultdict
from pathlib import Path
# Lateral movement logon types
LATERAL_LOGON_TYPES = {
"3": {"name": "Network", "techniques": ["T1021.002", "T1021.006", "T1047"], "risk_base": 20},
"10": {"name": "RemoteInteractive", "techniques": ["T1021.001"], "risk_base": 25},
}
# Suspicious account patterns
SYSTEM_ACCOUNTS = {"system", "anonymous logon", "anonymous", "local service", "network service", "dwm-1", "umfd-0"}
# Admin share indicators
ADMIN_SHARES = {"admin$", "c$", "ipc$", "d$", "e$"}
# PsExec and service-based indicators
SERVICE_LATERAL_PATTERNS = [
r"psexec", r"PSEXESVC", r"csexec", r"remcom",
r"cmd\.exe\s+/c", r"powershell.*-enc",
]
def parse_logs(input_path: str) -> list[dict]:
"""Parse JSON or CSV log files."""
path = Path(input_path)
if path.suffix == ".json":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else data.get("events", [])
elif path.suffix == ".csv":
with open(path, "r", encoding="utf-8-sig") as f:
return [dict(row) for row in csv.DictReader(f)]
return []
def normalize_event(event: dict) -> dict:
"""Normalize authentication event fields."""
field_map = {
"event_id": ["EventCode", "EventID", "event_id"],
"logon_type": ["Logon_Type", "LogonType", "logon_type"],
"account": ["Account_Name", "TargetUserName", "account_name", "user.name"],
"source_ip": ["Source_Network_Address", "IpAddress", "source_ip", "source.ip"],
"source_host": ["Workstation_Name", "WorkstationName", "source_host"],
"dest_host": ["Computer", "hostname", "DeviceName", "host.name"],
"logon_process": ["Logon_Process", "LogonProcessName", "logon_process"],
"auth_package": ["Authentication_Package", "AuthenticationPackageName", "auth_package"],
"share_name": ["Share_Name", "ShareName", "share_name"],
"service_name": ["Service_Name", "ServiceName", "service_name"],
"service_path": ["Service_File_Name", "ServiceFileName", "service_path"],
"process_name": ["Process_Name", "ProcessName", "process_name"],
"timestamp": ["_time", "timestamp", "Timestamp", "@timestamp", "UtcTime"],
}
normalized = {}
for target, sources in field_map.items():
for src in sources:
if src in event and event[src]:
normalized[target] = str(event[src]).strip()
break
if target not in normalized:
normalized[target] = ""
return normalized
def detect_network_logon(event: dict) -> dict | None:
"""Detect lateral movement via network logon events."""
event_id = event.get("event_id", "")
logon_type = event.get("logon_type", "")
if event_id != "4624" or logon_type not in LATERAL_LOGON_TYPES:
return None
account = event.get("account", "").lower()
if account in SYSTEM_ACCOUNTS or account.endswith("$"):
return None
source_ip = event.get("source_ip", "")
if not source_ip or source_ip in ("-", "::1", "127.0.0.1"):
return None
lt_info = LATERAL_LOGON_TYPES[logon_type]
risk = lt_info["risk_base"]
indicators = [f"Logon Type {logon_type} ({lt_info['name']})"]
auth_pkg = event.get("auth_package", "").lower()
if "ntlm" in auth_pkg:
risk += 10
indicators.append("NTLM authentication (potential Pass-the-Hash)")
if "negotiate" in auth_pkg and logon_type == "3":
indicators.append("Negotiate authentication package")
return {
"detection_type": "NETWORK_LOGON",
"technique": lt_info["techniques"][0],
"account": event.get("account", ""),
"source_ip": source_ip,
"source_host": event.get("source_host", ""),
"dest_host": event.get("dest_host", ""),
"logon_type": logon_type,
"auth_package": event.get("auth_package", ""),
"timestamp": event.get("timestamp", ""),
"risk_score": risk,
"indicators": indicators,
}
def detect_explicit_creds(event: dict) -> dict | None:
"""Detect explicit credential usage (Event 4648)."""
if event.get("event_id") != "4648":
return None
account = event.get("account", "").lower()
if account in SYSTEM_ACCOUNTS or account.endswith("$"):
return None
return {
"detection_type": "EXPLICIT_CREDENTIAL",
"technique": "T1021",
"account": event.get("account", ""),
"source_host": event.get("source_host", event.get("dest_host", "")),
"dest_host": event.get("dest_host", ""),
"process_name": event.get("process_name", ""),
"timestamp": event.get("timestamp", ""),
"risk_score": 35,
"indicators": ["Explicit credential logon (4648) - possible PsExec/RunAs"],
}
def detect_share_access(event: dict) -> dict | None:
"""Detect admin share access."""
if event.get("event_id") != "5140":
return None
share = event.get("share_name", "").lower()
share_name = share.split("\\")[-1] if "\\" in share else share
if share_name not in ADMIN_SHARES:
return None
account = event.get("account", "").lower()
if account in SYSTEM_ACCOUNTS or account.endswith("$"):
return None
risk = 40 if share_name in ("admin$", "c$") else 25
return {
"detection_type": "ADMIN_SHARE_ACCESS",
"technique": "T1021.002",
"account": event.get("account", ""),
"source_ip": event.get("source_ip", ""),
"dest_host": event.get("dest_host", ""),
"share": share,
"timestamp": event.get("timestamp", ""),
"risk_score": risk,
"indicators": [f"Admin share accessed: {share_name}"],
}
def detect_service_lateral(event: dict) -> dict | None:
"""Detect service-based lateral movement (PsExec)."""
if event.get("event_id") not in ("7045", "4697"):
return None
service_path = event.get("service_path", "")
for pattern in SERVICE_LATERAL_PATTERNS:
if re.search(pattern, service_path, re.IGNORECASE):
return {
"detection_type": "SERVICE_LATERAL",
"technique": "T1569.002",
"service_name": event.get("service_name", ""),
"service_path": service_path,
"dest_host": event.get("dest_host", ""),
"timestamp": event.get("timestamp", ""),
"risk_score": 60,
"indicators": [f"Suspicious service for lateral movement: {pattern}"],
}
return None
def build_movement_graph(findings: list[dict]) -> dict:
"""Build a graph of lateral movement paths."""
graph = defaultdict(lambda: defaultdict(list))
for finding in findings:
src = finding.get("source_ip") or finding.get("source_host", "unknown")
dst = finding.get("dest_host", "unknown")
if src and dst and src != dst:
graph[src][dst].append({
"account": finding.get("account", ""),
"technique": finding.get("technique", ""),
"timestamp": finding.get("timestamp", ""),
"type": finding.get("detection_type", ""),
})
return dict(graph)
def analyze_velocity(findings: list[dict], window_minutes: int = 10, threshold: int = 5) -> list[dict]:
"""Detect rapid multi-host access patterns."""
account_events = defaultdict(list)
for f in findings:
if f.get("account") and f.get("timestamp"):
account_events[f["account"]].append(f)
velocity_alerts = []
for account, events in account_events.items():
events.sort(key=lambda x: x.get("timestamp", ""))
unique_dests = set()
window_start = 0
for i, event in enumerate(events):
unique_dests.add(event.get("dest_host", ""))
if len(unique_dests) >= threshold:
velocity_alerts.append({
"detection_type": "VELOCITY_ANOMALY",
"account": account,
"unique_destinations": len(unique_dests),
"destinations": list(unique_dests),
"risk_score": 80,
"risk_level": "CRITICAL",
"indicators": [f"Account accessed {len(unique_dests)} hosts rapidly"],
})
break
return velocity_alerts
def run_hunt(input_path: str, output_dir: str) -> None:
"""Execute lateral movement hunt."""
print(f"[*] Lateral Movement Hunt - {datetime.datetime.now().isoformat()}")
events = parse_logs(input_path)
print(f"[*] Loaded {len(events)} events")
findings = []
stats = defaultdict(int)
detectors = [
detect_network_logon,
detect_explicit_creds,
detect_share_access,
detect_service_lateral,
]
for raw_event in events:
event = normalize_event(raw_event)
for detector in detectors:
result = detector(event)
if result:
risk = result["risk_score"]
result["risk_level"] = (
"CRITICAL" if risk >= 70 else "HIGH" if risk >= 50
else "MEDIUM" if risk >= 30 else "LOW"
)
findings.append(result)
stats[result["detection_type"]] += 1
# Velocity analysis
velocity_alerts = analyze_velocity(findings)
findings.extend(velocity_alerts)
stats["VELOCITY_ANOMALY"] = len(velocity_alerts)
# Build movement graph
graph = build_movement_graph(findings)
# Write output
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
with open(output_path / "lateral_movement_findings.json", "w", encoding="utf-8") as f:
json.dump({
"hunt_id": f"TH-LATMOV-{datetime.date.today().isoformat()}",
"total_events": len(events),
"total_findings": len(findings),
"statistics": dict(stats),
"movement_graph": {src: dict(dsts) for src, dsts in graph.items()},
"findings": findings,
}, f, indent=2)
with open(output_path / "hunt_report.md", "w", encoding="utf-8") as f:
f.write(f"# Lateral Movement Hunt Report\n\n")
f.write(f"**Date**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"**Events**: {len(events)} | **Findings**: {len(findings)}\n\n")
f.write("## Movement Graph\n\n")
for src, dests in graph.items():
for dst, connections in dests.items():
f.write(f"- `{src}` -> `{dst}` ({len(connections)} connections)\n")
f.write("\n## Velocity Anomalies\n\n")
for alert in velocity_alerts:
f.write(f"- **{alert['account']}**: {alert['unique_destinations']} hosts in short window\n")
print(f"[+] {len(findings)} findings, {len(graph)} source nodes in movement graph")
def main():
parser = argparse.ArgumentParser(description="Lateral Movement Detection")
subparsers = parser.add_subparsers(dest="command")
hunt_p = subparsers.add_parser("hunt")
hunt_p.add_argument("--input", "-i", required=True)
hunt_p.add_argument("--output", "-o", default="./latmov_output")
subparsers.add_parser("queries", help="Print Splunk SPL queries")
args = parser.parse_args()
if args.command == "hunt":
run_hunt(args.input, args.output)
elif args.command == "queries":
print("=== Splunk Lateral Movement Queries ===\n")
queries = {
"Network Logons": 'index=wineventlog EventCode=4624 Logon_Type=3\n| where NOT match(Account_Name, "(?i)(SYSTEM|ANONYMOUS|\\\\$)")\n| stats count dc(Computer) by Account_Name Source_Network_Address\n| where count > 3',
"RDP Sessions": 'index=wineventlog EventCode=4624 Logon_Type=10\n| stats count by Account_Name Source_Network_Address Computer',
"Admin Shares": 'index=wineventlog EventCode=5140 Share_Name IN ("*ADMIN$","*C$")\n| stats count by Account_Name Source_Address Computer Share_Name',
"PsExec Services": 'index=wineventlog EventCode=7045\n| where match(Service_File_Name, "(?i)(psexec|PSEXESVC)")\n| table _time Computer Service_Name Service_File_Name',
}
for name, query in queries.items():
print(f"--- {name} ---")
print(query)
print()
else:
parser.print_help()
if __name__ == "__main__":
main()