第 34 章

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 包含的关键信息:

不同的软件实现会产生不同的组合。Chrome、Firefox、curl、Python requests、Java HttpClient——每一个都有其独特的"指纹",就像不同人的笔迹一样。

JA3 的应用场景:

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 |             |
+-----+--------------+---------+---------+----+----...------+--+

关键字段:

重要扩展:

扩展编号 名称 作用
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 的计算方法非常简单:

  1. 从 ClientHello 中提取五个字段:

    • TLSVersion:ClientHello 中声明的版本(如 771 = 0x0303 = TLS 1.2)
    • Ciphers:加密套件 ID 列表(排除 GREASE 值)
    • Extensions:扩展类型 ID 列表(排除 GREASE 值)
    • EllipticCurves:Supported Groups 扩展中的曲线列表(排除 GREASE)
    • EllipticCurvePointFormats:EC Point Formats 扩展中的格式列表
  2. 将这五个字段按如下格式拼接成字符串:

    TLSVersion,Ciphers-list,Extensions-list,EllipticCurves-list,EllipticCurvePointFormats-list
    

    其中列表元素之间用 - 分隔,五个字段之间用 , 分隔。

  3. 计算这个字符串的 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,无需解析原始字节:


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 的改进:

被动操作系统指纹

除了 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
}

旋转指纹与反反检测

检测系统和规避技术之间的军备竞赛永无止境:

检测方提升手段:

规避方提升手段:

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 的伪装机制——这些知识既是防御者的武器,也是研究者的工具。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论