TLS Fingerprint Detection
Chapter 34: TLS Fingerprint Detection
In 2017, security researcher John Althouse published a blog post describing a technique he had built: by observing the ClientHello message that a TLS client sends at the very start of every encrypted connection, he could identify which software had initiated the connection — without decrypting a single byte. He named the technique JA3.
The idea is at once simple and striking: even in HTTPS traffic, even when all actual content is encrypted, the client's "digital fingerprint" sits in the clear on the wire. Cloudflare rapidly deployed JA3 fingerprint detection across millions of websites, using it to distinguish real browsers from scrapers and bots. It quickly became one of the standard weapons in the bot-detection arsenal.
TLS fingerprinting reveals a deep truth about cryptography: encrypting content is not the same as encrypting behavior. You can hide what you say, but the way you shake hands — which cipher suites you advertise, in what order, which extensions you include — that metadata speaks volumes.
This chapter traces the binary structure of TLS ClientHello, implements a server that computes JA3 and JA4 fingerprints, and explores the ongoing cat-and-mouse game between detection and evasion.
Level 1 · What You Need to Know
What TLS Fingerprinting Reveals
In an HTTPS connection, the TLS handshake happens before any HTTP data is exchanged. ClientHello is the very first handshake message, sent by the client, and its contents are completely plaintext (even under TLS 1.3, the extension data in ClientHello remains unencrypted until Encrypted ClientHello/ECH is negotiated).
ClientHello carries:
- Which TLS versions the client supports
- Which cipher suites the client supports, and in what priority order
- Which TLS extensions the client includes, and their values
- Which elliptic curves (named groups) the client supports for key exchange
- Which elliptic curve point formats the client supports
Different software stacks produce different combinations. Chrome, Firefox, curl, Python requests, Java's HttpClient — each has a distinctive fingerprint, as individual as a person's handwriting.
JA3 use cases:
- Bot detection: A genuine Chrome browser produces a consistent JA3 hash for a given version; a simple curl or Python script has a completely different hash. Cloudflare's Bot Management product relies heavily on this.
- Malware identification: Many malware families use custom TLS implementations and leave distinctive JA3 fingerprints. Security teams maintain databases of known-bad fingerprints for IDS/IPS rules.
- Encrypted traffic classification: Even in encrypted traffic, JA3 can reveal the application type — the Tor Browser, a particular version of OpenSSL, a specific IoT device firmware.
- Compliance auditing: Verify that internal systems use approved TLS library versions, not outdated and vulnerable ones.
JA3's Limitations
JA3 is not a silver bullet:
Hash collisions: Different clients can produce the same JA3 hash. The hash space is 128-bit MD5, and as more clients converge on the same modern cipher suites, JA3's discriminating power decreases.
Spoofability: A determined attacker can use the utls library to forge a specific browser's ClientHello, defeating JA3 detection entirely (see Level 4).
Version sensitivity: Browser upgrades often change the JA3 fingerprint, requiring continuous database updates.
These limitations drove the development of JA4 and the broader JA4+ family, which offer finer-grained and harder-to-spoof fingerprint characteristics.
Level 2 · How It Works Under the Hood
TLS ClientHello Structure
The TLS record layer is the outer framing. Every TLS record has:
+--+--+--+--+--+-----...-----+
| ContentType | Version | Length | Data...
| (1 byte) |(2 bytes)|(2 bytes)|
+--+--+--+--+--+-----...-----+
ClientHello has ContentType 0x16 (Handshake) and a Version typically of 0x0301 (TLS 1.0, for backward compatibility; the actual negotiated version is carried in extensions).
Inside the Handshake layer, the ClientHello body:
+------+--+--+--+---------+---------+-----+----+----...----+--+
| Type | Length | Version | Random |SIDLen|Cipher Suites |...
| 0x01 |(3 bytes)|(2 bytes)|(32 bytes)| (1B)| |
+------+---------+---------+---------+------+----...-------+--+
Key fields:
- Type:
0x01= ClientHello - Version: typically
0x0303(TLS 1.2, even when negotiating TLS 1.3, for compatibility) - Random: 32 random bytes (the first 4 were historically a Unix timestamp, now also random)
- Session ID: 0–32 bytes for session resumption
- Cipher Suites: 2-byte length + N two-byte cipher suite IDs
- Compression Methods: typically just
0x00(none; compression is deprecated) - Extensions: 2-byte total length + a sequence of extensions (each: 2-byte type + 2-byte length + data)
Key extensions:
| ID | Name | Purpose |
|---|---|---|
| 0x0000 | SNI | Plaintext hostname — the target domain |
| 0x000a | Supported Groups | Elliptic curves for key exchange |
| 0x000b | EC Point Formats | Supported curve point formats |
| 0x000d | Signature Algorithms | Supported signature schemes |
| 0x0010 | ALPN | Application-layer protocol (HTTP/2, etc.) |
| 0x0015 | Padding | Pads ClientHello to a fixed length |
| 0x0023 | Session Ticket | Session resumption without server state |
| 0x002b | Supported Versions | TLS versions (required for TLS 1.3) |
| 0x002d | PSK Key Exchange Modes | TLS 1.3 key exchange |
| 0x0033 | Key Share | TLS 1.3 key material |
| 0xfe0d | Encrypted ClientHello | ECH — encrypts most of ClientHello |
JA3 Hash Computation
JA3 computation is straightforward:
-
Extract five fields from ClientHello:
- TLSVersion: the version declared in the ClientHello header
- Ciphers: the cipher suite ID list, excluding GREASE values
- Extensions: the extension type ID list, excluding GREASE values
- EllipticCurves: the Supported Groups list, excluding GREASE values
- EllipticCurvePointFormats: the EC Point Formats list
-
Concatenate as a string:
TLSVersion,Ciphers-list,Extensions-list,EllipticCurves-list,PointFormats-listItems within a list are joined with
-; the five sections are joined with,. -
Compute the MD5 hash of that string — the result is the 32-character hex JA3 fingerprint.
GREASE value filtering: GREASE (Generate Random Extensions And Sustain Extensibility, RFC 8701) is a mechanism from Google that injects reserved values into TLS extensions to test server extensibility handling. These values must be filtered out during JA3 computation, otherwise the same client would produce different JA3 hashes on different connections. GREASE values follow a pattern: 0x?A?A (e.g., 0x0a0a, 0x1a1a, ..., 0xfafa).
Example JA3 string (Chrome 120):
771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21,29-23-24,0
MD5: cd08e31494f9531f560d64c695473da9
Capturing ClientHello Before Handshake: Intercepting net.Conn
Go's standard crypto/tls package does not expose the raw ClientHello bytes directly. To capture them, we need to intercept at the TCP layer and read the ClientHello before TLS processing consumes it.
The approach: wrap net.Conn to "peek" at bytes flowing through the Read method:
type peekConn struct {
net.Conn
peeked bool
capture func([]byte)
}
func (c *peekConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
if !c.peeked && n > 0 {
data := make([]byte, n)
copy(data, b[:n])
c.capture(data)
c.peeked = true
}
return
}
There is a caveat: the TLS stack may call Read multiple times to receive the full ClientHello if it is split across TCP segments. A more robust approach is tls.Config.GetConfigForClient, which fires a callback after the TLS library has already parsed the ClientHello:
tlsConfig := &tls.Config{
GetConfigForClient: func(info *tls.ClientHelloInfo) (*tls.Config, error) {
// info contains the parsed ClientHello fields
ja3Str, ja3Hash := computeJA3FromInfo(info)
log.Printf("JA3 from %s: %s (%s)", info.Conn.RemoteAddr(), ja3Hash, ja3Str)
return nil, nil // nil means use the global config
},
}
tls.ClientHelloInfo provides:
SupportedVersions: the TLS versions the client offeredCipherSuites: cipher suites (Go has already filtered GREASE)SupportedCurves: elliptic curvesSupportedPoints: point formatsSupportedProtos: ALPN protocol listServerName: the SNI hostname
Level 3 · Code in Practice
Implementing a TLS Fingerprint Server
Below is a complete TLS fingerprint detection server: it captures raw ClientHello bytes via a wrapping listener, parses them, computes JA3 and JA4 hashes, and returns a JSON fingerprint response.
ClientHello parser (clienthello.go):
package main
import (
"encoding/binary"
"fmt"
"strings"
)
// GREASE values must be excluded from JA3/JA4 computation.
var greaseTable = map[uint16]bool{
0x0a0a: true, 0x1a1a: true, 0x2a2a: true, 0x3a3a: true,
0x4a4a: true, 0x5a5a: true, 0x6a6a: true, 0x7a7a: true,
0x8a8a: true, 0x9a9a: true, 0xaaaa: true, 0xbaba: true,
0xcaca: true, 0xdada: true, 0xeaea: true, 0xfafa: true,
}
func isGREASE(v uint16) bool { return greaseTable[v] }
type ClientHelloData struct {
Version uint16
CipherSuites []uint16
Extensions []uint16
EllipticCurves []uint16
EllipticCurvePoints []uint8
SNI string
ALPN []string
SupportedVersions []uint16
Raw []byte
}
// ParseClientHello parses the ClientHello body starting from the Version field.
// The caller must strip the 5-byte TLS record header and 4-byte Handshake header
// before passing data here.
func ParseClientHello(data []byte) (*ClientHelloData, error) {
if len(data) < 34 {
return nil, fmt.Errorf("data too short (%d bytes)", len(data))
}
ch := &ClientHelloData{Raw: data}
ch.Version = binary.BigEndian.Uint16(data[0:2])
offset := 34 // skip Version (2) + Random (32)
// Session ID
if offset >= len(data) {
return nil, fmt.Errorf("truncated at session ID")
}
sidLen := int(data[offset])
offset += 1 + sidLen
// Cipher Suites
if offset+2 > len(data) {
return nil, fmt.Errorf("truncated at cipher suite length")
}
csLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
offset += 2
for i := 0; i+1 < csLen && offset+i+1 < len(data); i += 2 {
cs := binary.BigEndian.Uint16(data[offset+i : offset+i+2])
if !isGREASE(cs) {
ch.CipherSuites = append(ch.CipherSuites, cs)
}
}
offset += csLen
// Compression Methods
if offset >= len(data) {
return nil, fmt.Errorf("truncated at compression methods")
}
cmLen := int(data[offset])
offset += 1 + cmLen
// Extensions
if offset+2 > len(data) {
return ch, nil // no extensions section
}
extTotal := int(binary.BigEndian.Uint16(data[offset : offset+2]))
offset += 2
end := offset + extTotal
if end > len(data) {
end = len(data)
}
for offset+4 <= end {
extType := binary.BigEndian.Uint16(data[offset : offset+2])
extLen := int(binary.BigEndian.Uint16(data[offset+2 : offset+4]))
offset += 4
if offset+extLen > len(data) {
break
}
extData := data[offset : offset+extLen]
offset += extLen
if isGREASE(extType) {
continue
}
ch.Extensions = append(ch.Extensions, extType)
switch extType {
case 0x0000:
ch.SNI = parseSNIExtension(extData)
case 0x000a:
ch.EllipticCurves = parseUint16List2(extData, true)
case 0x000b:
ch.EllipticCurvePoints = parseUint8List2(extData)
case 0x0010:
ch.ALPN = parseALPNExtension(extData)
case 0x002b:
ch.SupportedVersions = parseSupportedVersionsExt(extData)
}
}
return ch, nil
}
func parseSNIExtension(data []byte) string {
// list length (2) + type (1) + name length (2) + name
if len(data) < 5 || data[2] != 0 {
return ""
}
nameLen := int(binary.BigEndian.Uint16(data[3:5]))
if 5+nameLen > len(data) {
return ""
}
return string(data[5 : 5+nameLen])
}
func parseUint16List2(data []byte, filterGREASE bool) []uint16 {
if len(data) < 2 {
return nil
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
data = data[2:]
if listLen > len(data) {
listLen = len(data)
}
var out []uint16
for i := 0; i+1 < listLen; i += 2 {
v := binary.BigEndian.Uint16(data[i : i+2])
if filterGREASE && isGREASE(v) {
continue
}
out = append(out, v)
}
return out
}
func parseUint8List2(data []byte) []uint8 {
if len(data) < 1 {
return nil
}
n := int(data[0])
if n+1 > len(data) {
return nil
}
out := make([]uint8, n)
copy(out, data[1:1+n])
return out
}
func parseALPNExtension(data []byte) []string {
if len(data) < 2 {
return nil
}
total := int(binary.BigEndian.Uint16(data[0:2]))
data = data[2:]
if total > len(data) {
total = len(data)
}
var protos []string
i := 0
for i < total {
if i >= len(data) {
break
}
l := int(data[i])
i++
if i+l > len(data) {
break
}
protos = append(protos, string(data[i:i+l]))
i += l
}
return protos
}
func parseSupportedVersionsExt(data []byte) []uint16 {
if len(data) < 1 {
return nil
}
n := int(data[0])
data = data[1:]
if n > len(data) {
n = len(data)
}
var out []uint16
for i := 0; i+1 < n; i += 2 {
v := binary.BigEndian.Uint16(data[i : i+2])
if !isGREASE(v) {
out = append(out, v)
}
}
return out
}
JA3 and JA4 computation (fingerprint.go):
package main
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strconv"
"strings"
)
// ComputeJA3 returns the JA3 string and its MD5 hash.
func ComputeJA3(ch *ClientHelloData) (string, string) {
ver := strconv.Itoa(int(ch.Version))
ciphers := u16Join(ch.CipherSuites, "-")
exts := u16Join(ch.Extensions, "-")
curves := u16Join(ch.EllipticCurves, "-")
points := u8Join(ch.EllipticCurvePoints, "-")
ja3Str := strings.Join([]string{ver, ciphers, exts, curves, points}, ",")
h := md5.Sum([]byte(ja3Str))
return ja3Str, hex.EncodeToString(h[:])
}
func u16Join(vs []uint16, sep string) string {
ss := make([]string, len(vs))
for i, v := range vs {
ss[i] = strconv.Itoa(int(v))
}
return strings.Join(ss, sep)
}
func u8Join(vs []uint8, sep string) string {
ss := make([]string, len(vs))
for i, v := range vs {
ss[i] = strconv.Itoa(int(v))
}
return strings.Join(ss, sep)
}
type JA4 struct {
A string // version + SNI indicator + counts + first ALPN
B string // sorted cipher hash (SHA256, first 12 hex chars)
C string // sorted extension hash (SHA256, first 12 hex chars)
Full string // a_b_c
}
// ComputeJA4 computes a JA4 fingerprint (simplified; see FoxIO spec for full detail).
func ComputeJA4(ch *ClientHelloData) JA4 {
// JA4a: t{tlsver}{sni}{ciphercount}{extcount}{firstALPN}
var tlsVer string
if len(ch.SupportedVersions) > 0 {
max := uint16(0)
for _, v := range ch.SupportedVersions {
if v > max {
max = v
}
}
switch max {
case 0x0304:
tlsVer = "13"
case 0x0303:
tlsVer = "12"
case 0x0302:
tlsVer = "11"
default:
tlsVer = fmt.Sprintf("%02d", max&0xff)
}
} else {
switch ch.Version {
case 0x0303:
tlsVer = "12"
default:
tlsVer = fmt.Sprintf("%02d", ch.Version&0xff)
}
}
sni := "i"
if ch.SNI != "" {
sni = "d"
}
alpn := "00"
if len(ch.ALPN) > 0 {
p := ch.ALPN[0]
if len(p) >= 2 {
alpn = string([]byte{p[0], p[len(p)-1]})
}
}
a := fmt.Sprintf("t%s%s%02d%02d%s",
tlsVer, sni, len(ch.CipherSuites), len(ch.Extensions), alpn)
// JA4b: SHA256 of sorted cipher list, first 12 hex chars
sorted := make([]uint16, len(ch.CipherSuites))
copy(sorted, ch.CipherSuites)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
hb := sha256.Sum256([]byte(u16Join(sorted, ",")))
b := hex.EncodeToString(hb[:])[:12]
// JA4c: SHA256 of sorted extension list (excluding SNI=0 and ALPN=16)
var exts []uint16
for _, e := range ch.Extensions {
if e != 0x0000 && e != 0x0010 {
exts = append(exts, e)
}
}
sort.Slice(exts, func(i, j int) bool { return exts[i] < exts[j] })
hc := sha256.Sum256([]byte(u16Join(exts, ",")))
c := hex.EncodeToString(hc[:])[:12]
full := a + "_" + b + "_" + c
return JA4{A: a, B: b, C: c, Full: full}
}
Server with capturing listener (main.go):
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"sync"
"time"
)
// Known JA3 fingerprint database
var knownClients = map[string]string{
"cd08e31494f9531f560d64c695473da9": "Chrome 120",
"579ccef312d18482fc42e2b822ca2430": "Firefox 121",
"b32309a26951912be7dba376398abc3b": "curl/7.88",
"e6573e91e6eb777c0933c5b8f97f10cd": "Python requests/2.31",
"a0e9f5d64349fb13191bc781f81f42e1": "Go net/http",
}
var (
helloMu sync.RWMutex
helloStore = make(map[string]*ClientHelloData) // key: remote addr string
)
// capConn intercepts the first Read to capture raw ClientHello bytes.
type capConn struct {
net.Conn
once sync.Once
rawBuf []byte
}
func (c *capConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
if n > 0 {
c.once.Do(func() {
tmp := make([]byte, n)
copy(tmp, b[:n])
c.rawBuf = append(c.rawBuf, tmp...)
tryParse(c.Conn.RemoteAddr().String(), c.rawBuf)
})
}
return
}
func tryParse(addr string, raw []byte) {
// TLS record header: 5 bytes. Handshake header: 4 bytes. Total skip: 9.
if len(raw) < 9 {
return
}
ch, err := ParseClientHello(raw[9:])
if err != nil {
return
}
helloMu.Lock()
helloStore[addr] = ch
helloMu.Unlock()
}
type capListener struct{ net.Listener }
func (l *capListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return &capConn{Conn: c}, nil
}
type FingerprintResponse struct {
ClientIP string `json:"client_ip"`
JA3 string `json:"ja3"`
JA3String string `json:"ja3_string"`
JA4 string `json:"ja4"`
SNI string `json:"sni,omitempty"`
ALPN []string `json:"alpn,omitempty"`
KnownClient string `json:"known_client,omitempty"`
IsBot bool `json:"is_bot"`
Timestamp time.Time `json:"timestamp"`
}
func fpHandler(w http.ResponseWriter, r *http.Request) {
addr := r.RemoteAddr
helloMu.RLock()
ch := helloStore[addr]
helloMu.RUnlock()
resp := FingerprintResponse{
ClientIP: addr,
Timestamp: time.Now(),
}
if ch != nil {
ja3Str, ja3Hash := ComputeJA3(ch)
ja4 := ComputeJA4(ch)
resp.JA3 = ja3Hash
resp.JA3String = ja3Str
resp.JA4 = ja4.Full
resp.SNI = ch.SNI
resp.ALPN = ch.ALPN
if known, ok := knownClients[ja3Hash]; ok {
resp.KnownClient = known
resp.IsBot = false
} else {
resp.IsBot = true // unknown fingerprint — treat as suspect
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func main() {
cert, err := generateSelfSignedCert() // implementation omitted
if err != nil {
log.Fatalf("cert: %v", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS10, // accept wide range to observe diverse clients
MaxVersion: tls.VersionTLS13,
}
ln, err := net.Listen("tcp", ":8443")
if err != nil {
log.Fatalf("listen: %v", err)
}
tlsLn := tls.NewListener(&capListener{ln}, tlsCfg)
http.HandleFunc("/", fpHandler)
http.HandleFunc("/json", fpHandler)
log.Println("TLS fingerprint server on :8443")
log.Fatal(http.Serve(tlsLn, nil))
}
Testing with different clients:
# Real Chrome — JA3 reflects Chrome's cipher suite list
# curl — produces its own distinct JA3
curl -k https://localhost:8443/json
# Python requests
python3 -c "
import requests, json
r = requests.get('https://localhost:8443/json', verify=False)
print(json.dumps(r.json(), indent=2))
"
# Go net/http
go run -e 'package main; import ("crypto/tls"; "fmt"; "net/http"); func main() {
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, _ := client.Get("https://localhost:8443/json")
fmt.Println(resp.Status)
}'
Level 4 · Advanced Topics and Edge Cases
JA4+ Extended Fingerprint Family
JA4 was introduced by FoxIO in 2023 as a successor to JA3. The full JA4+ family covers multiple protocol layers:
| Fingerprint | What it covers |
|---|---|
| JA4 | TLS client fingerprint (improved JA3) |
| JA4S | TLS server response fingerprint |
| JA4H | HTTP client fingerprint (header order and casing) |
| JA4L | Network latency fingerprint (TTL, packet size patterns) |
| JA4X | X.509 certificate fingerprint |
| JA4SSH | SSH handshake fingerprint |
JA4 improvements over JA3:
- Uses SHA-256 instead of MD5 (stronger collision resistance)
- Splits the fingerprint into three independent components (a/b/c) that can be compared separately — matching on
aalone filters by TLS version and client type without needing an exact cipher match - Sorts the extension list so the fingerprint is independent of extension ordering (less variation due to randomization)
- Defines a QUIC variant (JA4Q)
Passive OS Fingerprinting
Beyond TLS, the TCP/IP stack itself leaks OS identity through characteristics observable in the SYN packet:
type OSFingerprint struct {
// TCP initial window size differs by OS
InitialWindowSize int
// IP TTL: Linux defaults to 64, Windows to 128
TTL int
// TCP options present and their order (MSS, SACK, timestamps, window scale)
TCPOptions []string
WindowScale int
}
var osSignatures = map[string]OSFingerprint{
"Linux 5.x": {InitialWindowSize: 65535, TTL: 64, WindowScale: 7},
"Windows 10": {InitialWindowSize: 65535, TTL: 128, WindowScale: 8},
"macOS 14": {InitialWindowSize: 65535, TTL: 64, WindowScale: 6},
}
Capturing SYN packet characteristics in Go requires raw packet access via github.com/google/gopacket (a libpcap wrapper), since the standard net package operates above the TCP layer and does not expose SYN-level data.
utls: Mimicking Browser TLS Fingerprints
github.com/refraction-networking/utls gives Go programs fine-grained control over every field in the ClientHello, enabling perfect impersonation of specific browser versions:
import (
"net"
tls "github.com/refraction-networking/utls"
)
// connectAsChrome120 establishes a TLS connection with Chrome 120's ClientHello.
func connectAsChrome120(host string) (net.Conn, error) {
tcp, err := net.Dial("tcp", host+":443")
if err != nil {
return nil, err
}
cfg := &tls.Config{ServerName: host}
// HelloChrome_120 reproduces Chrome 120's exact cipher suites,
// extension list, elliptic curves, and GREASE placement.
conn := tls.UClient(tcp, cfg, tls.HelloChrome_120)
if err := conn.Handshake(); err != nil {
tcp.Close()
return nil, err
}
return conn, nil
}
// connectWithCustomSpec gives complete control over the ClientHello.
func connectWithCustomSpec(host string) (net.Conn, error) {
tcp, _ := net.Dial("tcp", host+":443")
cfg := &tls.Config{ServerName: host}
conn := tls.UClient(tcp, cfg, tls.HelloCustom)
spec := tls.ClientHelloSpec{
TLSVersMax: tls.VersionTLS13,
TLSVersMin: tls.VersionTLS12,
CipherSuites: []uint16{
tls.GREASE_PLACEHOLDER,
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
Extensions: []tls.TLSExtension{
&tls.SNIExtension{},
&tls.SupportedCurvesExtension{
Curves: []tls.CurveID{
tls.GREASE_PLACEHOLDER,
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
},
&tls.SupportedVersionsExtension{
Versions: []uint16{
tls.GREASE_PLACEHOLDER,
tls.VersionTLS13,
tls.VersionTLS12,
},
},
&tls.KeyShareExtension{
KeyShares: []tls.KeyShare{
{Group: tls.GREASE_PLACEHOLDER},
{Group: tls.X25519},
},
},
&tls.ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}},
},
}
if err := conn.ApplyPreset(&spec); err != nil {
return nil, err
}
if err := conn.Handshake(); err != nil {
return nil, err
}
return conn, nil
}
The Detection Arms Race: Rotating Fingerprints and Evasion
The arms race between detection systems and evasion techniques escalates continuously:
Defenders escalate by:
- Combining TLS fingerprints with HTTP/2 fingerprints — the SETTINGS frame sent by the client (header table size, initial window size, max concurrent streams) varies by client library and is as distinctive as JA3
- Combining fingerprints with behavioral signals (mouse movement, click patterns, scroll behavior, inter-request timing)
- Using JA4H to analyze HTTP request header ordering and casing, which Go's
net/httpand Python'srequestsproduce very differently from a real browser
Evaders escalate by:
- Using
utlsto clone exact browser fingerprints, including TLS 1.3 Key Share groups and GREASE placement - Driving a real browser engine via Playwright or Puppeteer rather than an HTTP client — the TLS stack is Chromium's own, producing an authentic fingerprint
- Routing through residential proxies so the source IP resembles a real user's ISP
HTTPS Interception Proxy (MitM) in Go
A Man-in-the-Middle HTTPS proxy intercepts encrypted connections by dynamically generating certificates for each target host, signed by a local CA that the client has been configured to trust:
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"sync"
"time"
)
type MITMCertStore struct {
caCert *x509.Certificate
caKey *ecdsa.PrivateKey
cache sync.Map
}
func (s *MITMCertStore) CertFor(host string) (*tls.Certificate, error) {
if v, ok := s.cache.Load(host); ok {
return v.(*tls.Certificate), nil
}
leafKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: host},
DNSNames: []string{host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, s.caCert, &leafKey.PublicKey, s.caKey)
if err != nil {
return nil, err
}
cert := &tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: leafKey,
}
s.cache.Store(host, cert)
return cert, nil
}
The proxy uses GetCertificate in the server's tls.Config to dynamically return the appropriate leaf certificate per SNI hostname, and acts as a regular TLS client toward the upstream server. All decrypted traffic flows through the proxy for inspection.
Legal and ethical boundaries: MitM proxying is a powerful tool for security audits, SSL pinning verification, and traffic debugging — but only on systems you own or have explicit written authorization to test. Intercepting third-party traffic without consent is illegal in virtually every jurisdiction.
TLS fingerprinting sits at the intersection of cryptography, network protocol engineering, and security research. It is a vivid reminder that encryption protects content, not behavior. Understanding every byte of a ClientHello message, the JA3 and JA4 computation algorithms, the utls impersonation mechanism, and the limits of each layer of detection — these are skills that belong equally to the defender building detection systems and the researcher probing their boundaries.