From 04f75d1c55380916ac37bc7370d661a318fd4a9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:22:17 +0000 Subject: [PATCH 1/5] Initial plan From b88773eb4c7ec857f27cb66716329d1221fc4b8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:31:34 +0000 Subject: [PATCH 2/5] Implement TLS SNI interception without connecting to remote server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change modifies the VPN packet handling to catch TLS SNI (Server Name Indication) without actually connecting to tracker hosts, enabling better tracking detection by faking the server response. Key changes: - Added SNI_PENDING state for TLS sessions awaiting SNI extraction - tcp.c: For port 443 connections, delay real socket opening until SNI is checked. Send fake SYN-ACK immediately to receive ClientHello. - ip.c: When SNI check fails (tracker detected), send RST to the app without ever making a connection to the tracker server. - netguard.h: Added sni_state field to tcp_session structure and SNI_PENDING/SNI_RECEIVED constants. Flow comparison: Before: SYN → connect to tracker → get SNI → block (tracker sees connection) After: SYN → fake SYN-ACK → get SNI → block (no connection to tracker) Co-authored-by: kasnder <5175206+kasnder@users.noreply.github.com> --- app/src/main/jni/netguard/ip.c | 31 +++++++ app/src/main/jni/netguard/netguard.h | 5 ++ app/src/main/jni/netguard/tcp.c | 119 +++++++++++++++++++++------ 3 files changed, 131 insertions(+), 24 deletions(-) diff --git a/app/src/main/jni/netguard/ip.c b/app/src/main/jni/netguard/ip.c index 0ac7f0d82..eb56554d7 100644 --- a/app/src/main/jni/netguard/ip.c +++ b/app/src/main/jni/netguard/ip.c @@ -384,6 +384,37 @@ void handle_ip(const struct arguments *args, } else { if (protocol == IPPROTO_UDP) block_udp(args, pkt, length, payload, uid); + else if (protocol == IPPROTO_TCP) { + // For TLS connections with SNI_PENDING state, send RST to close the fake connection + // This ensures the app knows the connection is blocked, without ever connecting to tracker + const uint8_t pkt_version = (*pkt) >> 4; + const struct iphdr *ip4 = (struct iphdr *) pkt; + const struct ip6_hdr *ip6 = (struct ip6_hdr *) pkt; + const struct tcphdr *tcphdr = (struct tcphdr *) payload; + + // Search for SNI_PENDING session to send RST + struct ng_session *cur = args->ctx->ng_session; + while (cur != NULL && + !(cur->protocol == IPPROTO_TCP && + cur->tcp.version == pkt_version && + cur->tcp.source == tcphdr->source && cur->tcp.dest == tcphdr->dest && + (pkt_version == 4 ? cur->tcp.saddr.ip4 == ip4->saddr && + cur->tcp.daddr.ip4 == ip4->daddr + : memcmp(&cur->tcp.saddr.ip6, &ip6->ip6_src, 16) == 0 && + memcmp(&cur->tcp.daddr.ip6, &ip6->ip6_dst, 16) == 0))) + cur = cur->next; + + if (cur != NULL && cur->tcp.sni_state == SNI_PENDING) { + log_android(ANDROID_LOG_WARN, "Blocked tracker via SNI - no connection made to %s/%u", + dest, dport); + // Account for received data in sequence numbers before RST + const uint8_t tcpoptlen = (uint8_t) ((tcphdr->doff - 5) * 4); + const uint8_t *data_ptr = payload + sizeof(struct tcphdr) + tcpoptlen; + const uint16_t datalen = (const uint16_t) (length - (data_ptr - pkt)); + cur->tcp.remote_seq += datalen; + write_rst(args, &cur->tcp); + } + } log_android(ANDROID_LOG_WARN, "Address v%d p%d %s/%u syn %d not allowed", version, protocol, dest, dport, syn); diff --git a/app/src/main/jni/netguard/netguard.h b/app/src/main/jni/netguard/netguard.h index 3d7898c6e..bb7ca891e 100644 --- a/app/src/main/jni/netguard/netguard.h +++ b/app/src/main/jni/netguard/netguard.h @@ -75,6 +75,10 @@ #define SOCKS5_CONNECT 4 #define SOCKS5_CONNECTED 5 +// SNI interception states - used to catch TLS SNI without connecting to remote server +#define SNI_PENDING 1 // Waiting for ClientHello with SNI +#define SNI_RECEIVED 2 // SNI extracted, ready to check or connect + struct context { pthread_mutex_t lock; int pipefds[2]; @@ -191,6 +195,7 @@ struct tcp_session { uint8_t state; uint8_t socks5; + uint8_t sni_state; // SNI interception state for TLS connections struct segment *forward; int checkedHostname; diff --git a/app/src/main/jni/netguard/tcp.c b/app/src/main/jni/netguard/tcp.c index a0c76edec..513f9c277 100644 --- a/app/src/main/jni/netguard/tcp.c +++ b/app/src/main/jni/netguard/tcp.c @@ -689,6 +689,9 @@ jboolean handle_tcp(const struct arguments *args, if (tcphdr->urg) return 1; + // Check if this is a TLS connection (port 443) that needs SNI interception + int is_tls_sni = (ntohs(tcphdr->dest) == 443); + // Check session if (cur == NULL) { if (tcphdr->syn) { @@ -755,6 +758,7 @@ jboolean handle_tcp(const struct arguments *args, s->tcp.dest = tcphdr->dest; s->tcp.state = TCP_LISTEN; s->tcp.socks5 = SOCKS5_NONE; + s->tcp.sni_state = 0; s->tcp.forward = NULL; s->next = NULL; @@ -770,33 +774,53 @@ jboolean handle_tcp(const struct arguments *args, s->tcp.forward->next = NULL; } - // Open socket - s->socket = open_tcp_socket(args, &s->tcp, redirect); - if (s->socket < 0) { - // Remote might retry - ng_free(s, __FILE__, __LINE__); - return 0; - } + // For TLS connections: delay socket opening until SNI is extracted + // This allows us to detect trackers before connecting to them + if (is_tls_sni && allowed) { + log_android(ANDROID_LOG_INFO, "%s SNI pending - fake SYN-ACK without connecting", packet); + s->socket = -1; // No socket yet + s->tcp.sni_state = SNI_PENDING; + s->tcp.recv_window = s->tcp.send_window; + + // Send fake SYN-ACK to the app + s->tcp.remote_seq++; // remote SYN + if (write_syn_ack(args, &s->tcp) >= 0) { + s->tcp.time = time(NULL); + s->tcp.local_seq++; // local SYN + s->tcp.state = TCP_SYN_RECV; + } - s->tcp.recv_window = get_receive_window(s); + s->next = args->ctx->ng_session; + args->ctx->ng_session = s; + } else { + // Normal flow: open socket immediately + s->socket = open_tcp_socket(args, &s->tcp, redirect); + if (s->socket < 0) { + // Remote might retry + ng_free(s, __FILE__, __LINE__); + return 0; + } + + s->tcp.recv_window = get_receive_window(s); - log_android(ANDROID_LOG_DEBUG, "TCP socket %d lport %d", - s->socket, get_local_port(s->socket)); + log_android(ANDROID_LOG_DEBUG, "TCP socket %d lport %d", + s->socket, get_local_port(s->socket)); - // Monitor events - memset(&s->ev, 0, sizeof(struct epoll_event)); - s->ev.events = EPOLLOUT | EPOLLERR; - s->ev.data.ptr = s; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, s->socket, &s->ev)) - log_android(ANDROID_LOG_ERROR, "epoll add tcp error %d: %s", - errno, strerror(errno)); + // Monitor events + memset(&s->ev, 0, sizeof(struct epoll_event)); + s->ev.events = EPOLLOUT | EPOLLERR; + s->ev.data.ptr = s; + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, s->socket, &s->ev)) + log_android(ANDROID_LOG_ERROR, "epoll add tcp error %d: %s", + errno, strerror(errno)); - s->next = args->ctx->ng_session; - args->ctx->ng_session = s; + s->next = args->ctx->ng_session; + args->ctx->ng_session = s; - if (!allowed) { - log_android(ANDROID_LOG_WARN, "%s resetting blocked session", packet); - write_rst(args, &s->tcp); + if (!allowed) { + log_android(ANDROID_LOG_WARN, "%s resetting blocked session", packet); + write_rst(args, &s->tcp); + } } } else { log_android(ANDROID_LOG_WARN, "%s unknown session", packet); @@ -850,9 +874,54 @@ jboolean handle_tcp(const struct arguments *args, // Do not change the order of the conditions + // Handle SNI interception for TLS connections + // When in SNI_PENDING state and allowed (SNI was checked in ip.c), open real socket + if (cur->tcp.sni_state == SNI_PENDING && datalen > 0) { + // SNI was allowed in ip.c (otherwise we wouldn't get here) + // Now open the real connection to the server + log_android(ANDROID_LOG_INFO, "%s SNI allowed - connecting to real server after fake handshake", + session); + + cur->socket = open_tcp_socket(args, &cur->tcp, redirect); + if (cur->socket < 0) { + log_android(ANDROID_LOG_ERROR, "%s failed to open socket after SNI check", session); + write_rst(args, &cur->tcp); + return 0; + } + + cur->tcp.recv_window = get_receive_window(cur); + log_android(ANDROID_LOG_DEBUG, "TCP socket %d lport %d (after SNI)", + cur->socket, get_local_port(cur->socket)); + + // Queue the ClientHello data to be sent when socket connects + queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); + + // Update remote_seq to account for received data + cur->tcp.remote_seq += datalen; + + // Send ACK for the received data + if (write_ack(args, &cur->tcp) < 0) { + return 0; + } + + // Monitor events for the new socket + memset(&cur->ev, 0, sizeof(struct epoll_event)); + cur->ev.events = EPOLLOUT | EPOLLERR; + cur->ev.data.ptr = cur; + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cur->socket, &cur->ev)) + log_android(ANDROID_LOG_ERROR, "epoll add tcp error %d: %s (after SNI)", + errno, strerror(errno)); + + // Reset to normal flow - socket will complete handshake + cur->tcp.state = TCP_LISTEN; // Reset to LISTEN to wait for socket connect + cur->tcp.sni_state = 0; // Clear SNI state + + return 1; + } + // Queue data to forward if (datalen) { - if (cur->socket < 0) { + if (cur->socket < 0 && cur->tcp.sni_state != SNI_PENDING) { log_android(ANDROID_LOG_ERROR, "%s data while local closed", session); write_rst(args, &cur->tcp); return 0; @@ -862,7 +931,9 @@ jboolean handle_tcp(const struct arguments *args, write_rst(args, &cur->tcp); return 0; } - queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); + if (cur->tcp.sni_state != SNI_PENDING) { + queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); + } } if (tcphdr->rst /* +ACK */) { From 7405e55d0811271d859462fd4cb45203a0bd4901 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:37:06 +0000 Subject: [PATCH 3/5] Fix code review issues: error handling, state machine, and cleanup - Handle write_syn_ack failure by cleaning up and returning early - Don't update remote_seq in SNI handler - let check_tcp_socket handle it - Remove redundant SNI_PENDING checks - Remove unused SNI_RECEIVED constant - Keep state as TCP_ESTABLISHED instead of resetting to TCP_LISTEN Co-authored-by: kasnder <5175206+kasnder@users.noreply.github.com> --- app/src/main/jni/netguard/netguard.h | 3 +-- app/src/main/jni/netguard/tcp.c | 38 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/app/src/main/jni/netguard/netguard.h b/app/src/main/jni/netguard/netguard.h index bb7ca891e..afb26760b 100644 --- a/app/src/main/jni/netguard/netguard.h +++ b/app/src/main/jni/netguard/netguard.h @@ -75,9 +75,8 @@ #define SOCKS5_CONNECT 4 #define SOCKS5_CONNECTED 5 -// SNI interception states - used to catch TLS SNI without connecting to remote server +// SNI interception state - used to catch TLS SNI without connecting to remote server #define SNI_PENDING 1 // Waiting for ClientHello with SNI -#define SNI_RECEIVED 2 // SNI extracted, ready to check or connect struct context { pthread_mutex_t lock; diff --git a/app/src/main/jni/netguard/tcp.c b/app/src/main/jni/netguard/tcp.c index 513f9c277..e66cab8ca 100644 --- a/app/src/main/jni/netguard/tcp.c +++ b/app/src/main/jni/netguard/tcp.c @@ -788,10 +788,17 @@ jboolean handle_tcp(const struct arguments *args, s->tcp.time = time(NULL); s->tcp.local_seq++; // local SYN s->tcp.state = TCP_SYN_RECV; + + // Add session to list only on success + s->next = args->ctx->ng_session; + args->ctx->ng_session = s; + } else { + // Failed to send fake SYN-ACK, clean up + log_android(ANDROID_LOG_ERROR, "%s failed to send fake SYN-ACK", packet); + clear_tcp_data(&s->tcp); + ng_free(s, __FILE__, __LINE__); + return 0; } - - s->next = args->ctx->ng_session; - args->ctx->ng_session = s; } else { // Normal flow: open socket immediately s->socket = open_tcp_socket(args, &s->tcp, redirect); @@ -894,16 +901,10 @@ jboolean handle_tcp(const struct arguments *args, cur->socket, get_local_port(cur->socket)); // Queue the ClientHello data to be sent when socket connects + // Note: queue_tcp uses the TCP sequence number from tcphdr + // remote_seq should NOT be updated here - it will be updated when data is forwarded queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); - // Update remote_seq to account for received data - cur->tcp.remote_seq += datalen; - - // Send ACK for the received data - if (write_ack(args, &cur->tcp) < 0) { - return 0; - } - // Monitor events for the new socket memset(&cur->ev, 0, sizeof(struct epoll_event)); cur->ev.events = EPOLLOUT | EPOLLERR; @@ -912,16 +913,17 @@ jboolean handle_tcp(const struct arguments *args, log_android(ANDROID_LOG_ERROR, "epoll add tcp error %d: %s (after SNI)", errno, strerror(errno)); - // Reset to normal flow - socket will complete handshake - cur->tcp.state = TCP_LISTEN; // Reset to LISTEN to wait for socket connect - cur->tcp.sni_state = 0; // Clear SNI state + // Clear SNI state - connection will proceed as normal + // State remains TCP_ESTABLISHED, queued data will be sent when socket connects + // remote_seq will be updated and ACK sent when data is forwarded in check_tcp_socket + cur->tcp.sni_state = 0; return 1; } - // Queue data to forward + // Queue data to forward (only for non-SNI_PENDING sessions) if (datalen) { - if (cur->socket < 0 && cur->tcp.sni_state != SNI_PENDING) { + if (cur->socket < 0) { log_android(ANDROID_LOG_ERROR, "%s data while local closed", session); write_rst(args, &cur->tcp); return 0; @@ -931,9 +933,7 @@ jboolean handle_tcp(const struct arguments *args, write_rst(args, &cur->tcp); return 0; } - if (cur->tcp.sni_state != SNI_PENDING) { - queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); - } + queue_tcp(args, tcphdr, session, &cur->tcp, data, datalen); } if (tcphdr->rst /* +ACK */) { From a874282ecd877823aeb80b69cd5b789dad02b5e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:38:11 +0000 Subject: [PATCH 4/5] No new changes - final review Co-authored-by: kasnder <5175206+kasnder@users.noreply.github.com> --- _codeql_detected_source_root | 1 + 1 file changed, 1 insertion(+) create mode 120000 _codeql_detected_source_root diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 000000000..6902ac805 --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +./app \ No newline at end of file From ad00412d34dbccb129388c20094834d30bda14e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:38:36 +0000 Subject: [PATCH 5/5] Remove CodeQL artifact and add to gitignore Co-authored-by: kasnder <5175206+kasnder@users.noreply.github.com> --- .gitignore | 3 +++ _codeql_detected_source_root | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 120000 _codeql_detected_source_root diff --git a/.gitignore b/.gitignore index bc8013157..bfdf091f9 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,6 @@ lint/tmp/ .DS_Store /crowdin.properties + +# CodeQL temporary files +_codeql_detected_source_root diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root deleted file mode 120000 index 6902ac805..000000000 --- a/_codeql_detected_source_root +++ /dev/null @@ -1 +0,0 @@ -./app \ No newline at end of file