Frontend Labs
⋯

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:

  1. Extract from ClientHello: SSLVersion, Ciphers, Extensions, EllipticCurves, EllipticCurvePointFormats
  2. Join each list with - and sort (for extensions, preserve order)
  3. Concatenate fields with ,
  4. 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
}

Generated from week14.md · 8 Oct 2026 16:36