TLS Certificate Forensics
X.509 certificates are transmitted in cleartext during the TLS handshake and contain a wealth of forensic information: the subject (who the cert claims to be), issuer (which CA issued it), serial number (unique across that CA), validity window, and Subject Alternative Names (all hostnames the cert covers). Attackers who use self-signed certs, recently-issued certs, or certs with generic subjects leave distinctive fingerprints. Certificate serial numbers also allow cross-investigation correlation — the same cert appearing on multiple IPs confirms shared infrastructure.
An adversary is rotating IP addresses for their C2 server every 48 hours to evade IP blocklists. But they're reusing the same TLS certificate across all the new IPs. In your Zeek ssl.log you see: five different destination IPs over two weeks, each with a different registration date, each with the same certificate serial number. The serial number is the invariant — it links all five IPs into one C2 cluster regardless of IP rotation.
Certificate Extraction from PCAP
#!/bin/bash
PCAP="$1"
echo "=== TLS certificates (subject + issuer) ==="
tshark -r "$PCAP" -n -Y "tls.handshake.type==11" -T fields \
-E separator="\t" \
-e ip.src \
-e ip.dst \
-e tls.handshake.certificate_length \
-e x509ce.dNSName \
-e tls.x509.subjectPublicKeyInfo_element \
| head -30
echo ""
echo "=== Certificate serial numbers (for pivoting) ==="
tshark -r "$PCAP" -n -Y "tls.handshake.type==11" -T fields \
-E separator="\t" \
-e ip.src -e ip.dst \
-e tls.handshake.certificate_length \
| head -20
# Export certificates as DER files for openssl analysis
echo ""
echo "=== Exporting TLS certificates ==="
tshark -r "$PCAP" -n \
-Y "tls.handshake.type==11" \
--export-objects tls,/tmp/certs/ 2>/dev/null && \
ls /tmp/certs/ 2>/dev/null | head -10
# Analyze exported certs with openssl
for cert in /tmp/certs/*.pem 2>/dev/null; do
echo "--- Certificate: $cert ---"
openssl x509 -in "$cert" -noout \
-subject -issuer -serial -dates -nameopt RFC2253 2>/dev/null
echo ""
done
Certificate Analysis Pipeline
#!/usr/bin/env python3
"""
Extract and analyze TLS certificates from PCAP.
Requires: pip install dpkt cryptography
"""
import dpkt, socket, struct, hashlib, sys, json
from collections import defaultdict
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from datetime import datetime, timezone
def ip_to_str(addr: bytes) -> str:
return socket.inet_ntoa(addr)
def parse_certificate_from_handshake(payload: bytes) -> list:
"""Extract DER-encoded certificates from TLS Certificate handshake message."""
certs = []
try:
pos = 0
# TLS record: type(1) + version(2) + length(2)
if len(payload) < 5 or payload[0] != 22:
return certs
rec_len = struct.unpack_from(">H", payload, 3)[0]
pos = 5
# Handshake: type(1) + length(3)
if pos >= len(payload) or payload[pos] != 11: # 11 = Certificate
return certs
pos += 4
# Certificates list length (3 bytes)
if pos + 3 > len(payload):
return certs
certs_len = struct.unpack_from(">I", b"\x00" + payload[pos:pos+3])[0]
pos += 3
end = pos + certs_len
while pos < end:
cert_len = struct.unpack_from(">I", b"\x00" + payload[pos:pos+3])[0]
pos += 3
cert_der = payload[pos:pos + cert_len]
certs.append(cert_der)
pos += cert_len
except Exception:
pass
return certs
def analyze_cert(cert_der: bytes, src: str, dst: str) -> dict:
try:
cert = x509.load_der_x509_certificate(cert_der, default_backend())
now = datetime.now(timezone.utc)
days_until_expiry = (cert.not_valid_after_utc - now).days
days_since_issued = (now - cert.not_valid_before_utc).days
validity_days = (cert.not_valid_after_utc - cert.not_valid_before_utc).days
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
dns_names = san.value.get_values_for_type(x509.DNSName)
except Exception:
dns_names = []
subject = cert.subject.rfc4514_string()
issuer = cert.issuer.rfc4514_string()
serial = hex(cert.serial_number)
# Compute fingerprints
sha1 = hashlib.sha1(cert_der).hexdigest()
sha256 = hashlib.sha256(cert_der).hexdigest()
# Flags
flags = []
if "CN=Issuer" in issuer or subject == issuer:
flags.append("SELF-SIGNED")
if days_since_issued < 7:
flags.append("NEWLY-ISSUED")
if validity_days > 365 * 3:
flags.append("LONG-VALIDITY")
if not dns_names:
flags.append("NO-SAN")
return {
"src": src, "dst": dst,
"subject": subject[:80],
"issuer": issuer[:80],
"serial": serial,
"dns_names": dns_names,
"not_before": cert.not_valid_before_utc.isoformat(),
"not_after": cert.not_valid_after_utc.isoformat(),
"days_since_issued": days_since_issued,
"validity_days": validity_days,
"sha1": sha1,
"sha256": sha256,
"flags": flags,
}
except Exception as e:
return {"error": str(e), "src": src, "dst": dst}
# Main
pcap_file = sys.argv[1]
cert_records = []
with open(pcap_file, "rb") as f:
pcap = dpkt.pcap.Reader(f)
for ts, buf in pcap:
try:
eth = dpkt.ethernet.Ethernet(buf)
if not isinstance(eth.data, dpkt.ip.IP):
continue
ip = eth.data
if not isinstance(ip.data, dpkt.tcp.TCP):
continue
tcp = ip.data
payload = bytes(tcp.data)
if len(payload) < 10:
continue
certs = parse_certificate_from_handshake(payload)
if certs:
src = ip_to_str(ip.src)
dst = ip_to_str(ip.dst)
for cert_der in certs[:1]: # First cert = server cert
record = analyze_cert(cert_der, src, dst)
if "error" not in record:
cert_records.append(record)
except Exception:
pass
# Print flagged certificates
print("=== Flagged Certificates ===")
for r in cert_records:
if r.get("flags"):
print(f"\n {r['src']}→{r['dst']}")
print(f" Subject: {r['subject']}")
print(f" Issuer: {r['issuer']}")
print(f" Serial: {r['serial']}")
print(f" SANs: {r['dns_names']}")
print(f" Issued: {r['not_before']} (age: {r['days_since_issued']}d)")
print(f" Flags: {r['flags']}")
# Group by serial (find reused certs across multiple IPs)
from collections import defaultdict
by_serial = defaultdict(list)
for r in cert_records:
by_serial[r.get("serial","")].append(r["dst"])
print("\n=== Certificates shared across multiple IPs ===")
for serial, ips in by_serial.items():
unique = set(ips)
if len(unique) > 1:
print(f" Serial {serial}: {unique}")
The SNI in a TLS ClientHello is sent by the client and says what hostname the client thinks it's connecting to — it's not verification. An attacker controlling both the DNS and the server can set the SNI to anything, including microsoft.com, and the client will send that SNI in the ClientHello. What matters for server identity verification is the certificate the server presents. If the SNI says teams.microsoft.com but the server's certificate is self-signed with subject CN=localhost, that's a red flag regardless of the SNI. In forensics: always cross-reference SNI against the actual certificate presented. Zeek's ssl.log includes both server_name (SNI) and subject (cert subject) — compare them. A mismatch is an immediate investigation priority.
Q & A
Q: How do I pivot from a certificate SHA1 fingerprint across multiple PCAPs from different time periods?
Certificate pivoting across multiple PCAPs: (1) Extract certificate SHA1 fingerprints from each PCAP using the script above, (2) Build a lookup table: {sha1_fingerprint: [list of (timestamp, src_ip, dst_ip)]}, (3) Query across all PCAPs: find any SHA1 that appears with different destination IPs — this reveals IP rotation while reusing the same cert, (4) For external threat intel: submit the SHA1 to Censys or Shodan certificate search — they maintain historical records of which IPs hosted which certificates, often going back years. Censys's certificate database covers billions of certificates and can show you every IP that ever presented a given cert. If you find the same SHA1 on multiple IPs over time in your internal logs, Censys often has even more historical context about the same cert appearing on other attack infrastructure. Practical limit: if the attacker is using Let's Encrypt, they can get new free certs within minutes — pivoting by cert only works when they're lazy about cert rotation.