The quickest way to capture a TLS ClientHello is not a packet capture. Open a plain TCP socket, point the browser at it as if it were an HTTPS site, and read the first record it sends. That record is the ClientHello. The handshake never finishes, because the listener has no certificate, and it does not need to. We did this to compare our engine's handshake with Google Chrome's, a question we had put off as hours of rebuilds, and it took about a minute.

Three ways to get the bytes

A packet capture works and is what most guides show. In Wireshark the display filter tls.handshake.type == 1 keeps only ClientHellos; with tcpdump you filter on the record type 0x16 and the handshake type 0x01 at the start of the TCP payload. It needs capture permissions, and you are reading the browser's traffic to a real site, with everything else on the interface mixed in.

A TLS server that logs the hello works too. Go hands it to you in GetConfigForClient, and Node libraries can read it off an incoming socket before the handshake carries on. That route needs a certificate the browser will accept, or a flag telling it not to care.

The third route needs neither. The ClientHello is the first thing a client sends, before the server has said a word. So a listener that does nothing except read and hang up has everything the other two have.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

The listener

This is the whole of the capture half of our probe, scripts/probe-clienthello.mjs, cut down:

import { createServer } from 'node:net'

createServer((sock) => {
  sock.once('data', (buf) => {
    if (buf[0] !== 0x16) return sock.destroy()  // not a TLS handshake record
    if (buf[5] !== 0x01) return sock.destroy()  // not a ClientHello
    console.log(parseHello(buf))
    sock.destroy()
  })
}).listen(39444, '127.0.0.1')

Start a browser at https://127.0.0.1:39444/, with a fresh profile folder so nothing is cached, and the first connection arrives as the page starts to load. The browser shows a connection error. The terminal shows the hello.

Parsing it is a walk through a fixed layout. Skip the 5-byte record header and the 4-byte handshake header. Read the 2-byte legacy version, skip the 32 random bytes and the session id. Then come the cipher suites, the compression methods, and the extensions, each a 2-byte type and a 2-byte length. Four extensions carry most of what a fingerprint is built from: supported groups (0x000a), signature algorithms (0x000d), ALPN (0x0010) and supported versions (0x002b). Our parser reads those four bodies and records every extension type.

Compare the set, not the order

Run it once against Tesseract Chrome, our patched Chromium, and once against branded Chrome, and two fields differ on the first try. Both are Chrome being Chrome.

Chrome permutes the order of its ClientHello extensions on every connection. It is deliberate, a guard against servers that come to depend on one order, and Chrome has done it since version 110. So comparing the ordered list compares two shuffles. The thing to compare is the set, sorted.

Chrome also sends GREASE values: reserved code points of the form 0x?a?a (0x0a0a, 0x1a1a and so on up to 0xfafa), placed in several lists and picked at random per connection. Our first run showed the first signature-algorithm entry differing, 0xdada against 0x2a2a. That was GREASE too. We had missed it only because that line printed raw hex while the extension list already labelled GREASE. The parser now maps all sixteen values to one label everywhere before comparing.

With both rules applied, our engine against branded Chrome on the same machine:

legacy version    same      cipher suites       same
extension set     same      extension count     same
supported groups  same      supported versions  same
signature algs    same      ALPN                same

What a minute bought

We had treated the handshake as a question that cost a rebuild per guess. It never did: a rebuild is only needed to change the binary, not to ask it something. The listener answered it outright, and the handshake joined the page's JavaScript surface on the list of things that are identical to Chrome and so are not where a detector finds the difference.

That fits the decision behind it. We do not offer a TLS setting per profile, because every real Chrome of one version sends this same shape, and why our engine has no TLS fingerprint setting sets out that argument with JA4 strings. The shuffling that makes the ordered list useless is also why JA3 hashes move on every connection, which why Chrome's JA3 changes works through.

Two limits worth knowing if you use this yourself. Compare a browser only against a Chrome of the same major, because Chrome's TLS stack changes between majors. And a debugging proxy that decrypts HTTPS sends its own ClientHello to the site, not the browser's, so capture with nothing in between. After the handshake, the next layer down is the HTTP/2 preamble, which needs a server that completes TLS to read.