fix(ios-vpn): K1/K2/H1 — Upstream-Fehler, In-Flight-Cap, Response-Layout

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-21 23:22:22 +02:00
parent dfcee68dc8
commit 1aa86c2c0c
2 changed files with 263 additions and 58 deletions

View File

@ -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: "<unparsebar>")
}
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<UInt8>, 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<UInt8>, 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<UInt8>, 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<UInt8>, length: Int, ihl: Int, rcode: UInt8
) -> Data {
var resp = [UInt8](repeating: 0, count: length)
for i in 0..<length { resp[i] = bytes[i] }
let udpStart = ihl
let dnsStart = udpStart + 8
// K1: Ende der Question-Section bestimmen Paket dahinter abschneiden.
let respLen: Int
if let qEnd = dnsQuestionEnd(bytes, dnsStart: dnsStart, length: length) {
respLen = qEnd
} else {
// Fallback: ungeschnitten (Pointer/OOB) markiert als ungeprüfter Pfad.
respLen = length
}
var resp = [UInt8](repeating: 0, count: respLen)
for i in 0..<respLen { resp[i] = bytes[i] }
// IP total-length (Bytes 2..3) auf die neue, evtl. gekürzte Länge setzen.
resp[2] = UInt8((respLen >> 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=<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-

View File

@ -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