Skip to content

ja4_fingerprint: wrong JA4 cipher hash and empty-list values #13727

Description

@maskit

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions