Skip to content

Improve TLS ClientHello SNI parsing (research mode)#669

Merged
kasnder merged 4 commits into
masterfrom
claude/trackercontrol-655-review-0wgg7x
Jul 15, 2026
Merged

Improve TLS ClientHello SNI parsing (research mode)#669
kasnder merged 4 commits into
masterfrom
claude/trackercontrol-655-review-0wgg7x

Conversation

@kasnder

@kasnder kasnder commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Addresses the tls.c gaps raised in the #655 comment (from #654) for the opt-in, research-mode SNI extraction path (jni_sni / is_play). This is not a change to DNS-based blocking — per the discussion on #655, DNS attribution stays system-wide.

The SNI path had three concrete defects and a missing capability:

  1. No cross-segment reassembly. A ClientHello split across TCP segments (increasingly common with large extension sets / post-quantum key shares) was parsed from the first segment only; if the record was incomplete, parse_tls_header silently gave up and the SNI was lost.
  2. cur variable shadowing. The TCP session found for a port-443 flow was stored in a shadowed local, so the outer cur stayed NULL. Consequently checkedHostname was never set and the address decision re-ran on every packet.
  3. Uninitialised checkedHostname. ng_malloc does not zero memory, and the field was never initialised at session creation, so the per-session "already checked" gate read garbage.
  4. Off-by-one in parse_server_name_extension (tls.c:66): the entry-header bound pos + 3 < data_len dropped a server name ending exactly at the buffer boundary.

Changes

tls.c / tls.h

  • parse_tls_header now returns a status code (>=0 hostname length, TLS_PARSE_INCOMPLETE, TLS_PARSE_NO_SNI, TLS_PARSE_INVALID) instead of void, so the caller can distinguish "record not fully received — buffer more" from "complete, no SNI" and "not TLS". TLS_PARSE_INCOMPLETE is returned whenever the declared record length exceeds the bytes present.
  • Fixed the entry-header off-by-one (<<=).
  • FQDN_MAX hoisted to the header so the caller can size its buffer.

ip.c

  • Assign the found session to the single cur (removes the shadowing), so the decision block sees it and checkedHostname is honoured.
  • Bounded cross-segment reassembly: when the first 443 segment yields an incomplete record, buffer the payload per session (capped at TLS_SNI_MAX_BUFFER = 16 KiB, one max TLS record) and re-parse as later segments arrive. The block decision is postponed while reassembling so it is never made on partial evidence. The buffer is allocated only for a split ClientHello and freed as soon as the record completes or the cap is reached — keeping the cost bounded and confined to research mode, in line with the battery-first constraint flagged in the issue.

tcp.c / netguard.h

  • Initialise checkedHostname = 0 at session creation and add the tls_data / tls_len reassembly fields, freed in clear_tcp_data (all TCP teardown paths route through it, so no leak).

Testing

The full Android/NDK build cannot run in this environment (the NDK is not installed and the required Gradle 9.6.1 distribution host is blocked by egress policy), so tls.c was validated with a host-side harness compiled -Wall -Wextra (zero warnings):

  • full record → SNI extracted;
  • every truncation of the record → TLS_PARSE_INCOMPLETE (drives reassembly);
  • feeding the record in 200-byte chunks into a growing buffer (mirroring the ip.c logic) → SNI found exactly when the record completes;
  • non-TLS payload → TLS_PARSE_INVALID (never buffered);
  • ClientHello without SNI → TLS_PARSE_NO_SNI.

CI will exercise the full native build.

Notes / scope

  • SNI reassembly relies on segments arriving roughly in order at the start of the handshake; out-of-order or corrupted buffers are rejected by the TLS structure checks and fall back to "no SNI" (best-effort, research mode).
  • A ClientHello fragmented across multiple TLS records (as opposed to TCP segments) remains out of scope — real ClientHellos are a single record; it is TCP segmentation that this PR fixes.

🤖 Generated with Claude Code


Generated by Claude Code

claude and others added 2 commits July 14, 2026 22:45
Addresses the tls.c gaps raised in #654/#655 for the opt-in SNI
extraction path (jni_sni / is_play).

parse_tls_header:
- Return status codes instead of void so the caller can tell an
  incomplete TLS record (buffer more) apart from a complete record with
  no SNI or a non-TLS payload. TLS_PARSE_INCOMPLETE is returned whenever
  the record length exceeds the bytes present.
- Fix the off-by-one in parse_server_name_extension: the entry-header
  bound was `pos + 3 < data_len`, which dropped a server name ending
  exactly at the buffer boundary. It is now `pos + 3 <= data_len`.

ip.c:
- Fix a variable-shadowing bug: the TCP session found for a 443 flow was
  stored in a shadowed local, so the outer `cur` stayed NULL. As a
  result checkedHostname was never set and the block decision re-ran on
  every packet. The found session is now assigned to the single `cur`.
- Reassemble a ClientHello that spans multiple TCP segments. When the
  first segment yields an incomplete record, buffer the payload per
  session (bounded by TLS_SNI_MAX_BUFFER) and re-parse as later segments
  arrive, instead of losing the SNI for good. The block decision is
  postponed while reassembling so it is not made on partial evidence.
  The buffer is allocated only for a split ClientHello and freed as soon
  as the record completes or the cap is reached, keeping the cost bounded
  and confined to research mode.

tcp.c / netguard.h:
- Initialise checkedHostname at session creation (previously read from
  uninitialised malloc memory) and add the reassembly buffer fields,
  freed in clear_tcp_data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fqmytQ1CBbvpXKrweEm7z
A blocked SNI verdict on an established 443 session only dropped the
deciding segment; retransmissions then passed because the decision is
once-per-session. Send an RST to the client instead, mirroring
handle_tcp's handling of blocked new sessions.

Also stop allocating the 16 KiB reassembly buffer on zero-length
segments (the handshake ACK), deferring without buffering instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 15, 2026 07:34
Continuation segments were appended to the reassembly buffer blindly,
so a retransmitted segment (e.g. when the engine is slow to ACK) would
duplicate bytes and corrupt the buffer, losing the SNI. Track the next
expected sequence number: ignore pure retransmissions, append only the
fresh tail of a partial overlap, and abandon reassembly on a forward
gap rather than parse a buffer with a hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the opt-in research-mode TLS ClientHello SNI extraction path used by the native NetGuard engine, primarily to (1) correctly distinguish incomplete TLS records from non-SNI cases, and (2) add bounded TCP-segment reassembly so SNI isn’t lost when a ClientHello spans multiple segments.

Changes:

  • Updated parse_tls_header to return explicit status codes (success / incomplete / no SNI / invalid) and fixed an SNI list boundary off-by-one.
  • Added bounded per-session buffering to reassemble split ClientHello payloads (research mode only), and ensured session state is initialized and freed correctly.
  • Fixed TCP session lookup shadowing so checkedHostname gating works as intended.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
app/src/main/jni/netguard/tls.h Exposes FQDN_MAX and new parse_tls_header return codes/status API.
app/src/main/jni/netguard/tls.c Implements new return-code semantics; fixes SNI parsing boundary condition.
app/src/main/jni/netguard/ip.c Implements per-session ClientHello buffering/retry and avoids re-deciding every packet.
app/src/main/jni/netguard/tcp.c Initializes new TCP session fields and frees buffered TLS data on teardown.
app/src/main/jni/netguard/netguard.h Adds TCP session fields for TLS reassembly buffer + length.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// post-quantum key shares. The buffer is per-session, allocated only for a
// split ClientHello, and freed as soon as the record is complete or the cap is
// reached, so the battery/memory cost stays bounded and confined to is_play.
#define TLS_SNI_MAX_BUFFER 16384
Comment on lines 355 to +359
char* packetdata = data;
// While a ClientHello is still being reassembled across TCP segments we
// let the segment through but postpone the block decision until the SNI
// is available (or we give up), so it is not made on partial evidence.
int defer_sni = 0;
Comment on lines +393 to +395
if (cur != NULL && cur->tcp.checkedHostname == 0) {
char hostname[FQDN_MAX + 1] = "";
int rc;
Comment on lines +430 to +433
if (strnlen(hostname, sizeof(hostname)) > 0) {
log_android(ANDROID_LOG_DEBUG, "Seen SNI: %s", hostname);
packetdata = hostname;
}
This reverts commit 0a10948.

Not worth the extra native code: the corruption it guards against
(a retransmission during the few-ms reassembly window) is rare and
already degrades safely — the TLS structure checks reject the buffer
and the decision falls back to IP/DNS evidence. Blind best-effort
append is the documented scope of research-mode reassembly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kasnder
kasnder merged commit 4ea429a into master Jul 15, 2026
2 checks passed
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.

3 participants