because the existing workflow for TLS fingerprinting is annoying.
say you want to know what TLS fingerprint a host exposes, or you want to identify what clients are talking on your network. here is what you do today:
you could run openssl s_client -connect example.com:443 and stare at the output. it shows you the certificate and the negotiated cipher, but it does not give you a JA3 or JA4 string. it does not tell you what fingerprint a Chrome client would produce versus curl.
you could use Wireshark or tshark -Y tls.handshake.type==1 to pull ClientHellos from a capture. but then you need to parse the bytes yourself. Wireshark shows you the fields, but you still have to extract the cipher suites, filter GREASE, sort them, hash them to get a JA4. there is the FoxIO ja4 Python tool that computes fingerprints from pcap, but it gives you a string with no context about what the server negotiated or what the certificate looks like.
you could use testssl.sh or sslscan to enumerate a server, but those are server scanners. they tell you what the server supports, not what a specific client fingerprint looks like.
none of these give you the full picture in one place: the handshake as it happened on the wire, the negotiated parameters, the certificate chain, and the JA3/JA4/JA4S fingerprints with client identification. if you want all of that, you end up piping three tools together and writing a parser.
grip does it in one command. you point it at a hostname and it builds a real TLS ClientHello, sends it, parses the ServerHello byte by byte, grabs the certificate chain, and fingerprints everything. or you point it at a pcap file and it reassembles TCP streams, extracts every ClientHello, groups them by source IP, and ranks them. two modes, one binary.
the name is literal. you grip the handshake and pull it apart.
grip has two modes that do fundamentally different things:
you give it a hostname, it connects and does a real TLS handshake. not through rustls, not through openssl. it builds the ClientHello byte by byte, sends it over a raw TCP socket, reads the ServerHello response, and parses every field itself. then it computes JA3/JA4/JA4S fingerprints and checks them against a built-in database of known clients.
the one exception: for TLS 1.3, the Certificate message is encrypted under the handshake keys. grip cannot read it from the raw bytes. so it opens a second connection via rustls with webpki-roots to fetch the certificate chain. this is a deliberate fallback, not a shortcut. the handshake bytes you see are the real bytes the client sent. the certificate comes from a verified secondary connection. the fallback is capped at 5 seconds and respects --no-verify.
you give it a pcap file. grip does proper TCP stream reassembly, not just packet scanning. a TLS ClientHello can span multiple TCP segments, so you cannot just look for 0x16 0x03 in each packet. grip groups packets by four tuple (src_ip, src_port, dst_ip, dst_port), reassembles them in sequence number order handling overlaps and gaps, then walks the reassembled byte stream looking for TLS records. if you skip reassembly you will miss fragmented ClientHellos and produce wrong fingerprints.
after extraction it groups handshakes by source IP, deduplicates, and shows a ranked table with fingerprint, client identification, and hit count.
git clone https://github.com/yourpwnguy/grip.git
cd grip/
cargo install --path .when it is on crates.io:
cargo install gripcheck the releases page for pre-built binaries for linux, macos, and windows. each release is around 4 MB, stripped and LTO optimized.
grip example.comconnects to example.com:443, does the handshake, parses everything, prints a report. takes about 0.2 seconds.
grip example.com:8443for scripting, piping to jq, or feeding into a siem:
grip --format json example.comby default grip shows all three. use flags to filter:
grip --ja4 example.com
grip --ja3 example.com
grip --ja4s example.com
grip --all-fp example.comjust the fingerprint string, nothing else. useful for piping into other tools:
grip --quiet example.comgrip --pcap capture.pcapshows a ranked table of every unique client fingerprint found in the capture.
for TLS 1.3 where the cert is encrypted in the raw capture:
grip --cert-chain example.comfor inspecting certs that fail verification:
grip --no-verify expired.badssl.comsee the actual bytes of the ClientHello and ServerHello:
grip --raw example.comextra telemetry: handshake timing, bytes sent and received, resolved IPs, GREASE count:
grip --verbose example.comgrip --pcap capture.pcap --filter 192.168.1.5grip --pcap capture.pcap --uniquegrip --pcap capture.pcap --sort-by count
grip --pcap capture.pcap --sort-by ip
grip --pcap capture.pcap --sort-by fingerprint$ grip example.com
grip · live handshake example.com:443
────────────────────────────────────────────────────────────────────
╭─ negotiated ─────────────────────────────────────────────────────╮
│ tls version TLS 1.3 │
│ cipher suite TLS_AES_128_GCM_SHA256 0x1301 │
│ key exchange X25519 │
│ sni example.com hostname │
│ offered TLS 1.3 · TLS 1.2 │
╰──────────────────────────────────────────────────────────────────╯
╭─ certificate ────────────────────────────────────────────────────╮
│ subject CN=example.com │
│ issuer C=US, O=SSL Corporation, CN=Cloudflare TLS Iss…│
│ sans example.com *.example.com │
│ expires 2026-10-27 (52 days) │
│ sha-256 61:53:a9:6f:d1:a6:ab:7f:4d:43:8f:c3:49:32:48:4…│
│ ct logs 1 scts embedded │
╰──────────────────────────────────────────────────────────────────╯
╭─ fingerprints ───────────────────────────────────────────────────╮
│ ja3 d879bc8777862a634eecc81a0d21e701 │
│ ja4 t13d1305h2_bda08f0cbb17_c83d862dc2aa │
│ ja4s t13d020000_1301_1acd28cc39f1 │
╰──────────────────────────────────────────────────────────────────╯
────────────────────────────────────────────────────────────────────
◆ unclassified client
$ grip --verbose --cert-chain --all-fp example.com
resolve querying dns for example.com, sni example.com
receive TLS 1.3, 95 B in, 1169 ms
fingerprint ja4 t13d1305h2_bda08f0cbb17_c83d862dc2aa
certificate 4 in chain, leaf CN=example.com
grip · live handshake example.com:443
────────────────────────────────────────────────────────────────────
╭─ negotiated ─────────────────────────────────────────────────────╮
│ tls version TLS 1.3 │
│ cipher suite TLS_AES_128_GCM_SHA256 0x1301 │
│ key exchange X25519 │
│ sni example.com hostname │
│ offered TLS 1.3 · TLS 1.2 │
╰──────────────────────────────────────────────────────────────────╯
╭─ certificate ────────────────────────────────────────────────────╮
│ subject CN=example.com │
│ issuer C=US, O=SSL Corporation, CN=Cloudflare TLS Iss…│
│ sans example.com *.example.com │
│ expires 2026-10-27 (52 days) │
│ sha-256 61:53:a9:6f:d1:a6:ab:7f:4d:43:8f:c3:49:32:48:4…│
│ ct logs 1 scts embedded + 3 intermediates │
╰──────────────────────────────────────────────────────────────────╯
╭─ chain of trust ─────────────────────────────────────────────────╮
│ leaf CN=example.com depth 0 │
│ └─ 61:53:a9:6f:d1:a6:ab:7f:4d:43:8f:c3:49:32:4…│
│ issuer C=US, O=SSL Corporation, CN=Cloudflar… depth 1│
│ └─ f1:5f:29:ab:ef:73:aa:4d:d9:ab:75:4b:ae:ae:3…│
│ issuer C=US, O=SSL Corporation, CN=SSL.com T… depth 2│
│ └─ 5d:1b:c3:99:27:4e:64:9e:1c:72:69:7d:e9:1a:5…│
│ issuer C=US, O=SSL Corporation, CN=SSL.com T… depth 3│
│ └─ ba:06:d3:d3:e3:48:fc:e7:47:8c:c8:4b:42:2d:0…│
╰──────────────────────────────────────────────────────────────────╯
╭─ fingerprints ───────────────────────────────────────────────────╮
│ ja3 d879bc8777862a634eecc81a0d21e701 │
│ ja4 t13d1305h2_bda08f0cbb17_c83d862dc2aa │
│ ja4s t13d020000_1301_1acd28cc39f1 │
╰──────────────────────────────────────────────────────────────────╯
╭─ telemetry ──────────────────────────────────────────────────────╮
│ handshake 1169 ms │
│ bytes 205 out 95 in │
│ grease 0 filtered │
│ resolved [2606:4700:10::6814:179a]:443 [2606:4700:10::…│
╰──────────────────────────────────────────────────────────────────╯
────────────────────────────────────────────────────────────────────
◆ 1169 ms · unclassified client
{
"target": "example.com:443",
"negotiated": {
"tls_version": "TLS 1.3",
"offered_versions": ["TLS 1.3", "TLS 1.2"],
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"cipher_hex": "0x1301",
"key_exchange": "X25519",
"alpn": null,
"sni": "example.com",
"sni_is_ip": false
},
"certificate": {
"subject": "CN=example.com",
"issuer": "C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3",
"sans": ["example.com", "*.example.com"],
"expires": "2026-10-27 (52 days)",
"sha256": "61:53:a9:6f:d1:a6:ab:7f:4d:43:8f:c3:49:32:48:42:99:d0:72:9d:91:40:b3:a1:26:bb:2f:9c:07:b0:22:00",
"ct_logs": "1 scts embedded"
},
"fingerprints": {
"ja3": "d879bc8777862a634eecc81a0d21e701",
"ja4": "t13d1305h2_bda08f0cbb17_c83d862dc2aa",
"ja4s": "t13d020000_1301_1acd28cc39f1",
"lookup": null
}
}when you call a TLS library's connect function and ask for the certificate, the library builds its own ClientHello. cipher suites, extension order, GREASE values, all of that is the library's choice. if you are trying to understand what is actually on the wire, you need the real bytes.
grip reads raw bytes off the TCP socket and parses every field itself. the TLS record layer, the handshake header, the version field (which lies, more on that below), the random, the session ID, the cipher suites, every extension. the raw bytes are the source of truth.
TLS 1.3 ClientHellos write 0x0303 (TLS 1.2) in the legacy version field. the actual version is hidden inside the supported_versions extension (0x002b). if you read the top level version field to determine TLS version, you will fingerprint every TLS 1.3 client as TLS 1.2. this trips everyone up the first time they build a TLS parser. grip handles this correctly by checking supported_versions when present.
Defined in RFC 8701. browsers insert fake reserved values (0x0a0a, 0x1a1a, 0x2a2a, and so on up to 0xfafa) into cipher suites and extensions to test that servers do not choke on unknown values. JA4 strips these before computing fingerprints. the order of operations matters: you sort cipher suites numerically after removing GREASE. sort first, then remove, and you get the wrong fingerprint for some clients. grip filters GREASE before sorting.
The older format, originally by Salesforce. it is an MD5 hash of a comma delimited string:
{TLSVersion},{CipherSuites},{Extensions},{EllipticCurves},{PointFormats}
output: cd08e31494f9531f560d64c695473da9
simple, widely supported, but has known collision issues and does not encode enough information for modern differentiation.
The newer format by FoxIO. three parts separated by underscores:
t13d1305h2_bda08f0cbb17_c83d862dc2aa
│││││││└── first two chars of first ALPN value
│││││└──── number of extensions (2 digits, excluding SNI and ALPN)
│││└────── number of cipher suites (2 digits, excluding GREASE)
││└─────── d = SNI present (domain), i = IP address
│└──────── negotiated TLS version (13 = 1.3, 12 = 1.2)
└───────── protocol (t = TLS, q = QUIC, d = DTLS)
part 2 is SHA256 truncated to 12 hex chars of cipher suites sorted numerically, comma separated, GREASE removed. part 3 is SHA256 truncated to 12 hex chars of extensions sorted numerically plus signature algorithms, comma separated.
JA4 is human readable by design. you can look at t13d1305h2 and know it is TLS 1.3, domain facing, 13 extensions, 5 cipher suites, ALPN starts with "h2".
References: FoxIO JA4 spec and JA3 spec.
Same idea but from the ServerHello. what cipher suite did the server pick, what extensions did it send back. different server software produces different JA4S values. a Cloudflare edge server has a different JA4S than nginx, which differs from Apache. grip computes JA4S from the raw ServerHello bytes.
For TLS 1.2, the Certificate message is plaintext. grip reads it directly from the raw bytes. For TLS 1.3, it is encrypted under the handshake keys. grip cannot read it from the capture, so it falls back to a rustls connection with webpki-roots, capped at a 5 second budget. the raw handshake bytes you see are still the real bytes. the certificate comes from a verified secondary connection.
This is the hard part of pcap mode. the process:
- parse the pcap global header (magic number, version, snaplen, link type)
- parse each packet record (timestamp, captured length, original length, data)
- strip link layer headers (Ethernet and others) to get IP packets
- parse IP and TCP headers to extract the four tuple (src_ip, src_port, dst_ip, dst_port)
- group packets by four tuple, which equals one TCP stream
- reassemble segments in sequence number order, handling overlaps and gaps
- walk the reassembled byte stream looking for TLS records (
0x16 0x03) - parse each TLS record to extract the ClientHello
if you skip step 6 and just scan raw packets, you will miss fragmented ClientHellos and get wrong fingerprints.
For live mode, grip builds a ClientHello using ClientHelloBuilder. the builder handles GREASE injection, extension ordering, SNI, ALPN, supported versions, key share, and signature algorithms. the resulting bytes are sent over a raw TCP connection. the ServerHello response is read in a tight loop with a 400ms poll timeout so grip stops as soon as it has enough data. for TLS 1.3, that is right after the ServerHello, not after the full handshake.
src/
cli/ command line parsing, validation, live/pcap dispatch
args.rs clap derive structs
validate.rs input validation (timeout, port, SNI, flag combos)
run/
mod.rs entry point, mode dispatch
live.rs live probe orchestration
pcap.rs pcap analysis orchestration
helpers.rs report building, progress events
tls/ raw TLS parsing (hand-rolled, no TLS library)
record.rs TLS record layer (content type, version, length, payload)
version.rs TLS version detection (the lying version field)
cipher.rs cipher suite ID to IANA name mapping
grease.rs GREASE detection and filtering
client_hello.rs ClientHello parsing (random, session ID, ciphers, extensions)
server_hello.rs ServerHello parsing (selected cipher, extensions, version)
certificate.rs certificate chain parsing (X.509, SANs, SHA-256, CT logs)
builder.rs ClientHello construction (manual byte building)
extensions/ extension parsers
mod.rs Extension enum, parse_extensions dispatcher
sni.rs SNI (0x0000) parsing
alpn.rs ALPN (0x0010) parsing
versions.rs Supported Versions (0x002b) parsing
key_share.rs Key Share (0x0033) parsing
common.rs shared helpers (u16 lists, u8 lists)
fp/ fingerprint computation
ja3.rs JA3 MD5 hash
ja4.rs JA4 structured hash (three parts)
ja4s.rs JA4S server fingerprint
lookup.rs client identification from JA4 database
pcap/ pcap file handling
reader.rs pcap global header + record parsing
tcp.rs TCP packet parsing (IP, TCP headers, four tuple)
reassembly.rs TCP stream reassembly (sequence number ordering)
extractor.rs ClientHello extraction from reassembled streams
net/ live network probe
connect/
mod.rs probe orchestration (resolve, connect, drive, parse)
dial.rs DNS resolution + TCP connection with timeout
raw.rs TLS handshake driver (send ClientHello, read response)
cert_fetch.rs rustls fallback for TLS 1.3 certificate retrieval
progress.rs Progress trait for live animation events
output/ rendering and output formats
model.rs DTOs (LiveReport, PcapReport, Fingerprints, etc.)
human/ human-readable terminal output
mod.rs dispatcher
live.rs live report rendering
pcap.rs pcap table rendering
raw.rs raw hex dump rendering
quiet.rs single-value output for piping
json.rs JSON serialization
hex.rs hexdump utility
writer.rs output writer abstraction
ui/ design system
theme.rs truecolor palette, gradients, glyphs, box drawing
mascot.rs Nib mascot (pulse animation)
stage.rs animated build checklist (painter thread)
panel.rs aligned box layout primitive
db/ built-in fingerprint database
known.rs PHF map of known JA4 fingerprints (generated at build time)
error.rs error types (thiserror)
- live mode: around 0.2 seconds end to end (DNS resolve plus TCP connect plus TLS handshake plus parse plus cert fallback)
- release binary: around 3.8 MB stripped with LTO and codegen-units=1
- pcap mode: reassembles TCP streams in memory, processes thousands of handshakes per second
- no async runtime: blocking I/O is fine for a tool that makes one connection and exits
TLS only. grip parses TLS handshakes. it does not do HTTP parsing, certificate transparency monitoring, or traffic classification beyond the TLS layer.
no deep packet inspection. grip sees the handshake and stops. it does not follow the encrypted stream or decrypt application data.
cert fallback requires network. for TLS 1.3 live mode, the certificate is encrypted in the raw capture. grip falls back to a rustls connection to fetch it. if you are offline or behind a firewall that blocks the connection, you will not get the cert.
JA4 client DB is static. the built-in fingerprint database is compiled at build time from assets/fingerprints.csv. new clients require a rebuild.
no QUIC or HTTP3. JA4 supports QUIC fingerprinting but grip does not parse QUIC packets yet.
no live capture. pcap mode reads files. live packet capture via libpcap binding is on the roadmap.
--diffmode: compare two pcaps and show new or changed fingerprints- shell completions for bash, zsh, fish
--summaryflag for one liner output- JA4S server fingerprint lookup against known server DB
- QUIC and HTTP3 fingerprinting (JA4Q)
- live packet capture mode (libpcap binding)
- PCAPNG support (currently only classic pcap)
- custom ClientHello crafting (send specific cipher suites to probe server behavior)
- IPv6 pcap reassembly
- web UI for team dashboards
- siem integration (CEF, syslog)
- plugin system for custom fingerprint databases
- continuous monitoring mode (watch a network interface, fingerprint every new connection)
- rust 1.88 or later (edition 2024)
- just (command runner, optional but recommended)
just check # run tests plus clippy plus fmt check
just test # run all tests
just clippy # run clippy with strict warnings
just fmt # auto-format code
just build # debug build
just release # optimized release build
just bench # run benchmarks
just stats # show project statisticssee docs/architecture.md for the full spec.
the key design decisions:
- strictly one way dependency direction:
cli -> net/pcap -> output -> fp -> tls -> error. no cycles, no back arrows. - pure functional core:
tlsandfpare&[u8]in, structured data out. no I/O, no network, no file system. testable with canned byte arrays. - no unsafe:
#![forbid(unsafe_code)]across the entire crate. - raw bytes are truth: the TLS parser reads raw bytes. rustls is only used for certificate fallback, never for fingerprinting.
- custom UI system: truecolor gradients, animated mascot, checklist progress. designed for this tool, not a generic library.
open issues for bugs or feature requests. pull requests are welcome for fixes and new features. if you are adding a new TLS extension to the parser, add a test with the raw bytes. if you are adding a new client to the fingerprint database, add it to assets/fingerprints.csv.
MIT