Härtung: REST-Query-Injection-Parität + TOFU-Fingerprint-Fallback
Dritter, sicherheitsfokussierter Deep-Dive-Durchgang ("maximale Sicherheit"):
- RestTransport.fetchFieldValues hatte dieselbe ungeschützte
String-Interpolation wie das SSH-Pendant aus dem vorigen Fix, nur als
URL-Query statt CLI-Zeile - beim ersten Fix übersehen. Jetzt
RFC-3986-konform percent-encoded.
- CertificateFingerprint.sha256 fiel bei Extraktionsfehlern auf einen
festen String "unbekannt" zurück statt echtem Fingerabdruck -
theoretisches TOFU-Pinning-Bypass-Fenster (zwei verschiedene,
extraktions-fehlschlagende Zertifikate hätten sich denselben
"Fingerabdruck" geteilt). Rückgabetyp optional, Extraktionsfehler
führt jetzt zu hartem Verbindungsabbruch statt Trust-Dialog.
Beide Fixes defensiv/gehärtet, nicht live exploitiert. Zugangsdaten-
Speicherung (Keychain) und BackupServices eigene Escaping-Logik
gegengeprüft - bereits korrekt. Build + alle 101 Unit-Tests grün.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,19 @@ import Security
|
||||
import CryptoKit
|
||||
|
||||
enum CertificateFingerprint {
|
||||
static func sha256(of trust: SecTrust) -> String {
|
||||
/// `nil` means the certificate chain couldn't be read at all — the caller must treat that as
|
||||
/// a hard connection failure, never as a fingerprint to compare or offer for trust. The
|
||||
/// previous version returned the fixed string `"unbekannt"` in that case, which — however
|
||||
/// unlikely to trigger in practice — meant every certificate that failed extraction shared
|
||||
/// the exact same "fingerprint", so trusting one such (extraction-failed) certificate would
|
||||
/// have silently trusted every other one too, defeating TOFU pinning for that host. bugs.md,
|
||||
/// "maximale Sicherheit"-Durchgang, 2026-09-17 — hardening, not a live-reproduced exploit.
|
||||
static func sha256(of trust: SecTrust) -> String? {
|
||||
guard
|
||||
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let leaf = chain.first
|
||||
else {
|
||||
return "unbekannt"
|
||||
return nil
|
||||
}
|
||||
let data = SecCertificateCopyData(leaf) as Data
|
||||
let digest = SHA256.hash(data: data)
|
||||
|
||||
@@ -104,14 +104,31 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
/// property filters (`?field=value`), mirroring the console's `find field=value` — used here
|
||||
/// on the same assumption, not confirmed for this exact property. Unlike SSH, REST's GET
|
||||
/// already returns full objects, so no separate id-overlay is involved here at all.
|
||||
///
|
||||
/// `whereValue` is percent-encoded before landing in the query string — same defensive
|
||||
/// reasoning as `SSHTransport.fetchFieldValues`'s CLI-escaping (see `RouterOSCommand`'s
|
||||
/// `quoteIfNeeded` doc comment for the injection class this class of fix addresses): both
|
||||
/// current callers only ever pass the hardcoded literal `"no"`, but an unencoded value
|
||||
/// containing `&` could inject an additional, attacker-chosen query parameter into this
|
||||
/// request for any future caller passing real (e.g. device-controlled) text.
|
||||
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
|
||||
let data = try await send(path: "\(restPath)?\(whereField)=\(whereValue)", method: "GET", jsonBody: nil)
|
||||
let encodedValue = whereValue.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowedCharacters) ?? whereValue
|
||||
let data = try await send(path: "\(restPath)?\(whereField)=\(encodedValue)", method: "GET", jsonBody: nil)
|
||||
guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
return Set(array.compactMap { $0[returnField] as? String })
|
||||
}
|
||||
|
||||
/// RFC 3986 "unreserved characters" only — deliberately stricter than `.urlQueryAllowed`,
|
||||
/// which still permits `&`/`=`/`+`/`#` (valid query-string bytes in general, but exactly the
|
||||
/// characters that let an encoded value be misread as introducing a second parameter).
|
||||
private static let queryValueAllowedCharacters: CharacterSet = {
|
||||
var set = CharacterSet.alphanumerics
|
||||
set.insert(charactersIn: "-._~")
|
||||
return set
|
||||
}()
|
||||
|
||||
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
|
||||
var fields: [String: String] = [:]
|
||||
var id = ""
|
||||
@@ -258,7 +275,14 @@ extension RestTransport: URLSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let fingerprint = CertificateFingerprint.sha256(of: serverTrust)
|
||||
// `nil` (certificate chain unreadable) is always a hard rejection — never routed through
|
||||
// `lastRejectedFingerprint`/`untrustedCertificate`, since that flow ends in a dialog
|
||||
// offering to trust this exact fingerprint, and there is no reliable fingerprint to trust
|
||||
// here (see `CertificateFingerprint.sha256`'s doc comment).
|
||||
guard let fingerprint = CertificateFingerprint.sha256(of: serverTrust) else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
if certificateTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) {
|
||||
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
||||
} else {
|
||||
|
||||
@@ -192,6 +192,51 @@ Produktentscheidung ist, keine reine Korrektur. Nur als Beobachtung vermerkt.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-17 (Nachtrag 2: "finale Test für maximale Sicherheit")
|
||||
|
||||
Dritter Durchgang, gezielt sicherheitsfokussiert: Zugangsdaten-Speicherung (Keychain, SavedRoutersStore),
|
||||
TOFU-Implementierung (Zertifikat/SSH-Hostkey), alle verbleibenden Stellen, die Text in Router-Befehle
|
||||
oder URLs interpolieren. Positiv: `KeychainService` speichert Passwörter korrekt im macOS-Keychain
|
||||
(nie Klartext/UserDefaults), `SavedRoutersStore`/`SavedRouter` enthalten kein Passwort-Feld,
|
||||
`BackupService.escapeForRouterOSScript` escapte bereits vor diesem Durchgang korrekt (unabhängig von
|
||||
Fund #5 — offenbar aus einem früheren Lockout-Vorfall gelernt), `SSHHostKeyFingerprint` hat keine
|
||||
Fallback-Schwäche. Zwei weitere Lücken gefunden und gehärtet (defensiv, nicht live exploitiert):
|
||||
|
||||
### 8. REST-Pendant zu Fund #5: ungeschützte Query-String-Interpolation
|
||||
**Status:** fixed (Build grün, nicht live exploitiert — aktuell nur mit hartkodiertem Wert aufgerufen)
|
||||
**Confidence:** hoch (dieselbe Bugklasse wie #5, nur im REST- statt SSH-Pfad)
|
||||
|
||||
`RestTransport.fetchFieldValues` baute den Query-String `"\(restPath)?\(whereField)=\(whereValue)"`
|
||||
ohne jede Kodierung — beim SSH-Gegenstück wurde das in Fund #5 bereits gefixt, das REST-Pendant
|
||||
dabei übersehen. Ein `whereValue` mit `&` hätte einen zusätzlichen Query-Parameter einschleusen
|
||||
können. Aktuell nur mit hartkodiertem `"no"` aufgerufen (kein Live-Exploit möglich), aber generische
|
||||
`RouterOSTransport`-Methode — Härtung für zukünftige Aufrufer mit echtem Nutzer-/Gerätetext.
|
||||
|
||||
**Fix:** `whereValue` wird jetzt RFC-3986-"unreserved characters"-only percent-encoded (strenger
|
||||
als `.urlQueryAllowed`, das `&`/`=`/`+`/`#` weiterhin durchlässt).
|
||||
|
||||
### 9. TOFU-Zertifikatsprüfung hatte einen Fallback-Konstante-Blindfleck
|
||||
**Status:** fixed (Build grün, Härtung — kein realistisch auslösbarer Live-Exploit gefunden)
|
||||
**Confidence:** mittel (theoretische Lücke im Code nachvollzogen, keine funktionierende PoC gebaut,
|
||||
da eine echte TLS-Handshake-Situation gebraucht würde, in der `SecTrustCopyCertificateChain` trotz
|
||||
abgeschlossenem Handshake leer zurückkommt — unüblich, aber laut Apple-API-Vertrag nicht ausgeschlossen)
|
||||
|
||||
`CertificateFingerprint.sha256(of:)` gab bei fehlgeschlagener Zertifikatsketten-Extraktion den festen
|
||||
String `"unbekannt"` statt eines echten Fingerabdrucks zurück. Da `CertificateTrustStore.isTrusted`
|
||||
Fingerabdrücke per exaktem String-Vergleich prüft, hätte das Vertrauen in EIN Zertifikat, dessen
|
||||
Extraktion fehlschlägt, automatisch JEDES andere ebenfalls extraktions-fehlschlagende Zertifikat für
|
||||
denselben Host als vertrauenswürdig durchgehen lassen — ein theoretisches TOFU-Pinning-Bypass-Fenster.
|
||||
|
||||
**Fix:** Rückgabetyp auf `String?` geändert; schlägt die Extraktion fehl, lehnt
|
||||
`RestTransport.urlSession(_:didReceive:completionHandler:)` die Challenge jetzt hart ab
|
||||
(`.cancelAuthenticationChallenge`, kein `lastRejectedFingerprint` gesetzt) — der Nutzer bekommt in
|
||||
diesem Fall nie einen "Zertifikat vertrauen?"-Dialog mit einer nicht verifizierbaren Kennung
|
||||
angeboten, sondern einen normalen Verbindungsfehler.
|
||||
|
||||
Build + alle 101 Unit-Tests grün nach beiden Fixes.
|
||||
|
||||
---
|
||||
|
||||
## Noch nicht geprüft / außerhalb dieses Durchgangs
|
||||
|
||||
- UI-Interaktion selbst (Klickpfade, Darstellung) — nicht automatisierbar, siehe Hinweis oben.
|
||||
|
||||
@@ -275,3 +275,23 @@ diesmal mit echten Exploit-Versuchen gegen den Router statt nur Code-Lesen:
|
||||
Bugfix).
|
||||
|
||||
Build grün, alle 101 Unit-Tests grün (2 neue Regressionstests). Details in `bugs.md`.
|
||||
|
||||
### 13. Dritter Durchgang: gezielte Sicherheitshärtung (bugs.md #8-#9, 2026-09-17)
|
||||
**Status:** fixed (2 Härtungsfixes, keine weiteren Live-Exploits gefunden)
|
||||
|
||||
Auf Nutzerwunsch ("finale Test für maximale Sicherheit, test alles was du finden kannst") gezielt
|
||||
Zugangsdaten-Speicherung und TOFU-Mechanismen geprüft. Positiv bestätigt: Keychain-Nutzung korrekt,
|
||||
keine Passwörter in UserDefaults/JSON, `BackupService`s eigene Skript-Escaping-Logik war bereits
|
||||
vor diesem Durchgang korrekt. Zwei Härtungslücken gefunden und geschlossen (beide defensiv, nicht
|
||||
live exploitiert — anders als #5/#6 im vorigen Durchgang):
|
||||
|
||||
- **#8**: `RestTransport.fetchFieldValues` hatte dieselbe ungeschützte String-Interpolation wie das
|
||||
SSH-Pendant aus Fund #5, nur als URL-Query-String statt CLI-Zeile — beim ersten Fix übersehen.
|
||||
Jetzt RFC-3986-konform percent-encoded.
|
||||
- **#9**: `CertificateFingerprint.sha256` fiel bei Extraktionsfehlern auf einen festen String
|
||||
"unbekannt" zurück statt einen echten Fingerabdruck zu liefern — theoretisches TOFU-Bypass-Fenster
|
||||
(zwei verschiedene, beide extraktions-fehlschlagende Zertifikate hätten sich denselben
|
||||
"Fingerabdruck" geteilt). Rückgabetyp auf optional geändert, Extraktionsfehler führt jetzt zu
|
||||
hartem Verbindungsabbruch statt einem Vertrauens-Dialog mit unverifizierbarer Kennung.
|
||||
|
||||
Build grün, alle 101 Unit-Tests grün. Details in `bugs.md`.
|
||||
|
||||
Reference in New Issue
Block a user