TLS 指纹检测
第三十四章:TLS 指纹检测
2017 年,一位安全研究员 John Althouse 发布了一篇博客文章,介绍了他开发的一项技术:通过观察 TLS 握手时客户端发送的 ClientHello 消息,在不解密任何内容的情况下,推断出发起连接的是哪款软件。他将这项技术命名为 JA3。
这个想法看似简单,却令人震惊:即使是 HTTPS 流量,即使所有实际内容都已加密,客户端的"数字指纹"依然以明文暴露在网络上。Cloudflare 随即在全球数百万个网站上部署了 JA3 指纹检测,用于区分真实浏览器和爬虫机器人,不久后成为行业标准的反机器人检测手段之一。
TLS 指纹技术揭示了密码学的一个深刻真相:加密内容,不等于加密行为。你可以隐藏你说了什么,但你握手的方式——你选择哪些加密套件,你的扩展顺序——这些元数据本身就在说话。
本章将带你从 TLS ClientHello 的二进制结构出发,实现一个能计算 JA3/JA4 指纹的服务器,探索反检测与检测之间的猫鼠游戏。
Level 1 · 你需要知道的
TLS 指纹揭示了什么?
在一次 HTTPS 连接中,TLS 握手发生在任何 HTTP 数据传输之前。ClientHello 是握手的第一个消息,由客户端发送,内容完全是明文(TLS 1.3 之前,扩展部分在 TLS 1.3 中仍为明文)。
ClientHello 包含的关键信息:
- 客户端支持哪些 TLS 版本
- 客户端支持哪些 加密套件(Cipher Suites),以及它们的优先顺序
- 客户端支持哪些 TLS 扩展,以及各扩展的具体值
- 客户端支持哪些 椭圆曲线(Elliptic Curves,用于密钥交换)
- 客户端支持哪些 椭圆曲线点格式
不同的软件实现会产生不同的组合。Chrome、Firefox、curl、Python requests、Java HttpClient——每一个都有其独特的"指纹",就像不同人的笔迹一样。
JA3 的应用场景:
- 机器人检测:真实 Chrome 浏览器的 JA3 哈希是固定的(同版本下);简单的 curl 或 Python 脚本有截然不同的哈希。Cloudflare 的 Bot Management 产品大量使用了这项技术。
- 恶意软件识别:许多恶意软件使用自定义的 TLS 实现,产生独特的 JA3 指纹。安全团队可以维护已知恶意软件的指纹数据库,用于 IDS/IPS 规则。
- 加密流量分类:即使流量加密,也可以通过 JA3 推断应用类型(Tor 浏览器、特定版本的 OpenSSL 等)。
- 合规审计:确认内部系统使用的是被批准的 TLS 库版本,而不是旧的、有漏洞的版本。
JA3 的局限性
JA3 不是万能的。它有几个固有限制:
指纹碰撞:不同的客户端可能产生相同的 JA3 哈希(哈希空间是 MD5 的 128 位,而加密套件的组合数远小于这个空间)。随着时间推移,越来越多的客户端趋向于使用相同的现代加密套件,使 JA3 的区分度下降。
可欺骗性:有意的攻击者可以使用 utls 库伪造特定浏览器的 ClientHello,绕过 JA3 检测(Level 4 会详细讨论)。
版本敏感:浏览器升级往往会改变 JA3 指纹,需要持续更新指纹数据库。
正因如此,JA3 的后续版本 JA4 和更高级的 JA4+ 系列被开发出来,提供更精细、更难欺骗的指纹特征。
Level 2 · 原理深入
TLS ClientHello 结构
TLS 记录层(Record Layer)是 TLS 的外层封装。一个 TLS 记录包含:
+--+--+--+--+--+-----...-----+
| ContentType | Version | Length | Data...
| (1 byte) | (2 bytes) | (2 bytes)|
+--+--+--+--+--+-----...-----+
ClientHello 的 ContentType 是 0x16(Handshake),Version 通常是 0x0301(TLS 1.0,用于向后兼容,实际版本在扩展中协商)。
ClientHello 报文体(在 TLS 记录内的 Handshake 层):
+-----+--+--+--+------------+-------+----+----+----...----+--+--+
| Type| Length | Version | Random |SID |Cipher Suites|...
| 0x01| (3 bytes, BE)|(2 bytes)|(32 bytes)|len | |
+-----+--------------+---------+---------+----+----...------+--+
关键字段:
- Type:
0x01= ClientHello - Version:
0x0303= TLS 1.2(即使协商 TLS 1.3,这里通常也是 1.2 以保证兼容性) - Random:32 字节随机数(前 4 字节曾经是时间戳,现在也是随机的)
- Session ID:0-32 字节,用于会话恢复
- Cipher Suites:2 字节长度 + N 个 2 字节加密套件 ID 列表
- Compression Methods:通常只有
0x00(无压缩,压缩已被废弃) - Extensions:2 字节总长度 + 一系列扩展(每个扩展有 2 字节类型 + 2 字节长度 + 数据)
重要扩展:
| 扩展编号 | 名称 | 作用 |
|---|---|---|
| 0x0000 | SNI | 服务器名称指示,明文域名 |
| 0x000a | Supported Groups | 支持的椭圆曲线列表 |
| 0x000b | EC Point Formats | 支持的椭圆曲线点格式 |
| 0x000d | Signature Algorithms | 支持的签名算法 |
| 0x0010 | ALPN | 应用层协议协商(HTTP/2 等) |
| 0x0015 | Padding | 填充扩展,使 ClientHello 达到固定长度 |
| 0x001c | Record Size Limit | 最大记录大小 |
| 0x0023 | Session Ticket | 会话票据 |
| 0x002b | Supported Versions | 支持的 TLS 版本(TLS 1.3 必须) |
| 0x002d | PSK Key Exchange Modes | TLS 1.3 密钥交换模式 |
| 0x0033 | Key Share | TLS 1.3 密钥共享 |
| 0xfe0d | Encrypted ClientHello | 加密 ClientHello(ESNI/ECH) |
JA3 哈希计算
JA3 的计算方法非常简单:
-
从 ClientHello 中提取五个字段:
- TLSVersion:ClientHello 中声明的版本(如 771 = 0x0303 = TLS 1.2)
- Ciphers:加密套件 ID 列表(排除 GREASE 值)
- Extensions:扩展类型 ID 列表(排除 GREASE 值)
- EllipticCurves:Supported Groups 扩展中的曲线列表(排除 GREASE)
- EllipticCurvePointFormats:EC Point Formats 扩展中的格式列表
-
将这五个字段按如下格式拼接成字符串:
TLSVersion,Ciphers-list,Extensions-list,EllipticCurves-list,EllipticCurvePointFormats-list其中列表元素之间用
-分隔,五个字段之间用,分隔。 -
计算这个字符串的 MD5 哈希,得到 32 位十六进制字符串,即 JA3 指纹。
GREASE 值过滤:GREASE(Generate Random Extensions And Sustain Extensibility,RFC 8701)是 Google 提出的一个机制,在 TLS 扩展中插入随机的保留值,用于测试服务器的可扩展性处理。这些值必须在 JA3 计算时过滤掉,否则同一客户端的 JA3 会随机变化。GREASE 值是特定模式:0x?A?A(如 0x0a0a, 0x1a1a, ..., 0xfafa)。
JA3 字符串示例(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
在握手前捕获 ClientHello:net.Conn 拦截
Go 的标准 crypto/tls 包没有提供直接访问 ClientHello 原始字节的 API。我们需要在 TCP 层拦截数据,在 TLS 握手完成之前读取 ClientHello。
实现思路:包装 net.Conn,在 Read 调用时"窥探"数据:
// peekConn 在读取时记录前 N 个字节
type peekConn struct {
net.Conn
buf []byte
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 {
// 只捕获第一次读取(包含 ClientHello)
data := make([]byte, n)
copy(data, b[:n])
c.capture(data)
c.peeked = true
}
return
}
这个方法有一个问题:tls.Server 可能在多次 Read 调用中读取 ClientHello(如果数据被分片)。更可靠的方法是使用 tls.Config.GetConfigForClient,在握手回调中获取解析好的 ClientHello 信息(TLS 1.3 之后 Go 标准库提供了 ClientHelloInfo)。
使用 tls.Config.GetConfigForClient(推荐方式):
tlsConfig := &tls.Config{
GetConfigForClient: func(info *tls.ClientHelloInfo) (*tls.Config, error) {
// info 包含了解析后的 ClientHello 信息
// 但注意:这里已经是经过 Go 解析的字段,不是原始字节
// 要计算 JA3,需要原始字节
ja3 := computeJA3FromInfo(info)
log.Printf("JA3 from %s: %s", info.Conn.RemoteAddr(), ja3)
return nil, nil // 返回 nil 使用全局配置
},
}
tls.ClientHelloInfo 提供的字段足以计算 JA3,无需解析原始字节:
SupportedVersions:支持的 TLS 版本CipherSuites:加密套件列表(已过滤 GREASE)SupportedCurves:支持的椭圆曲线SupportedPoints:支持的点格式Extensions:扩展 ID 列表(注意:Go 1.17+ 才可用)
Level 3 · 代码实战
实现 TLS 指纹服务器
我们构建一个完整的 TLS 指纹检测服务器,捕获 ClientHello,计算 JA3/JA4,并返回 JSON 格式的指纹信息。
底层 ClientHello 捕获(clienthello.go):
package main
import (
"crypto/tls"
"encoding/binary"
"encoding/hex"
"crypto/md5"
"fmt"
"net"
"strconv"
"strings"
)
// GREASE 值:这些值应在 JA3 计算中过滤掉
var greaseValues = 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 greaseValues[v]
}
// ClientHelloData 存储从 ClientHello 提取的关键字段
type ClientHelloData struct {
Version uint16
CipherSuites []uint16
Extensions []uint16 // 扩展类型 ID 列表
EllipticCurves []uint16
EllipticCurvePoints []uint8
SNI string
ALPN []string
SupportedVersions []uint16
Raw []byte // 原始 ClientHello 字节(不含 TLS 记录头)
}
// ParseClientHello 从原始字节解析 ClientHello 报文
// data 是去掉 TLS 记录头(5字节)和握手头(4字节)后的内容
// 即从 ClientHello Version 字段开始
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}
// Version(2字节)
ch.Version = binary.BigEndian.Uint16(data[0:2])
// Random(32字节),跳过
offset := 34
// 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 suites length")
}
csLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
offset += 2
if offset+csLen > len(data) {
return nil, fmt.Errorf("cipher suites overflow")
}
for i := 0; i < csLen; 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 // 没有扩展部分,直接返回
}
extTotalLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
offset += 2
extEnd := offset + extTotalLen
if extEnd > len(data) {
extEnd = len(data)
}
for offset < extEnd {
if offset+4 > extEnd {
break
}
extType := binary.BigEndian.Uint16(data[offset : offset+2])
extLen := int(binary.BigEndian.Uint16(data[offset+2 : offset+4]))
offset += 4
extData := data[offset : offset+extLen]
offset += extLen
if isGREASE(extType) {
continue
}
ch.Extensions = append(ch.Extensions, extType)
// 解析特定扩展的内容
switch extType {
case 0x0000: // SNI
ch.SNI = parseSNI(extData)
case 0x000a: // Supported Groups (Elliptic Curves)
ch.EllipticCurves = parseUint16List(extData, 2, true)
case 0x000b: // EC Point Formats
ch.EllipticCurvePoints = parseUint8List(extData, 1)
case 0x0010: // ALPN
ch.ALPN = parseALPN(extData)
case 0x002b: // Supported Versions
ch.SupportedVersions = parseSupportedVersions(extData)
}
}
return ch, nil
}
func parseSNI(data []byte) string {
// SNI 扩展格式:2字节列表长度 + 1字节类型(0=host_name) + 2字节名称长度 + 名称
if len(data) < 5 {
return ""
}
nameType := data[2]
if nameType != 0 { // 0 = host_name
return ""
}
nameLen := int(binary.BigEndian.Uint16(data[3:5]))
if 5+nameLen > len(data) {
return ""
}
return string(data[5 : 5+nameLen])
}
func parseUint16List(data []byte, prefixLen int, filterGREASE bool) []uint16 {
if len(data) < prefixLen {
return nil
}
var listLen int
if prefixLen == 2 {
listLen = int(binary.BigEndian.Uint16(data[0:2]))
} else {
listLen = int(data[0])
}
data = data[prefixLen:]
if listLen > len(data) {
listLen = len(data)
}
var result []uint16
for i := 0; i+1 < listLen; i += 2 {
v := binary.BigEndian.Uint16(data[i : i+2])
if filterGREASE && isGREASE(v) {
continue
}
result = append(result, v)
}
return result
}
func parseUint8List(data []byte, prefixLen int) []uint8 {
if len(data) < prefixLen {
return nil
}
var listLen int
if prefixLen == 1 {
listLen = int(data[0])
}
if prefixLen+listLen > len(data) {
return nil
}
result := make([]uint8, listLen)
copy(result, data[prefixLen:prefixLen+listLen])
return result
}
func parseALPN(data []byte) []string {
if len(data) < 2 {
return nil
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
data = data[2:]
if listLen > len(data) {
return nil
}
var protocols []string
offset := 0
for offset < listLen {
if offset >= len(data) {
break
}
l := int(data[offset])
offset++
if offset+l > len(data) {
break
}
protocols = append(protocols, string(data[offset:offset+l]))
offset += l
}
return protocols
}
func parseSupportedVersions(data []byte) []uint16 {
// 客户端:1字节长度前缀 + uint16 列表
if len(data) < 1 {
return nil
}
listLen := int(data[0])
data = data[1:]
if listLen > len(data) {
return nil
}
var versions []uint16
for i := 0; i+1 < listLen; i += 2 {
v := binary.BigEndian.Uint16(data[i : i+2])
if !isGREASE(v) {
versions = append(versions, v)
}
}
return versions
}
JA3 和 JA4 计算(fingerprint.go):
package main
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// ComputeJA3 从 ClientHelloData 计算 JA3 字符串和哈希
func ComputeJA3(ch *ClientHelloData) (string, string) {
// 五个字段
version := strconv.Itoa(int(ch.Version))
ciphers := joinUint16(ch.CipherSuites, "-")
exts := joinUint16(ch.Extensions, "-")
curves := joinUint16(ch.EllipticCurves, "-")
points := joinUint8(ch.EllipticCurvePoints, "-")
// 拼接成 JA3 字符串
ja3Str := strings.Join([]string{version, ciphers, exts, curves, points}, ",")
// 计算 MD5
hash := md5.Sum([]byte(ja3Str))
ja3Hash := hex.EncodeToString(hash[:])
return ja3Str, ja3Hash
}
func joinUint16(values []uint16, sep string) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = strconv.Itoa(int(v))
}
return strings.Join(parts, sep)
}
func joinUint8(values []uint8, sep string) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = strconv.Itoa(int(v))
}
return strings.Join(parts, sep)
}
// JA4Result 存储 JA4 指纹的各个组成部分
type JA4Result struct {
A string // ja4a: TLS version + SNI indicator + cipher count + ext count + ALPN
B string // ja4b: cipher suite list hash (sorted)
C string // ja4c: extension list hash + signature algorithms
Full string // 完整的 ja4 字符串
}
// ComputeJA4 计算 JA4 指纹(简化实现)
// 完整规范见 https://github.com/FoxIO-LLC/ja4
func ComputeJA4(ch *ClientHelloData) JA4Result {
// JA4a: {TLS version}{SNI indicator}{cipher count}{ext count}{first ALPN}
tlsVer := "t" // transport: t=TLS, q=QUIC
var tlsVerStr string
// 使用 Supported Versions 中最高版本,或 ClientHello Version
if len(ch.SupportedVersions) > 0 {
maxVer := uint16(0)
for _, v := range ch.SupportedVersions {
if v > maxVer {
maxVer = v
}
}
switch maxVer {
case 0x0304:
tlsVerStr = "13"
case 0x0303:
tlsVerStr = "12"
case 0x0302:
tlsVerStr = "11"
default:
tlsVerStr = fmt.Sprintf("%02d", maxVer&0xff)
}
} else {
switch ch.Version {
case 0x0303:
tlsVerStr = "12"
default:
tlsVerStr = fmt.Sprintf("%02d", ch.Version&0xff)
}
}
sniIndicator := "i" // i=IP, d=domain
if ch.SNI != "" {
sniIndicator = "d"
}
cipherCount := fmt.Sprintf("%02d", len(ch.CipherSuites))
extCount := fmt.Sprintf("%02d", len(ch.Extensions))
alpnFirst := "00"
if len(ch.ALPN) > 0 {
s := ch.ALPN[0]
if len(s) >= 2 {
alpnFirst = string([]byte{s[0], s[len(s)-1]})
}
}
ja4a := tlsVer + tlsVerStr + sniIndicator + cipherCount + extCount + alpnFirst
// JA4b: 排序后的加密套件列表的 SHA256 前 12 字符
sortedCiphers := make([]uint16, len(ch.CipherSuites))
copy(sortedCiphers, ch.CipherSuites)
sort.Slice(sortedCiphers, func(i, j int) bool { return sortedCiphers[i] < sortedCiphers[j] })
cipherStr := joinUint16(sortedCiphers, ",")
h := sha256.Sum256([]byte(cipherStr))
ja4b := hex.EncodeToString(h[:])[:12]
// JA4c: 排序后的扩展列表(排除 SNI=0 和 ALPN=16)+ 签名算法(简化)
var extList []uint16
for _, e := range ch.Extensions {
if e != 0x0000 && e != 0x0010 { // 排除 SNI 和 ALPN
extList = append(extList, e)
}
}
sort.Slice(extList, func(i, j int) bool { return extList[i] < extList[j] })
extStr := joinUint16(extList, ",")
h2 := sha256.Sum256([]byte(extStr))
ja4c := hex.EncodeToString(h2[:])[:12]
full := ja4a + "_" + ja4b + "_" + ja4c
return JA4Result{A: ja4a, B: ja4b, C: ja4c, Full: full}
}
TLS 服务器主程序(main.go):
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"sort"
"strconv"
"sync"
"time"
)
// FingerprintResult 是返回给客户端的 JSON 响应
type FingerprintResult struct {
ClientIP string `json:"client_ip"`
JA3String string `json:"ja3_string"`
JA3Hash string `json:"ja3_hash"`
JA4 string `json:"ja4"`
SNI string `json:"sni"`
TLSVersion string `json:"tls_version"`
CipherSuites []string `json:"cipher_suites"`
Extensions []string `json:"extensions"`
ALPN []string `json:"alpn"`
KnownClient string `json:"known_client,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// 已知 JA3 指纹数据库
var knownFingerprints = map[string]string{
"cd08e31494f9531f560d64c695473da9": "Chrome 120",
"579ccef312d18482fc42e2b822ca2430": "Firefox 121",
"b32309a26951912be7dba376398abc3b": "curl/7.88",
"e6573e91e6eb777c0933c5b8f97f10cd": "Python requests/2.31",
"a0e9f5d64349fb13191bc781f81f42e1": "Go net/http",
}
// capturedHellos 存储每个连接捕获的 ClientHello 数据
var (
capturedMu sync.RWMutex
capturedHellos = make(map[net.Addr]*ClientHelloData)
)
type capturingConn struct {
net.Conn
captured bool
buf []byte
}
func (c *capturingConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
if !c.captured && n > 0 {
tmp := make([]byte, n)
copy(tmp, b[:n])
c.buf = append(c.buf, tmp...)
// 尝试解析 ClientHello
// TLS 记录头:5字节(ContentType 1 + Version 2 + Length 2)
// Handshake 头:4字节(Type 1 + Length 3)
// ClientHello 从第 9 字节开始
if len(c.buf) >= 9 {
chData := c.buf[9:]
if ch, parseErr := ParseClientHello(chData); parseErr == nil {
capturedMu.Lock()
capturedHellos[c.RemoteAddr()] = ch
capturedMu.Unlock()
c.captured = true
}
}
}
return
}
func fingerprintHandler(w http.ResponseWriter, r *http.Request) {
// 获取客户端地址
remoteAddr := r.RemoteAddr
// 从 r.TLS 获取协商结果
if r.TLS == nil {
http.Error(w, "TLS required", http.StatusBadRequest)
return
}
result := FingerprintResult{
ClientIP: remoteAddr,
Timestamp: time.Now(),
}
// 查找捕获的 ClientHello(通过地址匹配)
capturedMu.RLock()
for addr, ch := range capturedHellos {
if addr.String() == remoteAddr {
ja3Str, ja3Hash := ComputeJA3(ch)
ja4 := ComputeJA4(ch)
result.JA3String = ja3Str
result.JA3Hash = ja3Hash
result.JA4 = ja4.Full
result.SNI = ch.SNI
result.ALPN = ch.ALPN
result.TLSVersion = fmt.Sprintf("0x%04x", ch.Version)
// 加密套件名称
for _, cs := range ch.CipherSuites {
result.CipherSuites = append(result.CipherSuites,
fmt.Sprintf("0x%04x", cs))
}
// 检查已知指纹
if known, ok := knownFingerprints[ja3Hash]; ok {
result.KnownClient = known
}
break
}
}
capturedMu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func main() {
// 生成自签名证书(生产环境使用真实证书)
cert, err := generateSelfSignedCert()
if err != nil {
log.Fatalf("Failed to generate cert: %v", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
// 支持广泛的 TLS 版本以捕获更多客户端
MinVersion: tls.VersionTLS10,
MaxVersion: tls.VersionTLS13,
// 不限制加密套件,以便看到客户端的真实选择
CipherSuites: nil,
}
// 创建自定义 listener,包装每个连接进行 ClientHello 捕获
baseListener, err := net.Listen("tcp", ":8443")
if err != nil {
log.Fatalf("Listen: %v", err)
}
capturingListener := &capturingListener{Listener: baseListener}
tlsListener := tls.NewListener(capturingListener, tlsConfig)
mux := http.NewServeMux()
mux.HandleFunc("/", fingerprintHandler)
mux.HandleFunc("/json", fingerprintHandler)
server := &http.Server{Handler: mux}
log.Println("TLS fingerprint server listening on :8443")
log.Fatal(server.Serve(tlsListener))
}
// capturingListener 包装 net.Listener,用 capturingConn 包装每个连接
type capturingListener struct {
net.Listener
}
func (l *capturingListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return &capturingConn{Conn: conn}, nil
}
与已知浏览器签名对比:
// BrowserDatabase 持有已知客户端的 JA3 指纹数据库
type BrowserDatabase struct {
mu sync.RWMutex
fingerprints map[string]BrowserEntry
}
type BrowserEntry struct {
Name string
Version string
Platform string
IsBot bool
LastSeen time.Time
}
func (db *BrowserDatabase) Lookup(ja3Hash string) (BrowserEntry, bool) {
db.mu.RLock()
defer db.mu.RUnlock()
entry, ok := db.fingerprints[ja3Hash]
return entry, ok
}
// IsLikelyBot 基于 JA3 指纹判断是否为机器人
// 逻辑:已知机器人指纹 OR 与任何已知浏览器都不匹配
func (db *BrowserDatabase) IsLikelyBot(ja3Hash string) bool {
db.mu.RLock()
defer db.mu.RUnlock()
entry, ok := db.fingerprints[ja3Hash]
if !ok {
return true // 未知指纹,可疑
}
return entry.IsBot
}
Level 4 · 进阶与边界
JA4+ 扩展指纹族
JA4 是 JA3 的改进版(由 FoxIO 于 2023 年提出),整个 JA4+ 系列包含:
| 指纹 | 描述 |
|---|---|
| JA4 | TLS 客户端指纹(改进的 JA3) |
| JA4S | TLS 服务器响应指纹 |
| JA4H | HTTP 客户端指纹(Header 顺序和值) |
| JA4L | 网络延迟指纹(TTL、包大小特征) |
| JA4X | X.509 证书指纹 |
| JA4SSH | SSH 握手指纹 |
JA4 相比 JA3 的改进:
- 使用 SHA256 代替 MD5(抗碰撞性更强)
- 将指纹分为三个独立部分(a/b/c),可以单独比较
- 对扩展列表排序,使指纹与扩展顺序无关(减少因随机化导致的变化)
- 支持 QUIC 协议的 JA4Q 变体
被动操作系统指纹
除了 TLS 指纹,还可以通过 TCP/IP 协议栈的特征推断操作系统:
// 被动操作系统指纹特征
type OSFingerprint struct {
// TCP 初始窗口大小(不同 OS 有不同默认值)
TCPWindowSize int
// IP TTL 值(Linux 默认 64,Windows 默认 128)
IPTTL int
// TCP 选项和顺序(MSS、SACK、时间戳等)
TCPOptions []string
// TCP 窗口缩放因子
WindowScale int
}
// 常见操作系统的 TCP 握手特征
var osSignatures = map[string]OSFingerprint{
"Linux 5.x": {TCPWindowSize: 65535, IPTTL: 64, WindowScale: 7},
"Windows 10": {TCPWindowSize: 65535, IPTTL: 128, WindowScale: 8},
"macOS 14": {TCPWindowSize: 65535, IPTTL: 64, WindowScale: 6},
"iOS 17": {TCPWindowSize: 65535, IPTTL: 64, WindowScale: 6},
}
在 Go 中,通过 syscall 包可以设置 SO_TCPINFO 获取部分 TCP 连接信息,但要捕获 TCP SYN 包的特征,通常需要使用 gopacket(libpcap 的 Go 封装)在原始数据包级别抓包分析。
utls:伪造浏览器 TLS 指纹
github.com/refraction-networking/utls 是一个 Go 的 TLS 库,允许精确控制 ClientHello 的每个字段,实现对特定浏览器 TLS 指纹的完美模拟:
import (
"net"
tls "github.com/refraction-networking/utls"
)
func connectWithChromeFingerprint(host string) (net.Conn, error) {
tcpConn, err := net.Dial("tcp", host+":443")
if err != nil {
return nil, err
}
// 使用 Chrome 120 的 ClientHello 规格
config := &tls.Config{ServerName: host}
uconn := tls.UClient(tcpConn, config, tls.HelloChrome_120)
if err := uconn.Handshake(); err != nil {
tcpConn.Close()
return nil, err
}
return uconn, nil
}
// 或者完全自定义 ClientHello 规格
func connectWithCustomFingerprint(host string) (net.Conn, error) {
tcpConn, _ := net.Dial("tcp", host+":443")
config := &tls.Config{ServerName: host}
uconn := tls.UClient(tcpConn, config, tls.HelloCustom)
// 精确控制每个字段
spec := tls.ClientHelloSpec{
TLSVersMax: tls.VersionTLS13,
TLSVersMin: tls.VersionTLS12,
CipherSuites: []uint16{
tls.GREASE_PLACEHOLDER, // 插入随机 GREASE 值
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,
},
Extensions: []tls.TLSExtension{
&tls.SNIExtension{},
&tls.SupportedCurvesExtension{
Curves: []tls.CurveID{
tls.GREASE_PLACEHOLDER,
tls.X25519,
tls.CurveP256,
},
},
&tls.SupportedVersionsExtension{
Versions: []uint16{
tls.GREASE_PLACEHOLDER,
tls.VersionTLS13,
tls.VersionTLS12,
},
},
&tls.KeyShareExtension{
KeyShares: []tls.KeyShare{
{Group: tls.GREASE_PLACEHOLDER},
{Group: tls.X25519},
},
},
},
GetSessionID: nil,
}
if err := uconn.ApplyPreset(&spec); err != nil {
return nil, err
}
if err := uconn.Handshake(); err != nil {
return nil, err
}
return uconn, nil
}
旋转指纹与反反检测
检测系统和规避技术之间的军备竞赛永无止境:
检测方提升手段:
- 结合 JA3 + HTTP/2 指纹(HPACK 头部表大小、流设置等)
- 结合行为特征(鼠标移动、点击模式、浏览速度)
- 使用 JA4H 分析 HTTP 请求头顺序和大小写
规避方提升手段:
- 使用
utls精确克隆浏览器指纹,包括 TLS 1.3 的 Key Share 扩展 - 利用真实浏览器内核(Playwright + Chrome DevTools Protocol)而非 HTTP 客户端
- 通过住宅代理(Residential Proxy)路由流量,使 IP 看起来像真实用户
HTTPS 拦截代理(Go MitM)
在 Go 中实现 HTTPS 中间人代理,需要动态生成证书:
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"time"
)
// MITMProxy 为每个目标主机动态生成证书
type MITMProxy struct {
caCert *x509.Certificate
caKey *ecdsa.PrivateKey
certCache sync.Map // 缓存已生成的证书
}
// GenerateCertForHost 为目标主机生成由我们的 CA 签发的证书
func (p *MITMProxy) GenerateCertForHost(host string) (*tls.Certificate, error) {
if cached, ok := p.certCache.Load(host); ok {
return cached.(*tls.Certificate), nil
}
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
template := &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},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, p.caCert, &key.PublicKey, p.caKey)
if err != nil {
return nil, err
}
cert := &tls.Certificate{
Certificate: [][]byte{certDER},
PrivateKey: key,
}
p.certCache.Store(host, cert)
return cert, nil
}
MitM 代理在安全审计、SSL pinning 测试、流量调试中非常有用。注意:对非授权流量使用 MitM 是违法的;合法用途仅限于你控制的系统或明确授权的测试环境。
TLS 指纹技术是密码学、网络协议和安全工程交汇处的一个迷人领域。它提醒我们:加密保护内容,但不能掩盖行为。理解 ClientHello 的每一个字节,理解 JA3/JA4 的计算逻辑,理解 utls 的伪装机制——这些知识既是防御者的武器,也是研究者的工具。