JA3 指纹计算

  1. ClientHello 字段
    1. Version
    2. Cipher Suites
    3. Extensions
    4. Supported Groups
    5. EC Point Formats
    6. GREASE
  2. Python 实现
  3. 实测
  4. 参考

JA3 从 TLS ClientHello 中依次提取五组字段:

SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat

每组内部使用 - 连接,字段之间使用 , 连接,最后对完整字符串计算 MD5。没有扩展时,后三组字段保留为空。

769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0

字段取报文中各项的十进制编号,不取名称和字段长度。

ClientHello 字段

Wireshark 过滤条件:

tls.handshake.type == 1

ClientHello 及 JA3 字段

Version

读取 ClientHello.legacy_version 的 16 位值并转换为十进制。图中为 0x0303,对应十进制 771

ClientHello legacy_version

TLS 1.3 的 ClientHello.legacy_version 仍固定为 0x0303,实际支持版本位于 supported_versions 扩展。因此 JA3 第一项不是最终协商出的 TLS 版本。

Cipher Suites

按报文顺序读取每个 16 位 Cipher Suite 编号并转换为十进制。

ClientHello Cipher Suites

例如 0xc02b0xc02f 分别转换为 4919549199,组合后形如:

49195-49199-49196-49200-52393-52392-49161-49171

Extensions

读取每个扩展的 ExtensionType,不是 Extensions Length。例如:

server_name        = 0
supported_groups   = 10
ec_point_formats   = 11
supported_versions = 43
key_share          = 51

IANA 已将扩展 10 从 elliptic_curves 更名为 supported_groups,JA3 字段名沿用了早期叫法。

Supported Groups

扩展 10 的数据先包含一个 16 位列表长度,之后才是连续的 16 位组编号。图中的四个值为:

29-23-24-25

ClientHello Supported Groups

EC Point Formats

扩展 11 的数据先包含一个 8 位列表长度,之后每个格式占 8 位。图中的 uncompressed 对应 0

ClientHello EC Point Formats

GREASE

Cipher Suites、Extensions 和 Supported Groups 中可能出现 0x0a0a0x1a1a 一直到 0xfafa。JA3 计算忽略这些值,避免 GREASE 变化影响指纹。

Python 实现

监听本地 8443 端口,读取完整 TLS record 和 ClientHello handshake,解析五组字段并过滤 GREASE。

import hashlib
import socket
import struct


HOST = "127.0.0.1"
PORT = 8443
TLS_HANDSHAKE = 22
CLIENT_HELLO = 1
MAX_RECORD_LENGTH = 18432
MAX_HANDSHAKE_LENGTH = 1024 * 1024


class ParseError(ValueError):
    pass


def recv_exact(conn: socket.socket, length: int) -> bytes:
    data = bytearray()
    while len(data) < length:
        chunk = conn.recv(length - len(data))
        if not chunk:
            raise ParseError("connection closed before message completed")
        data.extend(chunk)
    return bytes(data)


def take(data: bytes, offset: int, length: int) -> tuple[bytes, int]:
    end = offset + length
    if end > len(data):
        raise ParseError("field exceeds message boundary")
    return data[offset:end], end


def read_uint(data: bytes, offset: int, length: int) -> tuple[int, int]:
    raw, offset = take(data, offset, length)
    return int.from_bytes(raw, "big"), offset


def read_vector(data: bytes, offset: int, length_size: int) -> tuple[bytes, int]:
    length, offset = read_uint(data, offset, length_size)
    return take(data, offset, length)


def is_grease(value: int) -> bool:
    high = value >> 8
    low = value & 0xFF
    return high == low and value & 0x0F0F == 0x0A0A


def parse_uint16_list(data: bytes) -> list[int]:
    if len(data) % 2:
        raise ParseError("16-bit list has an odd length")
    return [
        int.from_bytes(data[offset:offset + 2], "big")
        for offset in range(0, len(data), 2)
    ]


def read_client_hello(conn: socket.socket) -> bytes:
    handshake = bytearray()
    expected_length = None

    while expected_length is None or len(handshake) < expected_length:
        header = recv_exact(conn, 5)
        content_type, _record_version, record_length = struct.unpack("!BHH", header)
        if record_length > MAX_RECORD_LENGTH:
            raise ParseError(f"invalid TLS record length: {record_length}")

        fragment = recv_exact(conn, record_length)
        if content_type != TLS_HANDSHAKE:
            continue

        handshake.extend(fragment)
        if len(handshake) >= 4 and expected_length is None:
            if handshake[0] != CLIENT_HELLO:
                raise ParseError(f"unexpected handshake type: {handshake[0]}")
            body_length = int.from_bytes(handshake[1:4], "big")
            if body_length > MAX_HANDSHAKE_LENGTH:
                raise ParseError(f"ClientHello is too large: {body_length}")
            expected_length = 4 + body_length

    return bytes(handshake[4:expected_length])


def parse_ja3(client_hello: bytes) -> tuple[str, str]:
    offset = 0
    legacy_version, offset = read_uint(client_hello, offset, 2)
    _random, offset = take(client_hello, offset, 32)
    _session_id, offset = read_vector(client_hello, offset, 1)
    cipher_data, offset = read_vector(client_hello, offset, 2)
    _compression_methods, offset = read_vector(client_hello, offset, 1)

    ciphers = [value for value in parse_uint16_list(cipher_data) if not is_grease(value)]
    extensions = []
    supported_groups = []
    ec_point_formats = []

    if offset < len(client_hello):
        extension_data, offset = read_vector(client_hello, offset, 2)
        if offset != len(client_hello):
            raise ParseError("unexpected data after extensions")

        extension_offset = 0
        while extension_offset < len(extension_data):
            extension_type, extension_offset = read_uint(
                extension_data, extension_offset, 2
            )
            extension_body, extension_offset = read_vector(
                extension_data, extension_offset, 2
            )

            if not is_grease(extension_type):
                extensions.append(extension_type)

            if extension_type == 10:
                group_data, group_end = read_vector(extension_body, 0, 2)
                if group_end != len(extension_body):
                    raise ParseError("invalid supported_groups extension")
                supported_groups = [
                    value
                    for value in parse_uint16_list(group_data)
                    if not is_grease(value)
                ]
            elif extension_type == 11:
                point_data, point_end = read_vector(extension_body, 0, 1)
                if point_end != len(extension_body):
                    raise ParseError("invalid ec_point_formats extension")
                ec_point_formats = list(point_data)

    fields = [
        str(legacy_version),
        "-".join(map(str, ciphers)),
        "-".join(map(str, extensions)),
        "-".join(map(str, supported_groups)),
        "-".join(map(str, ec_point_formats)),
    ]
    fullstring = ",".join(fields)
    fingerprint = hashlib.md5(fullstring.encode("ascii")).hexdigest()
    return fullstring, fingerprint


def main() -> None:
    with socket.create_server((HOST, PORT), reuse_port=False) as server:
        print(f"listening on {HOST}:{PORT}")
        while True:
            conn, address = server.accept()
            with conn:
                conn.settimeout(5)
                try:
                    fullstring, fingerprint = parse_ja3(read_client_hello(conn))
                    print(f"client: {address[0]}:{address[1]}")
                    print(f"JA3 Fullstring: {fullstring}")
                    print(f"JA3: {fingerprint}")
                except (OSError, ParseError) as error:
                    print(f"client {address[0]}:{address[1]}: {error}")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass

实测

监听器:

python ja3_listener.py

OpenSSL 3.6.3:

openssl s_client \
  -connect 127.0.0.1:8443 \
  -servername example.com \
  </dev/null

输出:

JA3 Fullstring: 771,4866-4867-4865-49196-49200-159-52393-52392-52394-49195-49199-158-49188-49192-107-49187-49191-103-49162-49172-57-49161-49171-51-157-156-61-60-53-47,65281-0-11-10-35-22-23-13-43-45-51-27,4588-29-23-30-24-25-256-257,0
JA3: d5fc4cb6dc9483feba85243354b4bc25

TShark 4.7.2 对同一报文输出相同的 Fullstring 和 MD5。

Wireshark 计算的 JA3

JA3 只保留字段编号及顺序,不包含 SNI、ALPN 内容、证书或最终协商结果。不同程序可能产生相同指纹,同一程序也会因 TLS 库、配置和版本变化而改变,适合用于流量聚类和辅助识别,不能单独作为身份结论。

参考

Salesforce JA3

IANA TLS ExtensionType Values

RFC 8701: Applying GREASE to TLS Extensibility

RFC 8446: The Transport Layer Security Protocol Version 1.3


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。

文章标题:JA3 指纹计算

字数:1.2k

本文作者:

发布时间:2023-07-06, 14:10:00

最后更新:2026-08-26, 14:45:00

原始链接:https://cnlnn.pages.dev/posts/tls-clienthello-ja3-fingerprint/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。