Zeek ssl.log & x509.log¶
In one sentence
You can't read encrypted traffic, but the handshake is cleartext: ssl.log gives you the server name the client asked for (SNI), the TLS version and cipher, a JA3/JA4 fingerprint of the client software, and pointers to the certificate chain in x509.log — which is enough to identify C2 by its self-signed or default certificate, spot tools by their fingerprint, and separate real browsing from scripted traffic.
ssl.log fields¶
| Field | Meaning | Analyst notes |
|---|---|---|
ts, uid, id.* |
As in conn.log |
|
version |
TLSv10, TLSv12, TLSv13, SSLv3, DTLSv12 |
TLS 1.3 encrypts the server cert → x509.log may be empty for it |
cipher |
Negotiated cipher suite | Ancient/weak suites from a modern host = old tooling |
curve |
Key-exchange curve | |
server_name |
SNI — hostname the client asked for | The "what did they connect to" field. Blank SNI = direct-to-IP or old tooling |
resumed |
Session resumption used | Beacons often resume; many full handshakes to one host is also odd |
last_alert |
TLS alert if any | handshake_failure, unknown_ca… |
next_protocol |
ALPN (h2, http/1.1) |
Missing ALPN from a "browser" = not a browser |
established |
Handshake completed | F with many attempts = probing / failing C2 |
ssl_history (Zeek 5+) |
Letter history of the handshake | Like conn.log history, for TLS |
cert_chain_fuids / client_cert_chain_fuids |
File IDs of server / client cert chain → x509.log |
First element = leaf cert |
subject / issuer |
Leaf cert subject / issuer (if seen) | Self-signed when equal |
client_subject / client_issuer |
Client cert (mutual TLS) | Rare — VPNs, some C2 |
validation_status |
ok, self signed certificate, unable to get local issuer certificate, certificate has expired… |
Requires the validate-certs policy |
ja3 / ja3s (policy) |
Client / server TLS fingerprint (MD5) | Client software fingerprint |
ja4 / ja4s (policy, Zeek 6+) |
Newer, sortable fingerprint | e.g. t13d1516h2_8daaf6152771_02713d6af862 |
sni_matches_cert (policy) |
Does SNI match the cert's names? | F = domain fronting, misconfig, or fake cert |
x509.log fields¶
| Field | Meaning | Analyst notes |
|---|---|---|
ts, id / fingerprint |
File ID (fuid) of the cert; SHA-1/256 fingerprint |
Join to ssl.log cert_chain_fuids |
certificate.version |
3 usually | |
certificate.serial |
Serial number | Default tool certs have known serials |
certificate.subject |
CN=…,O=…,C=… |
Empty/garbage/CN=localhost → suspicious |
certificate.issuer |
Who signed it | = subject → self-signed |
certificate.not_valid_before / not_valid_after |
Validity window | Cert created minutes before first use, or valid for 1 or 100 years → generated by a tool |
certificate.key_alg, sig_alg, key_type, key_length, exponent, curve |
Key details | 1024-bit RSA / sha1WithRSA = old/lazy |
san.dns, san.uri, san.email, san.ip |
Subject Alternative Names | Real sites list many; C2 certs list none or one |
basic_constraints.ca, path_len |
CA flag | Leaf with ca=T is odd |
host_cert, client_cert (Zeek 5+) |
Where the cert was seen |
Quick queries¶
# Top SNIs from internal hosts — long tail again
zeek-cut server_name < ssl.log | sort | uniq -c | sort -n | head -40
# TLS to raw IP (no SNI) — legit CDNs rarely, C2 often
zeek-cut -d ts id.orig_h id.resp_h id.resp_p server_name version ja3 < ssl.log | awk '$5=="-"'
# Self-signed / invalid certs
zeek-cut -d ts id.orig_h id.resp_h server_name validation_status subject issuer < ssl.log | grep -v -P '\tok\t'
# Rare JA3 hashes (few clients use them) and where they go
zeek-cut id.orig_h ja3 < ssl.log | sort -u | awk '{print $2}' | sort | uniq -c | sort -n | head
zeek-cut -d ts id.orig_h id.resp_h server_name < ssl.log | grep -F '<ja3 hash>'
# Certificates valid for suspiciously short/long periods, or created just before use
zeek-cut -d ts certificate.subject certificate.issuer certificate.not_valid_before certificate.not_valid_after < x509.log \
| awk -F'\t' '{print}' | sort -u | head
# Certs with no SAN (real sites almost always have one)
zeek-cut fingerprint certificate.subject san.dns < x509.log | awk -F'\t' '$3=="-"' | sort -u
# Pivot: one connection's cert chain
grep -F 'CHhAvVGS1DHFjwGM9' ssl.log | zeek-cut cert_chain_fuids | tr ',' '\n' | while read f; do grep -F "$f" x509.log; done | zeek-cut certificate.subject certificate.issuer certificate.not_valid_before
# Known-bad default certificates
# Cobalt Strike default: CN=Major Cobalt Strike, serial 146473198 ; Metasploit: random CNs but 'O=…' patterns; Sliver: random 'O=' words
grep -iE 'Major Cobalt Strike|146473198' x509.log
index=zeek sourcetype=zeek:ssl
| stats dc(id.orig_h) as clients dc(id.resp_h) as servers values(server_name) as sni count by ja3
| where clients <= 3
| sort - count
Groups all TLS handshakes by the client fingerprint ja3. values(server_name) lists every SNI seen with that fingerprint (deduplicated). A fingerprint used by ≤ 3 machines that talks to one or two servers thousands of times is a beacon; browsers produce fingerprints shared by the whole fleet.
index=zeek sourcetype=zeek:ssl validation_status!="ok" validation_status!="-"
| stats count min(_time) as first max(_time) as last by id.orig_h, id.resp_h, server_name, validation_status, subject
| convert ctime(first) ctime(last)
min(_time)/max(_time) give first and last time each internal host hit each untrusted certificate; convert ctime() turns the epoch numbers into readable dates. Sort by count to see which self-signed cert is being visited on a schedule.
Analysis tips¶
The cheap C2 checklist for one ssl.log row
No SNI or SNI is a raw IP · validation_status ≠ ok · subject = issuer · cert valid from today · no SAN · next_protocol blank · JA3 unique to this host · id.resp_p 443 on a bare VPS IP · connection repeats at a fixed interval. Three or more ticks → treat as C2 until proven otherwise.
- TLS 1.3 hides the server certificate. For 1.3 you keep SNI, JA3/JA4, ALPN and timing, but
x509.logwon't have the server cert (unless a decrypting proxy). Encrypted SNI/ECH removes even the name — then you're down to JA4, destination IP reputation and behaviour. - JA3 collisions: many tools share fingerprints (Go's TLS stack, .NET, Python
requests). Use JA3 to cluster and rare-list, not as a signature by itself; JA4 splits better (t13d…shows version, ALPN, cipher count). Also fingerprint the server (ja3s/ja4s) — a C2 framework's listener answers the same way to every implant. - Domain fronting: SNI says
a.legit-cdn.com, the innerHost(invisible) says otherwise. Signs:sni_matches_cert = F, SNI on a CDN with unusually regular traffic,resp_hin a CDN range hit by exactly one host on a schedule. - Let's Encrypt is not a green flag: modern C2 (Sliver, Mythic, Havoc, redirectors) uses valid LE certs. Then fall back to behaviour: interval, byte sizes, destination age, JA3 rarity.
- Legit self-signed: printers, IoT, internal admin panels, dev boxes. Filter
local_resp = Tfirst, then look at what's left going to the internet. - Client certs (
client_subject) in outbound traffic from a workstation → VPN, corporate proxy, or a C2 using mutual TLS (rare, but Sliver mTLS mode exists). - Version/cipher outliers: a single host negotiating
TLSv10withTLS_RSA_WITH_RC4_128_SHAin 2026 is running something very old — or a deliberately downgraded tool. - Certificate reuse across IPs: the same
x509fingerprint appearing on severalid.resp_hover time = the actor moves infrastructure but keeps the cert. Great pivot for threat-intel and for retro-hunting.
Correlation¶
| Question | Then look at |
|---|---|
| How much moved, how often? | conn.log uid → bytes, duration; interval analysis (Beaconing & C2) |
| What did the name resolve from? | dns.log query = server_name moments earlier |
| Which process? | Sysmon 3 (with DestinationPort 443) on the host; EDR network telemetry |
| Was the cert used elsewhere? | x509.log fingerprint across time; Censys / Shodan / crt.sh for the same fingerprint or serial |
| Alerts? | Suricata tls.sni, JA3 rules (ja3.hash), notice.log SSL::Invalid_Server_Cert |
| Payload | Not available without decryption — pivot to host artefacts (Prefetch, SRUM, memory) |