Day 101 — TLS Fingerprinting (JA3)
| Learning objective | Compute JA3 fingerprint from TLS ClientHello via tls.ClientHelloInfo |
| Key packages | crypto/tls, crypto/md5, fmt, strings |
| Security context | JA3 fingerprints the TLS stack, not the application — hard for bots to spoof without modifying TLS library |
Key Concepts
JA3 algorithm:
- Extract from ClientHello:
SSLVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats - Join each list with
-and sort (for extensions, preserve order) - Concatenate fields with
, - MD5 hash the result
Known JA3 hashes:
| Client | JA3 Hash (representative) |
|---|---|
| Chrome 120 | b32309a26951912be7dba376398abc3b |
| Firefox 120 | a0e9f5d64349fb13191bc781f81f42e1 |
| curl 8.x | 7dc465e28f1d46d873bf9c2ae6d05e43 |
| Python requests | 6597c339e4df3929c3cff7cc61c2b1c9 |
| Scrapy | 0700c0e4ebf0d82de6a30e0c28d21c0e |
Exercise — JA3 Fingerprinter
package bot
import (
"crypto/md5"
"crypto/tls"
"fmt"
"strings"
)
// JA3Fingerprint computes a JA3 hash from tls.ClientHelloInfo
func JA3Fingerprint(hello *tls.ClientHelloInfo) string {
// SSLVersion (always TLS — represented as 0x0303 for TLS 1.2, etc.)
// In Go's ClientHelloInfo, we use SupportedVersions
version := uint16(0x0303) // TLS 1.2 as base
if len(hello.SupportedVersions) > 0 {
version = hello.SupportedVersions[0]
}
// Ciphers — exclude GREASE values (0xXaXa pattern)
var ciphers []string
for _, c := range hello.CipherSuites {
if !isGREASE(c) {
ciphers = append(ciphers, fmt.Sprintf("%d", c))
}
}
// Extensions — from SupportedProtos, SignatureSchemes etc.
// In practice, ClientHelloInfo doesn't expose raw extension IDs.
// We use available fields as proxy.
var extensions []string
if len(hello.SupportedProtos) > 0 {
extensions = append(extensions, "16") // ALPN extension ID
}
if len(hello.SupportedVersions) > 0 {
extensions = append(extensions, "43") // supported_versions
}
if hello.ServerName != "" {
extensions = append(extensions, "0") // SNI
}
// SignatureSchemes → signature_algorithms extension (13)
if len(hello.SignatureSchemes) > 0 {
extensions = append(extensions, "13")
}
// SupportedCurves → supported_groups (10)
if len(hello.SupportedCurves) > 0 {
extensions = append(extensions, "10")
}
// Elliptic curves
var curves []string
for _, c := range hello.SupportedCurves {
if !isGREASECurve(uint16(c)) {
curves = append(curves, fmt.Sprintf("%d", c))
}
}
// Point formats (not directly exposed — use "0" as default)
pointFormats := "0"
// Construct JA3 string: SSLVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
ja3Str := fmt.Sprintf("%d,%s,%s,%s,%s",
version,
strings.Join(ciphers, "-"),
strings.Join(extensions, "-"),
strings.Join(curves, "-"),
pointFormats,
)
hash := md5.Sum([]byte(ja3Str))
return fmt.Sprintf("%x", hash)
}
func isGREASE(v uint16) bool {
// GREASE values: 0x0a0a, 0x1a1a, 0x2a2a, ... 0xfafa
return v&0x0f0f == 0x0a0a
}
func isGREASECurve(v uint16) bool {
return v&0x0f0f == 0x0a0a
}
// Known JA3 hashes for common clients
var knownBotJA3 = map[string]Signal{
"7dc465e28f1d46d873bf9c2ae6d05e43": {Name: "ja3_curl", Weight: 25, Category: CategoryNetwork, Reason: "curl TLS fingerprint"},
"6597c339e4df3929c3cff7cc61c2b1c9": {Name: "ja3_python_requests", Weight: 25, Category: CategoryNetwork, Reason: "Python requests TLS fingerprint"},
"0700c0e4ebf0d82de6a30e0c28d21c0e": {Name: "ja3_scrapy", Weight: 30, Category: CategoryNetwork, Reason: "Scrapy TLS fingerprint"},
}
var knownGoodJA3 = map[string]bool{
"b32309a26951912be7dba376398abc3b": true, // Chrome 120
"a0e9f5d64349fb13191bc781f81f42e1": true, // Firefox 120
}
// JA3Detector hooks into TLS handshake to compute fingerprint per connection
type JA3Detector struct {
scorer *BotScorer
}
// GetConfigForClient returns a per-client TLS config that captures the ClientHello
func (d *JA3Detector) GetConfigForClient(hello *tls.ClientHelloInfo) (*tls.Config, error) {
hash := JA3Fingerprint(hello)
// Look up the session for this connection's remote address
ip, _, _ := net.SplitHostPort(hello.Conn.RemoteAddr().String())
session := d.scorer.sessionFor(ip)
if sig, bad := knownBotJA3[hash]; bad {
session.AddSignal(sig)
} else if !knownGoodJA3[hash] {
// Unknown fingerprint — add modest signal
session.AddSignal(Signal{"ja3_unknown", 10, CategoryNetwork,
fmt.Sprintf("Unknown JA3: %s", hash)})
}
// Store JA3 in session for debugging
session.AddSignal(Signal{
Name: fmt.Sprintf("ja3:%s", hash),
Weight: 0, // informational only
Category: CategoryNetwork,
Reason: "JA3 fingerprint recorded",
})
return nil, nil // use default TLS config
}