Detecting Cobalt Strike by its TLS Handshake
Every TLS connection starts with a ClientHello message. The client announces which TLS version it supports, which cipher suites it offers, which extensions it uses, and which elliptic curves it prefers. This combination is remarkably stable across runs of the same software. Cobalt Strike's Beacon, Metasploit's Meterpreter, and Emotet all have distinctive TLS fingerprints that survive IP rotation, domain fronting, and certificate changes.
Inner Warden captures these fingerprints in real time using pure Rust, no libpcap, no external dependencies. This post explains how JA3 and JA4 fingerprinting works, how we implemented it, and which malware families you can detect today.
What is JA3
JA3 was created by John Althouse, Jeff Atkinson, and Josh Atkins at Salesforce in 2017. The idea is simple: extract five fields from the TLS ClientHello, concatenate them in a specific order separated by commas, and hash the result with MD5.
MD5(
TLSVersion,
Ciphers,
Extensions,
EllipticCurves,
EllipticCurvePointFormats
)
Example raw string:
771,4866-4867-4865-49196-49200-159-52393-52392-52394-49195-49199-158-49188-49192-107-49187-49191-103-49162-49172-57-49161-49171-51-157-156-61-60-53-47-255,0-11-10-35-22-23-13-43-45-51-21,29-23-30-25-24,0-1-2
JA3 hash: e35d3904ce04caf3c038c24a8983e13eThe five fields capture the client's TLS capabilities in sufficient detail to distinguish most software implementations. A Chrome browser on Windows produces a different JA3 than Firefox, which differs from curl, which differs from Cobalt Strike.
How JA4 improves on JA3
JA4, created by John Althouse at FoxIO in 2023, addresses several limitations of JA3. Where JA3 is a single opaque hash, JA4 produces a human-readable fingerprint with three sections:
- JA4_a - protocol type + TLS version + SNI presence + cipher count + extension count + ALPN first value. This is readable without lookup tables.
- JA4_b - truncated SHA-256 of sorted cipher suites. Sorting removes ordering-based evasion.
- JA4_c - truncated SHA-256 of sorted extensions + signature algorithms. Captures the full extension profile.
Because JA4 sorts cipher suites and extensions before hashing, an attacker cannot evade detection by simply reordering them. JA3 was vulnerable to this. Inner Warden computes both JA3 and JA4 for every TLS connection, giving you backward compatibility with existing threat intel feeds while gaining the robustness of JA4.
Pure Rust, no libpcap
Most TLS fingerprinting tools depend on libpcap for packet capture (Zeek, Suricata, ja3-rs). Inner Warden uses AF_PACKET raw sockets on Linux to capture packets directly from the network interface. No C library dependency. No FFI. No pcap file format.
AF_PACKET socket (SOCK_RAW)
→ Ethernet frame
→ IP header parse (v4/v6)
→ TCP header parse
→ TLS record layer (content_type == 22)
→ Handshake (msg_type == 1 = ClientHello)
→ Extract: version, ciphers, extensions, curves, point_formats
→ Filter GREASE values
→ Compute JA3 string → MD5 → lookup
→ Compute JA4 sections → SHA-256 truncate → lookupThe JA3 spec requires MD5 for the final hash. We implement MD5 in pure Rust rather than pulling in an external crypto crate for a single hash function. The implementation is 128 lines, well-tested, and avoids adding a dependency that would pull in dozens of transitive crates for one function call.
GREASE value filtering
RFC 8701 introduced GREASE (Generate Random Extensions And Sustain Extensibility): random values injected into ClientHello fields to prevent server ossification. Chrome and Firefox add random GREASE values to cipher suites and extensions on every connection. If you do not filter them, the same browser produces a different JA3 hash every time.
0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a,
0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a,
0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa
Pattern: (value & 0x0f0f) == 0x0a0aInner Warden filters GREASE values before computing both JA3 and JA4 hashes. The check is a single bitmask operation per value, adding negligible overhead.
Known malicious fingerprints
Inner Warden ships with a curated list of known malicious JA3 hashes. When a TLS connection matches one of these fingerprints, it is flagged as a C2 callback and an incident is created with the malware family name:
72a589da586844d7f0818ce684948eea5d65ea3ab1d764ee22f9d68b2b5efea84d7a28d6f2263ed61de88ca66eb011e36734f37431670b3ab4292b8f60f29984c12f54a3f91dc7bafd92b1b4e5b1d41c2c535ae0ecbe460f0b94e3b7f1d60579e35d3904ce04caf3c038c24a8983e13eb37e25c3d9b5c40e1cfe2e0a4e7a4395This list is updated with each release. Hashes are sourced from abuse.ch, Trellix, and community submissions.
Comparison with Zeek and Suricata
Zeek and Suricata both support JA3 fingerprinting, but with different trade-offs:
Inner Warden's advantage is integration. A JA3 match does not just create a log entry. It feeds into the correlation engine, checks the behavioral DNA database, triggers IP blocking, and sends operator alerts. The TLS fingerprint becomes one signal in a larger detection chain.
Adding custom JA3 hashes
If you have threat intelligence with JA3 hashes specific to your environment (internal red team tools, known APT fingerprints), add them to your configuration:
[sensor.tls_fingerprint]
enabled = true
# Custom malicious JA3 hashes
[[sensor.tls_fingerprint.malicious_ja3]]
hash = "a0e9f5d64349fb13191bc781f81f42e1"
name = "InternalRedTeam-Implant"
[[sensor.tls_fingerprint.malicious_ja3]]
hash = "b4f0e9c245ab37df1098cc76ef4b2a18"
name = "APT-CustomLoader"Custom hashes are loaded at startup and checked alongside the built-in list. Matches produce the same incident pipeline as built-in detections: correlation, alerting, and optional auto-blocking.
What to do next
- eBPF kernel security - how Inner Warden's 30 eBPF programs monitor kernel events in real time alongside TLS fingerprinting.
- Cross-layer correlation - how a TLS fingerprint match combines with kernel events and honeypot data to build a complete attack picture.
- Behavioral DNA - fingerprinting attackers by behavior instead of IP address, correlating campaigns across sessions.