The ja4_fingerprint plugin produces JA4 values that don't match the JA4 spec or other JA4 implementations, for two reasons beyond the missing signature algorithms addressed in #13725. jax_fingerprint --method JA4 isn't affected by either.
1. Cipher suites are byte-swapped in the b section
add_ciphers() builds each cipher code with make_word(buf[i], buf[i + 1]), and make_word treats its first argument as the low byte:
make_word(unsigned char lowbyte, unsigned char highbyte)
{
return (static_cast<std::uint16_t>(highbyte) << 8) | lowbyte;
}
Cipher suites are big-endian on the wire, so every code is swapped (0x1302 becomes 0x0213) before it's sorted and hashed. As a result, the b section never matches other JA4 tools for any client. The cipher count in the a section is still correct, and GREASE values still get filtered because they read the same either way, so the output looks plausible.
jax_fingerprint reads the same bytes big-endian (tls_client_hello_summary.cc), and its b matches the spec.
The unit tests don't catch this because they pass cipher values to add_cipher() as integers and never go through make_word.
Reproduction
Load both plugins and connect once with openssl s_client:
ja4_fingerprint.so
jax_fingerprint.so --standalone --method JA4 --log-filename jax_ja4
For a ClientHello from OpenSSL 3.6 offering 30 cipher suites, the two plugins logged the same a and c but a different b:
| Plugin |
b |
ja4_fingerprint |
0be778905493 |
jax_fingerprint |
1d37bd780c83 |
Hashing that ClientHello's cipher list by hand, per the spec, gives 1d37bd780c83. Hashing the list with each code byte-swapped gives 0be778905493.
2. Empty lists are hashed instead of reported as zeros
The spec says b is 000000000000 when there are no ciphers, and c is 000000000000 when there are no extensions left after removing SNI and ALPN. make_JA4_fingerprint() always hashes the raw string, so an empty list gives e3b0c44298fc, the SHA-256 of an empty string. jax_fingerprint outputs zeros in both cases.
I found this by reading the code and haven't reproduced it with live traffic. A ClientHello with no extensions other than SNI/ALPN, which some TLS 1.2 clients send, should trigger the c case. A ClientHello with no cipher suites is malformed and is probably rejected by the TLS library before the plugin runs.
Impact
Fingerprints from ja4_fingerprint can't be compared with JA4 values from other sources such as ja4db, threat-intel feeds, or network sensors. Rules built from its own output match only other ja4_fingerprint output. Fixing either bug changes the values the plugin produces, so stored fingerprints would need to be regenerated.
Recommendation
Use jax_fingerprint with --method JA4 instead of ja4_fingerprint. It doesn't have either bug. With #13725 applied, its whole fingerprint matches the spec's published example and values computed by hand from live ClientHellos.
The configuration below gives the same headers and a comparable log:
jax_fingerprint.so --standalone --method JA4 --header ja4 --via-header x-ja4-via --log-filename ja4_fingerprint
--via-header sets the header to proxy.config.proxy_name, as ja4_fingerprint does.
- To leave
ja4 headers the client already sent in place, which is what ja4_fingerprint --preserve does, add --mode keep.
- Log lines start with
Client: instead of Client IP:, so parsers keyed on that prefix need updating.
The
ja4_fingerprintplugin produces JA4 values that don't match the JA4 spec or other JA4 implementations, for two reasons beyond the missing signature algorithms addressed in #13725.jax_fingerprint --method JA4isn't affected by either.1. Cipher suites are byte-swapped in the
bsectionadd_ciphers()builds each cipher code withmake_word(buf[i], buf[i + 1]), andmake_wordtreats its first argument as the low byte:Cipher suites are big-endian on the wire, so every code is swapped (0x1302 becomes 0x0213) before it's sorted and hashed. As a result, the
bsection never matches other JA4 tools for any client. The cipher count in theasection is still correct, and GREASE values still get filtered because they read the same either way, so the output looks plausible.jax_fingerprintreads the same bytes big-endian (tls_client_hello_summary.cc), and itsbmatches the spec.The unit tests don't catch this because they pass cipher values to
add_cipher()as integers and never go throughmake_word.Reproduction
Load both plugins and connect once with
openssl s_client:For a ClientHello from OpenSSL 3.6 offering 30 cipher suites, the two plugins logged the same
aandcbut a differentb:bja4_fingerprint0be778905493jax_fingerprint1d37bd780c83Hashing that ClientHello's cipher list by hand, per the spec, gives
1d37bd780c83. Hashing the list with each code byte-swapped gives0be778905493.2. Empty lists are hashed instead of reported as zeros
The spec says
bis000000000000when there are no ciphers, andcis000000000000when there are no extensions left after removing SNI and ALPN.make_JA4_fingerprint()always hashes the raw string, so an empty list givese3b0c44298fc, the SHA-256 of an empty string.jax_fingerprintoutputs zeros in both cases.I found this by reading the code and haven't reproduced it with live traffic. A ClientHello with no extensions other than SNI/ALPN, which some TLS 1.2 clients send, should trigger the
ccase. A ClientHello with no cipher suites is malformed and is probably rejected by the TLS library before the plugin runs.Impact
Fingerprints from
ja4_fingerprintcan't be compared with JA4 values from other sources such as ja4db, threat-intel feeds, or network sensors. Rules built from its own output match only otherja4_fingerprintoutput. Fixing either bug changes the values the plugin produces, so stored fingerprints would need to be regenerated.Recommendation
Use
jax_fingerprintwith--method JA4instead ofja4_fingerprint. It doesn't have either bug. With #13725 applied, its whole fingerprint matches the spec's published example and values computed by hand from live ClientHellos.The configuration below gives the same headers and a comparable log:
--via-headersets the header toproxy.config.proxy_name, asja4_fingerprintdoes.ja4headers the client already sent in place, which is whatja4_fingerprint --preservedoes, add--mode keep.Client:instead ofClient IP:, so parsers keyed on that prefix need updating.