From 31f68b3968c6837920579875efea2746da22b7a0 Mon Sep 17 00:00:00 2001 From: NK Date: Sat, 5 Sep 2026 16:02:57 +0530 Subject: [PATCH 1/2] fix(python): read single-certificate counts as one value in ja4x tshark reports a field with a single value as a plain string, so a ServerHello carrying exactly one certificate delivered the extension and RDN counts as strings ("12"). Those counts are iterated in to_ja4x, and a string is walked one digit at a time: 12 extensions were read as two certificates with 1 and 2 extensions, and the second one indexed past the computed issuer and subject hashes. The call is not guarded in main(), so the run aborted with an IndexError and no output at all. Single-digit counts worked by accident, and every capture in the test suite carries certificate chains, which is why this went unnoticed. Any server sending a lone leaf certificate with ten or more extensions killed the whole run. Coerce the counts to lists before iterating. --- python/ja4x.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/python/ja4x.py b/python/ja4x.py index 2a4fab0d..fadf7e25 100644 --- a/python/ja4x.py +++ b/python/ja4x.py @@ -37,7 +37,15 @@ def to_ja4x(x, debug_stream=-1): # we need to convert them into hex codes and then use sha256 if 'extension_lengths' not in x: return - + + # tshark reports a field with a single value as a plain string. The + # certificate counts are iterated below, and a string would be walked + # one digit at a time: a certificate with 10 or more extensions ('12') + # was read as two certificates with 1 and 2 extensions. + for field in ('extension_lengths', 'issuer_sequence', 'subject_sequence'): + if field in x and not isinstance(x[field], list): + x[field] = [x[field]] + x['issuers'] = [] x['subjects'] = [] x['issuer_hashes'] = [] From 39647b0433291b00a294d74afc708a458c3a11ee Mon Sep 17 00:00:00 2001 From: NK Date: Sat, 5 Sep 2026 16:02:57 +0530 Subject: [PATCH 2/2] test(python): cover ja4x single-certificate string counts --- python/test/test_ja4_unit.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/python/test/test_ja4_unit.py b/python/test/test_ja4_unit.py index fb3834eb..5edab833 100644 --- a/python/test/test_ja4_unit.py +++ b/python/test/test_ja4_unit.py @@ -5,9 +5,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from common import epoch_diff, get_cache # noqa: E402 +from common import cache_update, epoch_diff, get_cache # noqa: E402 from ja4 import first_last_alpn, hops, to_ja4, to_ja4s # noqa: E402 from ja4h import to_ja4h # noqa: E402 +from ja4x import to_ja4x # noqa: E402 def test_epoch_diff_spans_seconds(): @@ -108,3 +109,34 @@ def test_tls_fingerprints_handle_alpn_edge_cases( render(x, debug_stream=-1) for field in fields: assert x[field].split("_", 1)[0] == prefix + expected + + +def _ja4x_input(extension_lengths, nr_oids): + return { + "hl": "x509af", + "stream": 3, + "extension_lengths": extension_lengths, + "cert_extensions": [f"2.5.29.{i}" for i in range(10, 10 + nr_oids)], + "issuer_sequence": ["1"], + "subject_sequence": ["1"], + "rdn_oids": ["2.5.4.3", "2.5.4.3"], + } + + +def test_to_ja4x_two_digit_count_is_one_certificate(): + # tshark reports a lone certificate's counts as plain strings + x = _ja4x_input("10", nr_oids=10) + cache_update(x, "stream", 3, -1) + to_ja4x(x, debug_stream=-1) + assert "JA4X.1" in x + assert "JA4X.2" not in x + + +def test_to_ja4x_string_and_list_counts_agree(): + as_string = _ja4x_input("3", nr_oids=3) + as_list = _ja4x_input(["3"], nr_oids=3) + cache_update(as_string, "stream", 4, -1) + cache_update(as_list, "stream", 5, -1) + to_ja4x(as_string, debug_stream=-1) + to_ja4x(as_list, debug_stream=-1) + assert as_string["JA4X.1"] == as_list["JA4X.1"]