Fix live exploitierte RouterOS-CLI-Injection + Parser-Datenverlust

Deep-Dive-Gegencheck mit echtem Exploit-Nachweis gegen den Testrouter:

- RouterOSCommand.cliLine quotete Werte nur bei Leerzeichen und escapte
  eingebettete Anführungszeichen nie. Ein Kommentar wie test" ; :log
  warning "X schloss das Quoting vorzeitig und ließ RouterOS den Rest
  als zweiten Befehl ausführen. Live exploitiert (injizierter
  script,warning-Log-Eintrag) und live als behoben bestätigt. Betraf
  jede Schreiboperation über SSH - auf dem aktuellen Testrouter ist
  www-ssl deaktiviert, REST also unerreichbar, der Bug war aktiv.
- RouterOSCliParser.keyValues nahm an, print terse quote mehrwortige
  Werte - live an zwei Menüs widerlegt (RouterOS 7.24.4 quotet dort
  nichts). Trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen.
  Fix: Token-Scan statt Regex.
- SSHTransport.fetchFieldValues defensiv gegen dieselbe Injection-Klasse
  gehärtet (aktuell nur hartkodiert aufgerufen, aber generische API).

2 neue Regressionstests, alle 101 Unit-Tests grün. Details in bugs.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 18:08:34 +02:00
co-authored by Claude Sonnet 5
parent 3b84d31ec6
commit 800b48406b
7 changed files with 227 additions and 16 deletions
@@ -134,10 +134,22 @@ struct RouterOSCommand: Equatable, Identifiable {
.joined(separator: " ")
}
/// Always quotes, with `\` and `"` backslash-escaped inside the previous version only
/// quoted when the value contained a space and never escaped embedded quotes at all, which
/// let a value like `test" ; :log warning "INJECTED` (a completely plausible free-text
/// comment/SSID/hostname) close the quoted argument early and inject a second, independent
/// RouterOS command after the `;` RouterOS' console uses `;` as a statement separator, same
/// as the injection risk `SSHTransport.runDiagnosticCommand`'s doc comment already flags for
/// its own caller-sanitized input. Live-confirmed exploitable and live-confirmed fixed
/// (2026-09-17, bugs.md): unescaped, this executed an injected `:log warning` as a second
/// command; escaping `\`/`"` (verified live to be RouterOS' own escape syntax `\"` and `\\`
/// both round-tripped correctly through `print detail`) closes it. Quoting unconditionally
/// (not just "if it contains a space") also verified live to be always accepted, including for
/// plain single-word values and `yes`/`no` booleans no reason left to special-case those.
private static func quoteIfNeeded(_ value: String) -> String {
// An empty value must render as an explicit `""`, not a bare `key=` with nothing after
// the `=` that's how RouterOS' CLI represents "clear this field" versus a syntax error.
if value.isEmpty { return "\"\"" }
return value.contains(" ") ? "\"\(value)\"" : value
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
}
@@ -98,20 +98,57 @@ enum RouterOSCliParser {
value == "true" || value == "yes"
}
/// RouterOS' `print terse` does *not* quote multi-word values at all live-confirmed
/// (2026-09-17, RouterOS 7.24.4, two independent menus: `/ip firewall address-list` and
/// `/interface ethernet`): a comment set to `"hello world"` comes back completely unquoted as
/// literal `comment=hello world name=ether2 ...`, indistinguishable from a second field by
/// punctuation alone. The previous regex-based parser (`("[^"]*"|\S+)`) assumed quoting
/// sometimes happened and matched only a bare `\S+` otherwise, so it silently truncated every
/// such value at the first space and dropped the remaining words entirely this app's own
/// `RouterOSCliParserTests.testParseInterfacesMatchesLiveHexOutput` already contained an
/// exact real-device dump with this exact shape (`last-link-up-time=2026-09-15 20:19:19`) and
/// nobody noticed, because that particular field happens not to be read by any curated
/// schema the bug is real and was simply invisible until a multi-word field someone
/// actually reads (e.g. any comment shown via the Expert tool's generic "Weitere Parameter")
/// got truncated.
///
/// Fix: token-scan instead of regex-match. A whitespace-separated word only starts a *new*
/// field if it itself looks like `key=...` (letters/digits/dots/hyphens then `=`); anything
/// else is appended to the value of whichever field started most recently. This correctly
/// handles the common case (free text with spaces) at the cost of one known, irreducible edge
/// case given RouterOS' ambiguous unquoted format: a value whose text itself contains a
/// `word=` substring (e.g. a comment literally reading "config=broken") gets misread as a new
/// field starting mid-value rare, and no worse than the previous parser's 100% failure rate
/// on every multi-word value. A defensive quote-strip is kept in case some other menu/RouterOS
/// version *does* quote (unverified either way beyond the two menus tested live).
private static func keyValues(from text: String) -> [String: String] {
var result: [String: String] = [:]
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
for match in matches {
let key = nsText.substring(with: match.range(at: 1))
var value = nsText.substring(with: match.range(at: 2))
let keyPattern = try! NSRegularExpression(pattern: #"^[.a-zA-Z0-9-]+="#)
var currentKey: String?
var currentValueWords: [String] = []
func commitCurrentField() {
guard let key = currentKey else { return }
var value = currentValueWords.joined(separator: " ")
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
result[key] = value
}
for word in text.split(separator: " ", omittingEmptySubsequences: true) {
let token = String(word)
let range = NSRange(token.startIndex..<token.endIndex, in: token)
if keyPattern.firstMatch(in: token, range: range) != nil {
commitCurrentField()
let parts = token.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
currentKey = String(parts[0])
currentValueWords = parts.count > 1 ? [String(parts[1])] : [""]
} else if currentKey != nil {
currentValueWords.append(token)
}
}
commitCurrentField()
return result
}
}
@@ -173,8 +173,17 @@ final class SSHTransport: RouterOSTransport {
/// wrong (a real make-static conversion confirmed via Winbox wasn't found by it). `:put`
/// inside `:foreach` prints one value per line, so this splits on newlines, not ";" (the
/// semicolon-joined shape only applies to a single `:put [<menuPath> find ...]` list).
/// `whereValue` is quoted/escaped the same way as `RouterOSCommand`'s CLI rendering (see its
/// `quoteIfNeeded` doc comment for the live-confirmed injection this prevents) both current
/// callers only ever pass the hardcoded literal `"no"`, but this is a generic, reusable
/// `RouterOSTransport` method, so a future caller passing user/device-controlled text (e.g. a
/// hostname) must not silently reopen the same command-injection class this app has already
/// had to fix once.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let script = ":foreach i in=[\(menuPath) find \(whereField)=\(whereValue)] do={:put [\(menuPath) get $i \(returnField)]}"
let escapedValue = whereValue
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = ":foreach i in=[\(menuPath) find \(whereField)=\"\(escapedValue)\"] do={:put [\(menuPath) get $i \(returnField)]}"
let output = try await run(script)
let values = output
.split(whereSeparator: \.isNewline)