From 1aa86c2c0c6910626697a0311b9b8a5a2446eeac Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 21 May 2026 23:22:22 +0200 Subject: [PATCH] =?UTF-8?q?fix(ios-vpn):=20K1/K2/H1=20=E2=80=94=20Upstream?= =?UTF-8?q?-Fehler,=20In-Flight-Cap,=20Response-Layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K2: forwardPacket schreibt jetzt bei jedem Upstream-Fehler eine synthetische SERVFAIL ins TUN (zentrale finish(gotAnswer:)) — DNS-Client haengt nicht mehr. H1: harter In-Flight-Cap (32) statt Connection-pro-Query — ueber dem Cap sofort SERVFAIL; deckelt den NE-Memory-Footprint. K1: buildErrorResponse + dnsQuestionEnd schneiden das Paket hinter der Question- Section ab (EDNS-OPT-Muell weg), IP/UDP-Length neu berechnet. H3: Compression-Pointer-QNAME → fail-open (.forward) statt stillem Drop. Co-Authored-By: Claude Opus 4.7 --- .../DnsFilter.swift | 146 +++++++++++++-- .../PacketTunnelProvider.swift | 175 ++++++++++++++---- 2 files changed, 263 insertions(+), 58 deletions(-) diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift index 3255096..3c9f1ca 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift @@ -7,6 +7,8 @@ - Block-Query → synthetische NXDOMAIN-Response (RCODE=3), synchron gebaut. - Allow-Query → an Upstream-Resolver (1.1.1.1:53) forwarden, Response wird wieder als IPv4/UDP-Paket verpackt zurückgegeben. + - Upstream-Fehler → synthetische SERVFAIL-Response (RCODE=2), damit der + Client schnell failt statt in seinen Timeout zu laufen. - Drop (kein DNS / IPv6 / Nicht-UDP) → nil, das Paket wird verworfen. Unterschied zu Android: Android schreibt selbst ins TUN (FileOutputStream). @@ -82,12 +84,20 @@ enum DnsFilter { guard dnsLen >= 12 else { return .drop } // QNAME parsen — beginnt nach dem 12-Byte-DNS-Header. + // H3-Fix: `parseQname` liefert `nil`, wenn die QNAME nicht parsebar ist + // (Compression-Pointer in der Frage oder Out-of-Bounds). Früher führte + // das zu `.drop` → die Auflösung schlug für den Client stillschweigend + // fehl. Korrekt ist FAIL-OPEN: solche Pakete an den Upstream forwarden, + // damit reguläre Namensauflösung nicht heimlich kaputtgeht. Ein + // unparsebares QNAME bedeutet nur, dass WIR es nicht matchen können — + // nicht, dass die Query verworfen werden darf. guard let domain = parseQname(bytes, start: dnsStart + 12, end: length) else { - return .drop + return .forward(domain: "") } if hashList.matchesAnySuffix(domain) { - let response = buildNxDomainResponse(bytes, length: length, ihl: ihl) + let response = buildErrorResponse( + bytes, length: length, ihl: ihl, rcode: 3 /* NXDOMAIN */) return .block(response: response, domain: domain) } return .forward(domain: domain) @@ -97,7 +107,11 @@ enum DnsFilter { // ─── QNAME-Parser ────────────────────────────────────────────────────────── /// Parst den QNAME ab `start`. Gibt `nil` zurück bei DNS-Compression-Pointer - /// (0xC0-Bit gesetzt) oder Out-of-Bounds — identisch zu Androids `parseQname`. + /// (0xC0-Bit gesetzt) oder Out-of-Bounds. + /// + /// `nil` heißt NICHT „verwerfen": der Aufrufer (`classify`) behandelt `nil` + /// als FAIL-OPEN und forwardet die Query an den Upstream (H3-Fix). Ein + /// nicht-parsebares QNAME darf nie zum stillen Verlust der Auflösung führen. private static func parseQname( _ bytes: UnsafeBufferPointer, start: Int, end: Int ) -> String? { @@ -106,8 +120,8 @@ enum DnsFilter { while pos < end { let len = Int(bytes[pos]) & 0xFF if len == 0 { break } - // Compression-Pointer (zwei höchste Bits) — in einer Query unüblich, - // wir behandeln ihn defensiv als nicht-parsebar. + // Compression-Pointer (zwei höchste Bits) — in einer Query unüblich. + // Nicht parsebar → nil → Aufrufer forwardet fail-open (H3-Fix). if (len & 0xC0) != 0 { return nil } pos += 1 guard pos + len <= end else { return nil } @@ -123,19 +137,83 @@ enum DnsFilter { return host.isEmpty ? nil : host } - // ─── NXDOMAIN-Response-Builder ───────────────────────────────────────────── + // ─── Question-Section-Ende ───────────────────────────────────────────────── - /// Baut die synthetische NXDOMAIN-Response aus der Request-Kopie. - /// Logik 1:1 aus `DnsFilter.kt#buildNxDomainResponse`: + /// Ermittelt den Byte-Offset DIREKT HINTER der Question-Section, gemessen ab + /// dem Paket-Anfang (nicht ab `dnsStart`). + /// + /// Layout DNS-Question: QNAME (variabel) + QTYPE (2 B) + QCLASS (2 B). + /// QNAME ist eine Folge von Längen-prefixed Labels, terminiert durch ein + /// 0x00-Byte. Ein Compression-Pointer (0xC0-Bit) kommt in einer Query + /// normalerweise nicht vor — taucht er auf, geben wir `nil` zurück. + /// + /// Gibt `nil` zurück, wenn die Question-Section out-of-bounds läuft oder + /// einen Pointer enthält. Der Aufrufer darf das Paket dann NICHT abschneiden + /// und sollte fail-open forwarden. + private static func dnsQuestionEnd( + _ bytes: UnsafeBufferPointer, dnsStart: Int, length: Int + ) -> Int? { + // QNAME beginnt nach dem 12-Byte-DNS-Header. + var pos = dnsStart + 12 + while pos < length { + let len = Int(bytes[pos]) & 0xFF + if len == 0 { + // 0x00 terminiert die QNAME. Danach QTYPE(2) + QCLASS(2). + let end = pos + 1 + 4 + guard end <= length else { return nil } + return end + } + // Compression-Pointer in einer Query → nicht abschneidbar. + if (len & 0xC0) != 0 { return nil } + pos += 1 + len + } + return nil + } + + // ─── DNS-Error-Response-Builder (NXDOMAIN / SERVFAIL) ────────────────────── + + /// Baut eine synthetische DNS-Error-Response aus der Request-Kopie. + /// Generischer Builder für NXDOMAIN (RCODE=3) und SERVFAIL (RCODE=2). + /// + /// Logik (Basis aus Androids `DnsFilter.kt#buildNxDomainResponse`): /// - Src-/Dst-IP tauschen, Src-/Dst-Port tauschen. - /// - DNS-Flags auf 0x8183 setzen (QR=1, RD=1, RA=1, RCODE=3 NXDOMAIN). + /// - DNS-Flags: QR=1, RD=1, RA=1, OPCODE/AA/TC=0, RCODE = Parameter. /// - Answer/Authority/Additional-Counts auf 0. /// - IP-Checksum neu berechnen, UDP-Checksum auf 0 (optional in IPv4). - private static func buildNxDomainResponse( - _ bytes: UnsafeBufferPointer, length: Int, ihl: Int + /// + /// K1-Fix: Das Response-Paket wird NACH dem Ende der Question-Section + /// abgeschnitten. Früher kopierte der Builder das gesamte Request-Paket und + /// nullte nur ARCOUNT — ein evtl. vorhandener EDNS-OPT-Record blieb dann + /// physisch als Müll-Bytes hinter der Question-Section stehen, obwohl + /// ARCOUNT=0 sagt „keine Additional-Records". Manche Resolver/Clients + /// verwerfen so eine Response als korrupt. Jetzt: Paket auf + /// IP-Header + UDP-Header + DNS-Header + Question-Section gekürzt, + /// IP-Total-Length und UDP-Length entsprechend neu berechnet. + /// + /// Kann die Question-Section nicht sicher vermessen werden (Pointer / + /// Out-of-Bounds), fällt der Builder auf das ungeschnittene Paket zurück + /// (besser eine evtl. „dreckige" Response als gar keine). + private static func buildErrorResponse( + _ bytes: UnsafeBufferPointer, length: Int, ihl: Int, rcode: UInt8 ) -> Data { - var resp = [UInt8](repeating: 0, count: length) - for i in 0..> 8) & 0xFF) + resp[3] = UInt8(respLen & 0xFF) // Src-IP (12..15) und Dst-IP (16..19) tauschen. for i in 0..<4 { @@ -149,7 +227,6 @@ enum DnsFilter { resp[11] = 0 // UDP-Ports tauschen. - let udpStart = ihl let srcPort0 = bytes[udpStart] let srcPort1 = bytes[udpStart + 1] let dstPort0 = bytes[udpStart + 2] @@ -158,14 +235,19 @@ enum DnsFilter { resp[udpStart + 1] = dstPort1 resp[udpStart + 2] = srcPort0 resp[udpStart + 3] = srcPort1 + // UDP-Length (Bytes 4..5) = 8 (Header) + DNS-Payload-Länge. + let udpLen = respLen - udpStart + resp[udpStart + 4] = UInt8((udpLen >> 8) & 0xFF) + resp[udpStart + 5] = UInt8(udpLen & 0xFF) // UDP-Checksum auf 0 (in IPv4 erlaubt, Empfänger überspringt die Prüfung). resp[udpStart + 6] = 0 resp[udpStart + 7] = 0 - // DNS-Flags: 0x8183 = QR=1 OPCODE=0 AA=0 TC=0 RD=1 RA=1 RCODE=3 (NXDOMAIN). - let dnsStart = udpStart + 8 + // DNS-Flags: QR=1 OPCODE=0 AA=0 TC=0 RD=1 | RA=1 Z=0 RCODE=. + // High-Byte 0x81 = QR=1, RD=1. + // Low-Byte 0x80 | rcode = RA=1, RCODE = 3 (NXDOMAIN) bzw. 2 (SERVFAIL). resp[dnsStart + 2] = 0x81 - resp[dnsStart + 3] = 0x83 + resp[dnsStart + 3] = 0x80 | (rcode & 0x0F) // ANCOUNT / NSCOUNT / ARCOUNT (Bytes 6..11 im DNS-Header) auf 0 — // QDCOUNT (Bytes 4..5) bleibt unangetastet, die Frage spiegeln wir zurück. for i in 6...11 { @@ -176,6 +258,36 @@ enum DnsFilter { return Data(resp) } + // ─── SERVFAIL-Response aus einem rohen Request-Paket (K2) ────────────────── + + /// Baut eine SERVFAIL-Response (RCODE=2) direkt aus einem rohen + /// IPv4/UDP/DNS-Request-Paket — für den Fall, dass das Forwarding an den + /// Upstream-Resolver fehlschlägt (Timeout, recvError, leere Antwort, + /// Connection `.failed`). + /// + /// K2-Fix: Ohne diese Response bekäme der DNS-Client des iPhones bei jedem + /// kurzen Upstream-Hänger GAR NICHTS zurück und liefe in seinen eigenen, + /// mehrsekündigen Timeout → die komplette Namensauflösung des Geräts steht. + /// Mit einer sofortigen SERVFAIL-Response failt der Client schnell und kann + /// retryen / den nächsten Resolver fragen. + /// + /// Nutzt exakt dieselbe Paket-Bau-Logik wie die NXDOMAIN-Response inkl. des + /// K1-Abschneidens hinter der Question-Section. + /// + /// Gibt `nil` zurück, wenn das Request-Paket kein valides IPv4/UDP-Paket ist. + static func buildServFailResponse(request: Data) -> Data? { + let length = request.count + guard length >= 28 else { return nil } + return request.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Data? in + let bytes = raw.bindMemory(to: UInt8.self) + let version = (Int(bytes[0]) >> 4) & 0xF + guard version == 4 else { return nil } + let ihl = (Int(bytes[0]) & 0xF) * 4 + guard ihl >= 20, length >= ihl + 8 + 12 else { return nil } + return buildErrorResponse(bytes, length: length, ihl: ihl, rcode: 2 /* SERVFAIL */) + } + } + // ─── Forward-Response-Builder ────────────────────────────────────────────── /// Verpackt die rohe DNS-Antwort des Upstream-Resolvers wieder als IPv4/UDP- diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift index 04dd096..0850a11 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift @@ -15,6 +15,10 @@ zurückgeschrieben. - Miss → Query an Upstream-Resolver (1.1.1.1:53) via `NWConnection`, Response wieder als IPv4/UDP-Paket verpackt und ins TUN geschrieben. + - Upstream-Fehler (Timeout / recvError / leere Antwort / Connection failed) + oder In-Flight-Cap überschritten → synthetische SERVFAIL-Response ins TUN, + damit der DNS-Client des Geräts schnell failt statt in seinen eigenen + mehrsekündigen Timeout zu laufen. Blocklist: `blocklist.bin` aus dem App-Group-Container, mmap'd via `HashList`. `syncBlocklist` (Haupt-App) postet die Darwin-Notification @@ -82,6 +86,39 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Ist der Read-Loop aktiv? private var running = false + // ─── In-Flight-Cap (H1) ───────────────────────────────────────────────────── + // + // H1-Fix: Pro erlaubter Query wurde bisher eine frische `NWConnection` + // geöffnet (erst nach Antwort/Timeout bis 4 s gecancelt). Bei DNS-Bursts + // (App-Start, Webseite mit vielen Subresources) liefen so Dutzende + // gleichzeitige Connections + Timeout-WorkItems an — NE-Packet-Tunnel- + // Extensions haben aber ein hartes Memory-Limit (~15 MB) → Jetsam-Crash. + // + // Gewählte Variante: harter In-Flight-CAP statt Connection-Pooling. + // Begründung: + // - UDP-`NWConnection`-Pooling auf einen einzigen Socket ist fehleranfällig: + // ein gemeinsamer UDP-Socket vermischt Antworten mehrerer paralleler + // Queries — korrektes Demultiplexing bräuchte ein eigenes Pending-Mapping + // über die DNS-Transaction-ID. Das ist mehr Zustand (= mehr Speicher) und + // mehr Crash-Fläche als das Problem rechtfertigt. + // - Ein simpler Zähler ist O(1)-Zustand und deckelt den Speicher hart. + // - DNS ist von sich aus retry-tolerant: wird der Cap überschritten, + // antworten wir der Query SOFORT mit SERVFAIL (K2-Pfad) — der Client + // retryt dann von selbst, sobald der Burst abgeebbt ist. Besser ein + // schneller SERVFAIL als ein Extension-Crash, der den ganzen Tunnel + // (= den Schutz) reißt. + // + // UNGETESTETE ANNAHME: Der konkrete Cap-Wert (32) ist eine konservative + // Schätzung. Eine `NWConnection` + Timeout-WorkItem kostet grob einige KB; + // 32 gleichzeitige Verbindungen bleiben damit klar unter dem ~15-MB-Limit, + // decken aber typische Burst-Größen ab. Auf Gerät unter Last verifizieren + // (Instruments / Memory-Graph der Extension). + private static let maxInFlightUpstream = 32 + + /// Zähler aktuell offener Upstream-Connections. Nur auf `forwardQueue` + /// gelesen/geschrieben → keine zusätzliche Synchronisation nötig. + private var inFlightUpstream = 0 + // ─── startTunnel ──────────────────────────────────────────────────────────── /// Vom System aufgerufen, sobald `connection.startVPNTunnel()` (aus dem @@ -257,56 +294,112 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - let connection = NWConnection(host: host, port: port, using: .udp) - var didFinish = false + // Gesamte Connection-Lebenszeit läuft auf `forwardQueue` — damit ist auch + // der `inFlightUpstream`-Zähler ohne extra Lock konsistent. + forwardQueue.async { [weak self] in + guard let self = self, self.running else { return } - // Timeout — wie Androids `soTimeout = 4000`. - let timeoutWork = DispatchWorkItem { - if !didFinish { - didFinish = true - connection.cancel() + // ── H1: In-Flight-Cap ── + // Burst über dem Cap → keine neue Connection öffnen, sondern die Query + // sofort mit SERVFAIL beantworten. Der Client retryt selbst; der + // Speicher der Extension bleibt gedeckelt. + if self.inFlightUpstream >= PacketTunnelProvider.maxInFlightUpstream { + ExtLog.write("⚠️ Upstream-Cap erreicht — SERVFAIL (in-flight=\(self.inFlightUpstream))") + self.replyServFail(for: packet) + return } - } - forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: timeoutWork) - connection.stateUpdateHandler = { state in - switch state { - case .ready: - connection.send(content: query, completion: .contentProcessed { sendError in - if sendError != nil { - if !didFinish { - didFinish = true - timeoutWork.cancel() - connection.cancel() - } - return - } - // Antwort empfangen. - connection.receiveMessage { [weak self] data, _, _, recvError in - if didFinish { return } - didFinish = true - timeoutWork.cancel() - defer { connection.cancel() } - guard recvError == nil, let answer = data, !answer.isEmpty else { + let connection = NWConnection(host: host, port: port, using: .udp) + var didFinish = false + self.inFlightUpstream += 1 + + // Timeout-WorkItem — vorab deklariert, damit `finish()` es referenzieren + // kann; die eigentliche Item-Closure wird gleich darunter zugewiesen. + var timeoutWork: DispatchWorkItem? + + // Zentrale Abschluss-Logik: idempotent, dekrementiert den Cap-Zähler + // genau einmal und schreibt — wenn keine echte Antwort kam — eine + // SERVFAIL-Response ins TUN (K2). `gotAnswer = true` heißt: es wurde + // bereits eine valide Upstream-Antwort geschrieben. + func finish(gotAnswer: Bool) { + // Läuft immer auf `forwardQueue` (alle Aufrufer tun das). + if didFinish { return } + didFinish = true + timeoutWork?.cancel() + connection.cancel() + self.inFlightUpstream -= 1 + if !gotAnswer { + // K2: Upstream lieferte nichts Brauchbares → Client nicht hängen + // lassen, sondern schnell mit SERVFAIL failen. + self.replyServFail(for: packet) + } + } + + // Timeout — wie Androids `soTimeout = 4000`. Bei Ablauf: SERVFAIL. + let work = DispatchWorkItem { + ExtLog.write("Upstream-Timeout — SERVFAIL") + finish(gotAnswer: false) + } + timeoutWork = work + self.forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: work) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.send(content: query, completion: .contentProcessed { sendError in + if sendError != nil { + // Send fehlgeschlagen → SERVFAIL. + self.forwardQueue.async { finish(gotAnswer: false) } return } - // Antwort als IPv4/UDP-Paket verpacken und ins TUN schreiben. - if let response = DnsFilter.buildForwardResponse( - request: packet, dnsAnswer: answer) { - self?.writeToTun(response) + // Antwort empfangen. + connection.receiveMessage { [weak self] data, _, _, recvError in + guard let self = self else { return } + self.forwardQueue.async { + if didFinish { return } + guard recvError == nil, let answer = data, !answer.isEmpty, + let response = DnsFilter.buildForwardResponse( + request: packet, dnsAnswer: answer) else { + // recvError / leere / unverpackbare Antwort → SERVFAIL (K2). + finish(gotAnswer: false) + return + } + // Echte Antwort als IPv4/UDP-Paket ins TUN schreiben. + self.writeToTun(response) + finish(gotAnswer: true) + } } - } - }) - case .failed, .cancelled: - if !didFinish { - didFinish = true - timeoutWork.cancel() + }) + case .failed: + // Connection endgültig fehlgeschlagen (z.B. kein Netz) → SERVFAIL + // statt den Client hängen zu lassen (K2). + self.forwardQueue.async { finish(gotAnswer: false) } + case .cancelled: + // `cancelled` ist Folge eines bereits gelaufenen finish() — + // finish() ist idempotent, hier ist nichts mehr zu tun. + break + case .waiting: + // `.waiting` (kein Pfad) ist NICHT zwingend final — die Connection + // kann sich erholen, sobald Konnektivität zurückkommt. Wir brechen + // hier deshalb NICHT sofort ab, sondern überlassen es dem 4-s- + // Timeout (der dann SERVFAIL liefert). Ein vorzeitiger Abbruch bei + // kurzem Netz-Flackern wäre unnötig aggressiv. + break + default: + break } - default: - break } + connection.start(queue: self.forwardQueue) + } + } + + /// Synthetisiert eine SERVFAIL-Response für ein Request-Paket und schreibt + /// sie ins TUN. K2: gemeinsamer Fehler-Pfad für Timeout, recvError, leere + /// Antwort, Connection-`.failed` und den H1-Cap. + private func replyServFail(for requestPacket: Data) { + if let response = DnsFilter.buildServFailResponse(request: requestPacket) { + writeToTun(response) } - connection.start(queue: forwardQueue) } // ─── TUN-Write ──────────────────────────────────────────────────────────────