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

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

TLS 1.3 的 ClientHello.legacy_version 仍固定为 0x0303,实际支持版本位于 supported_versions 扩展。因此 JA3 第一项不是最终协商出的 TLS 版本。
Cipher Suites
按报文顺序读取每个 16 位 Cipher Suite 编号并转换为十进制。

例如 0xc02b、0xc02f 分别转换为 49195、49199,组合后形如:
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

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

GREASE
Cipher Suites、Extensions 和 Supported Groups 中可能出现 0x0a0a、0x1a1a 一直到 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。

JA3 只保留字段编号及顺序,不包含 SNI、ALPN 内容、证书或最终协商结果。不同程序可能产生相同指纹,同一程序也会因 TLS 库、配置和版本变化而改变,适合用于流量聚类和辅助识别,不能单独作为身份结论。
参考
RFC 8701: Applying GREASE to TLS Extensibility
RFC 8446: The Transport Layer Security Protocol Version 1.3
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。