Skip to content

Zeek: fix JA4T/JA4L empty for tunneled connections - #329

Open
mmguero wants to merge 3 commits into
FoxIO-LLC:mainfrom
mmguero-dev:main
Open

mmguero wants to merge 3 commits into
FoxIO-LLC:mainfrom
mmguero-dev:main

Conversation

@mmguero

@mmguero mmguero commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #324. From my comment there:

The JA4T and JA4L scripts read TCP header fields with get_current_packet_header() and timestamps with get_current_packet(). Both of those describe the outermost packet Zeek read off the wire. For a connection inside GENEVE, that's the tunnel's Ethernet/IP/UDP header, so rph?$tcp is false.

In JA4T, new_connection checks !rph?$tcp || rph$tcp$flags != TH_SYN and returns right there. The SYN never gets recorded, syn_window_size stays 0, and connection_state_remove writes nothing. parse_tcp_options in ja4t.cc never runs for these connections. If the scripts had been seeing inner headers, you'd get a window size followed by junk options, not an empty field.

JA4L fails the same way. new_connection takes the non-TCP path and records the SYN with the tunnel's TTL. When the SYN-ACK threshold fires, !rp?$tcp returns early, so ja4l_s never gets written and the threshold for the client's ACK never gets armed. That's where the all-empty ja4l/ja4ls columns in the forum post come from. ssl_server_hello has the same !rp?$tcp check, which is there to skip QUIC, and it also drops every TLS connection inside a UDP tunnel.

A fix in the BiF won't work. Zeek builds the inner packet in the tunnel analyzer, processes it, and frees it before the event queue drains. By the time any script handler or BiF runs, the inner packet is already gone. The data has to come from events that Zeek fills in from the inner headers while it still has them.

The change

JA4T/JA4TS. Window size comes from connection_SYN_packet. Option kinds, MSS, and window scale come from tcp_options. Zeek raises those two as separate events for the same packet, so whichever arrives first parks its data and the other claims it by matching packet timestamps. That keeps the code independent of event order. SYN-ACK retransmit delays come from consecutive responder SYN events. The -R suffix comes from connection_reset when history shows a responder RST. The client's second packet still ends tracking through ConnThreshold. The old rules carry over: only the connection's opening SYN counts, and the SYN-ACK has to be the responder's first packet.

JA4L. The SYN and SYN-ACK timestamps and both TTLs come from connection_SYN_packet, so tunneled connections get the inner TTL. ssl_server_hello checks the connection's own transport protocol, not the outer header.

Dead code. With JA4T now in pure script, the JA4::parse_tcp_options BiF, src/ja4t.cc, and src/ja4t.h are removed. CMakeLists and the README are updated to match.

Behavior changes for non-tunneled traffic

  • Non-Ethernet link types now get TCP options. The old rph$l2$encap != LINK_ETHERNET check returned empty options for anything else. For example, the existing ipv6.pcapng trace used in some of the baseline tests is NULL/Loopback, and its baseline recorded 65535_00_00_00 for both directions. It now records 65535_2-1-3-1-1-8-4_1346_6 and 65535_2-1-1-4-1-3_1346_10, which match the option kinds, MSS, and window scale tshark reports for the SYN and SYN-ACK. I updated the baseline.
  • ECN SYNs (SYN|ECE|CWR) now produce a JA4T. The old check needed flags to be exactly TH_SYN.
  • JA4L no longer writes ja4l_c without a SYN-ACK. Before, a client's second packet with no SYN-ACK seen (a SYN retransmit, say) gave a duration measured from zero.

Tests

New test ja4-conn-geneve with a trace built by a scapy script (gen_pcap-v2.py). The trace has two scenarios, each in the clear and inside GENEVE:

  • a full handshake on 443
  • a scanner pattern on 8443: SYN, the SYN-ACK sent three times, then a responder RST

Timestamps are fixed so the JA4L values stay stable. Inner TTLs are 50 for the client and 60 for the server, and the outer tunnel TTL is 200, so any fingerprint that picks up the tunnel header shows it right away. The baseline has the plain and tunneled rows identical for each scenario: 62500_50 / 125000_60 and 65535_2-3-4-1-1_1460_7 for the handshake, and ..._1-2-R4 in JA4TS for the scanner. The outer GENEVE UDP connection has no fingerprints, and TTL 200 never shows up. As far as I can tell, this is also the first test coverage the JA4TS delay and RST suffix path has had.

Known gaps

  • JA4L's F timestamp (first client data after the server hello) needs each packet's payload length. Script code can't see that for tunneled connections without a per-packet event like tcp_packet or new_packet, so tunneled connections skip F and ja4l_delta stays empty for them. Logging a timestamp from a packet that might carry no data seemed worse than leaving it unset.
  • JA4SSH counts ACKs and payload bytes per packet from the raw header, so it has the same problem and still produces nothing for tunneled connections.
  • QUIC JA4L still reads TTL from the raw header. For QUIC inside a UDP tunnel it logs the tunnel's TTL without any warning, so I'd treat this as the most urgent of the three. Zeek doesn't expose the inner TTL for UDP through any event I know of (I am a bit out of my depth at this point though).

get_current_packet_header() and get_current_packet() describe the
outermost packet. For a connection inside GENEVE, VXLAN, GRE, etc.
that is the tunnel's IP/UDP header, so rph?$tcp is false. JA4T bailed
out in new_connection before recording the SYN, and JA4L never armed
its SYN-ACK and ACK thresholds, leaving ja4t, ja4ts, ja4l and ja4ls
empty for every inner connection.

Take the handshake data from events that Zeek builds from the inner
headers:

- JA4T/JA4TS: window size comes from connection_SYN_packet, and option
  kinds, MSS and window scale come from tcp_options. The two events
  fire separately for the same packet, so whichever arrives first
  parks its data and the other claims it by packet timestamp.
  SYN-ACK retransmit delays come from responder SYN events, and the
  -R suffix comes from connection_reset with a responder RST in
  history. The LINK_ETHERNET restriction no longer applies.
- JA4L: SYN/SYN-ACK timestamps and both TTLs come from
  connection_SYN_packet. ssl_server_hello checks the connection's
  transport protocol, not the outer header.

Behavior changes for non-tunneled traffic:

- ECN SYNs (SYN|ECE|CWR) now produce a JA4T.
- JA4L no longer writes ja4l_c when the client's second packet
  arrives without a SYN-ACK having been seen.

Known gaps: JA4L's F timestamp needs per-packet payload length, which
is unavailable for tunneled connections, so it is skipped there and
ja4l_delta stays empty. JA4SSH and the QUIC JA4L TTL still read the
raw packet header.

Remove the JA4::parse_tcp_options BiF and src/ja4t.cc, which nothing
calls anymore. JA4T is now pure script.

Fixes FoxIO-LLC#324

Performance benchmarking tests to see how this impacts performance is the next step
before I submit this PR.

Signed-off-by: Seth Grover <seth.d.grover@gmail.com>
Signed-off-by: Seth Grover <seth.d.grover@gmail.com>
Signed-off-by: Seth Grover <seth.d.grover@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JA4T/JA4L empty for tunneled connections

1 participant