Adam Cassar

Co-Founder

5 min read

The easiest way to misunderstand network fingerprints is to compare example strings taken from different clients. We wanted a cleaner test: one packet capture, one TLS ClientHello and three fingerprint formats.

The complete lab is checked into this site's source under labs/network-fingerprinting/, and the publication bundle is available here. It pins the tool revisions, reconstructs the fixture, verifies its checksum, runs the tools and checks that their outputs refer to the same connection. Nothing in the comparison depends on a vendor database or an application label.

The input

The fixture is a 329-byte Peakhour-generated capture containing a local OpenSSL 3.5.6 ClientHello wrapped in one synthetic Ethernet/IPv4/TCP packet:

10.1.1.1:40000 -> 10.2.2.2:443
SNI: lab.peakhour.test

The lab stores the small fixture as base64 under an adjacent BSD 3-Clause licence and verifies the decoded PCAP with SHA-256 before using it. The reserved SNI and private addresses did not cross a network. We select packet 1 and TCP stream 0. That selection matters: saying that several tools read the same PCAP is weaker than proving that their output describes the same flow and ClientHello.

The pinned revisions for this run are:

Cisco Mercury  3172786645f70e1a8347d8cf020b736e185651e5
FoxIO JA4      0e54bc8371de34df94a35f2442c05bda2e8b2034
Salesforce JA3 502cc6395811c54743b0561419d61900a6df3ff7

These pins are part of the result. Fingerprint implementations and specifications change. A value without its method and version is harder to reproduce than it first appears.

Running the lab

From the repository root:

./labs/network-fingerprinting/run.sh
python labs/network-fingerprinting/verify.py

The runner fetches the pinned source archives, builds or invokes the implementations in a temporary work directory, and writes evidence to labs/network-fingerprinting/results/. The verifier checks the fixture and output checksums, connection tuple, SNI and output shape. The pinned source URLs are enforced by the runner rather than inferred by the verifier.

This is a fingerprint-format lab, not a speed test. Build time, runtime and memory use depend heavily on language, wrapper and capture path, so we do not compare them here.

JA3: a portable exact-match digest

For this ClientHello, the canonical JA3 feature string is:

771,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-16-22-23-13,29-23-30-24-25,0-1-2

Its MD5 digest is:

e1934f32e97b0bd52227953ca7d30118

The digest is convenient for logs and exact lookup. On its own it does not show which cipher, extension or group changed. The pre-hash string retains enough information to investigate that difference, which is why throwing it away too early can make later analysis harder.

JA3 removes GREASE values but otherwise retains the order of its selected lists. A client that permutes extension order can therefore generate a new JA3 digest without changing its effective TLS capabilities. The archived Salesforce JA3 repository defines the input fields and GREASE handling.

JA4: canonicalised components

The same ClientHello produces this JA4:

t12d2709h2_a2460661a67a_36cef8aed422

Its first section is readable:

  • t means TLS over TCP;
  • 12 is the highest supported TLS version after ignoring GREASE;
  • d says a domain was present in SNI;
  • 27 and 09 are the cipher and extension counts after the format's exclusions;
  • h2 summarises the first ALPN value.

The second section is the first 12 hexadecimal characters of SHA-256 over sorted cipher identifiers. The third is a truncated SHA-256 value derived from sorted extension identifiers and the signature algorithms in their original order. The canonical JA4 technical specification defines the exact exclusions and encodings.

The lab also records JA4_r, the raw form used by the FoxIO tooling:

t12d2709h2_002f,0033,0035,0039,003c,003d,0067,006b,009c,009d,009e,009f,c009,c00a,c013,c014,c023,c024,c027,c028,c02b,c02c,c02f,c030,cca8,cca9,ccaa_000a,000b,000d,0016,0017,0023,ff01_0403,0503,0603,0807,0808,0809,080a,080b,0804,0805,0806,0401,0501,0601,0303,0301,0302,0402,0502,0602

That makes the normalisation visible. It also shows why JA4 is not fuzzy hashing: sorting makes selected permutations equivalent, while the hashes still support equality matching rather than semantic distance.

Mercury NPF: a retained protocol tree

Cisco Mercury 2.18 emits this tls/2 fingerprint for the same ClientHello:

tls/2/(0303)(c02cc030009fcca9cca8ccaac02bc02f009ec024c028006bc023c0270067c00ac0140039c009c0130033009d009c003d003c0035002f)[(0000)(000a000c000a001d0017001e00180019)(000b000403000102)(000d002a0028040305030603080708080809080a080b080408050806040105010601030303010302040205020602)(0010000e000c02683208687474702f312e31)(0016)(0017)(ff01)]

The value is longer because it is doing another job. Parentheses and square brackets describe an ordered tree of selected byte strings. In the draft NPF notation, square brackets mark a lexicographically sorted list. The tls/2 prefix names the protocol and fingerprint rule version.

An analyst can inspect the retained values rather than relying only on a digest. Mercury also defines a compact hash nickname when a fixed-length index is needed, but that nickname loses the structure used for inspection, prefix comparison or approximate matching. Cisco's draft NPF specification documents both representations.

The Mercury JSON includes the same source and destination tuple and the same SNI as the JA3 and JA4 records. It does not identify the client application in this lab because we did not run a labelled fingerprint knowledge base or the destination-context classifier. A packet-derived NPF value and a process assessment are separate outputs.

What the comparison establishes

All three methods observe the same ClientHello, but they define similarity differently.

Question JA3 JA4 Mercury NPF tls/2
Compact default Yes Yes No; optional hash available
Inspectable selected inputs Only if the pre-hash string is retained Partly in a; fully in the recorded raw form Yes in the full tree
Selected list sorting No, after GREASE removal Ciphers and most extensions Rule-specific selected extensions
Explicit format version in value No Encoded field semantics, but no separate rule number Yes
Semantic or approximate comparison Not from the digest Component grouping, not hash distance Full structure can support richer matching
Application attribution in the format No No No

The table does not produce a universal winner. JA3 remains useful where historical compatibility matters. JA4 is compact and handles selected permutations cleanly. Mercury retains more material for inspection and for analysis systems that need structured features.

It also shows what none of the values can establish. The capture does not prove which person, device or application created the connection. Shared libraries, browser impersonation and software updates all complicate that inference. See A network fingerprint is a cohort, not a client for the operational consequences.

For the history behind these design choices, read Two lineages of TLS fingerprinting. The durable format comparison is in Mercury vs JA4 vs JA3.