Chapter 22

Building Automated IOC Extractors

An automated IOC extractor is a Python pipeline that reads a directory of PCAP files, extracts all network indicators (IPs, domains, URLs, TLS SNIs, certificate subjects), enriches each against threat intelligence APIs, and outputs a ranked IOC list with confidence scores. This is a production-quality tool you build once and reuse on every investigation.

Scenario

An incident has produced 8 PCAP files spanning 4 days. You need a complete IOC list with threat intel enrichment within 30 minutes of receiving the files. You run your IOC extractor. It processes all 8 files, extracts 2,847 unique external IPs and 1,203 domains, queries VirusTotal and AbuseIPDB for each, and produces a ranked output where the top 20 entries are all confirmed C2 or malware-related infrastructure — with zero manual work.

Extractor Architecture

  IOC Extractor Pipeline
  ═══════════════════════════════════════════════════════════════════

  Input: directory of PCAP files

  Stage 1: Extraction (tshark subprocess)
    ├── External destination IPs from TCP SYN packets
    ├── DNS query names (all types)
    ├── HTTP host headers + request URIs
    ├── TLS SNI values from ClientHello
    ├── X.509 certificate CN/SAN fields
    └── HTTP User-Agent strings

  Stage 2: Deduplication + Allowlist Filtering
    ├── Deduplicate across all PCAP files
    ├── Remove RFC 1918 private IP ranges
    ├── Remove known CDN/cloud IP ranges (Cloudflare, AWS, Google)
    ├── Remove known-good domains (microsoft.com, google.com, etc.)
    └── Output: raw IOC set

  Stage 3: Enrichment (parallel API calls)
    ├── VirusTotal: malicious detection count per IP/domain
    ├── AbuseIPDB: abuse confidence score per IP
    ├── Shodan: open ports, service banners (confirms C2 infrastructure)
    └── RDAP/WHOIS: registration date (new = suspicious)

  Stage 4: Scoring + Ranking
    ├── Score = VT_malicious * 3 + AbuseIPDB_score/10 + age_penalty
    ├── Sort by score descending
    └── Output: JSON report + CSV + raw IOC text files

Complete IOC Extractor

Pythonioc-extractor.py
#!/usr/bin/env python3
"""
Automated IOC extractor with threat intel enrichment.
Usage: python3 ioc-extractor.py   [--vt-key KEY] [--abuseipdb-key KEY]
"""
import subprocess, json, time, sys, os, glob, ipaddress, re
from pathlib import Path
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request, urllib.parse

# ── CONFIGURATION ─────────────────────────────────────────────────────
VT_API_KEY = os.getenv("VT_API_KEY", "")
ABUSE_API_KEY = os.getenv("ABUSEIPDB_API_KEY", "")

PRIVATE_NETS = [
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("224.0.0.0/4"),
    ipaddress.ip_network("240.0.0.0/4"),
]

ALLOWLIST_DOMAINS = {
    "microsoft.com", "windows.com", "windowsupdate.com",
    "google.com", "googleapis.com", "gstatic.com",
    "cloudflare.com", "akamai.com", "akamaiedge.net",
    "amazon.com", "amazonaws.com", "s3.amazonaws.com",
    "apple.com", "icloud.com",
}

def is_private(ip_str: str) -> bool:
    try:
        addr = ipaddress.ip_address(ip_str)
        return any(addr in net for net in PRIVATE_NETS)
    except ValueError:
        return True

def is_allowlisted(domain: str) -> bool:
    domain = domain.rstrip(".")
    for allowed in ALLOWLIST_DOMAINS:
        if domain == allowed or domain.endswith("." + allowed):
            return True
    return False

# ── EXTRACTION ─────────────────────────────────────────────────────────
def extract_from_pcap(pcap: str) -> dict:
    """Extract all IOC types from a single PCAP file."""
    def tshark(filter_str: str, fields: list) -> list:
        cmd = ["tshark", "-r", pcap, "-n", "-Y", filter_str, "-T", "fields",
               "-E", "separator=\t"] + [f for field in fields for f in ["-e", field]]
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
            return [line.split("\t") for line in result.stdout.splitlines() if line.strip()]
        except Exception:
            return []

    ips = set()
    domains = set()
    urls = set()
    sni = set()
    user_agents = set()

    # IPs from TCP SYN packets (external only)
    for row in tshark("tcp.flags.syn==1 and not tcp.flags.ack", ["ip.dst"]):
        ip = row[0].strip() if row else ""
        if ip and not is_private(ip):
            ips.add(ip)

    # DNS query names
    for row in tshark("dns.flags.response==0", ["dns.qry.name"]):
        name = row[0].strip() if row else ""
        if name and not is_allowlisted(name):
            domains.add(name)

    # HTTP hosts + URIs
    for row in tshark("http.request", ["http.host", "http.request.uri"]):
        host = row[0].strip() if len(row) > 0 else ""
        uri = row[1].strip() if len(row) > 1 else ""
        if host and not is_allowlisted(host):
            urls.add(f"http://{host}{uri}")

    # TLS SNI
    for row in tshark("tls.handshake.type==1", ["tls.handshake.extensions_server_name"]):
        name = row[0].strip() if row else ""
        if name and not is_allowlisted(name):
            sni.add(name)

    # User agents
    for row in tshark("http.user_agent", ["http.user_agent"]):
        ua = row[0].strip() if row else ""
        if ua:
            user_agents.add(ua)

    return {"ips": ips, "domains": domains, "urls": urls, "sni": sni, "user_agents": user_agents}

# ── ENRICHMENT ─────────────────────────────────────────────────────────
def vt_lookup(indicator: str, ioc_type: str) -> dict:
    if not VT_API_KEY:
        return {}
    endpoint = {"ip": "ip_addresses", "domain": "domains"}.get(ioc_type, "urls")
    url = f"https://www.virustotal.com/api/v3/{endpoint}/{urllib.parse.quote(indicator, safe='')}"
    try:
        req = urllib.request.Request(url, headers={"x-apikey": VT_API_KEY})
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
            stats = data.get("data", {}).get("attributes", {}).get("last_analysis_stats", {})
            return {"malicious": stats.get("malicious", 0), "suspicious": stats.get("suspicious", 0)}
    except Exception as e:
        return {"error": str(e)}

# ── MAIN ──────────────────────────────────────────────────────────────
def main():
    pcap_dir = Path(sys.argv[1])
    out_dir = Path(sys.argv[2])
    out_dir.mkdir(parents=True, exist_ok=True)

    pcap_files = sorted(glob.glob(str(pcap_dir / "*.pcap")))
    print(f"Processing {len(pcap_files)} PCAP files...")

    all_iocs = defaultdict(set)
    for pcap in pcap_files:
        print(f"  Extracting: {os.path.basename(pcap)}")
        result = extract_from_pcap(pcap)
        for key, values in result.items():
            all_iocs[key].update(values)

    print(f"\nExtracted:")
    for key, values in all_iocs.items():
        print(f"  {key}: {len(values)}")

    # Enrich IPs
    print(f"\nEnriching {len(all_iocs['ips'])} IPs against VirusTotal...")
    enriched = []
    for ip in sorted(all_iocs["ips"]):
        vt = vt_lookup(ip, "ip")
        score = vt.get("malicious", 0) * 3 + vt.get("suspicious", 0)
        enriched.append({"type": "ip", "value": ip, "vt_malicious": vt.get("malicious", 0),
                         "vt_suspicious": vt.get("suspicious", 0), "score": score})
        time.sleep(0.25)  # Rate limiting

    # Enrich domains
    print(f"Enriching {len(all_iocs['domains'])} domains...")
    for domain in sorted(all_iocs["domains"]):
        vt = vt_lookup(domain, "domain")
        score = vt.get("malicious", 0) * 3 + vt.get("suspicious", 0)
        enriched.append({"type": "domain", "value": domain, "vt_malicious": vt.get("malicious", 0),
                         "vt_suspicious": vt.get("suspicious", 0), "score": score})
        time.sleep(0.25)

    # Write outputs
    enriched.sort(key=lambda x: -x["score"])
    with open(out_dir / "enriched_iocs.json", "w") as f:
        json.dump(enriched, f, indent=2)

    print(f"\n=== Top 20 High-Score IOCs ===")
    for ioc in enriched[:20]:
        print(f"  score={ioc['score']:3d}  {ioc['type']:8}  {ioc['value']}")

    print(f"\nOutput: {out_dir}/enriched_iocs.json")

if __name__ == "__main__":
    main()

Q & A

Q: The enrichment step takes 4 hours because VirusTotal rate-limits to 4 lookups/second. How do I speed this up?

Several approaches: (1) Prioritize enrichment: don't enrich every IP — filter first. IPs that only appeared once in a capture and have no matching DNS name are likely scanners or CDN IPs; skip them. Enrich only IPs that appeared in DNS responses (meaning something resolved to them) or had sustained sessions. This can reduce the enrich set by 80%. (2) Use a private OSINT API: GreyNoise, Shodan, and commercial threat intel platforms have much higher rate limits and can often answer in bulk (query 1000 IPs at once). (3) Cache previous lookups: maintain a local SQLite database of previous VT lookups with a 7-day TTL. If the same IP was enriched yesterday, skip the API call and use the cached result. Many C2 IPs appear in multiple investigations — caching saves both time and API credits. (4) Parallel requests: use Python's ThreadPoolExecutor to run 4 VT lookups simultaneously (respecting the 4/second limit with a shared semaphore) instead of sequentially. This gives you the same throughput but doesn't stall one slow response from blocking the entire queue.