Medium severity4.8NVD Advisory· Published Apr 28, 2026· Updated Apr 30, 2026
CVE-2026-41393
CVE-2026-41393
Description
OpenClaw before 2026.3.31 contains a wide-area discovery vulnerability allowing arbitrary tailnet peers to be accepted as DNS authorities. Attackers with same-tailnet position and CA-trusted endpoint access can exfiltrate operator credentials through DNS steering manipulation.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.31 | 2026.3.31 |
Affected products
1Patches
1a23c33a681f8macOS: use MagicDNS for wide-area gateway discovery (#57833)
2 files changed · +101 −75
apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift+22 −74 modified@@ -14,10 +14,11 @@ struct WideAreaGatewayBeacon: Equatable { } enum WideAreaGatewayDiscovery { - private static let maxCandidates = 40 private static let digPath = "/usr/bin/dig" private static let defaultTimeoutSeconds: TimeInterval = 0.2 - private static let nameserverProbeConcurrency = 6 + // Security: wide-area discovery must trust only the Tailscale MagicDNS resolver. + // Probing arbitrary tailnet peers lets the fastest responder become DNS-SD authority. + private static let tailscaleDNSResolver = "100.100.100.100" struct DiscoveryContext { var tailscaleStatus: @Sendable () -> String? @@ -39,27 +40,16 @@ enum WideAreaGatewayDiscovery { timeoutSeconds - Date().timeIntervalSince(startedAt) } - guard let ips = collectTailnetIPv4s( - statusJson: context.tailscaleStatus()).nonEmpty else { return [] } - var candidates = Array(ips.prefix(self.maxCandidates)) - guard let nameserver = findNameserver( - candidates: &candidates, + guard let statusJson = context.tailscaleStatus(), + !collectTailnetIPv4s(statusJson: statusJson).isEmpty, + let discovery = loadWideAreaPtrRecords( remaining: remaining, dig: context.dig) - else { - return [] - } + else { return [] } - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return [] } - let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" - guard let ptrLines = context.dig( - ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], - min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), - !ptrLines.isEmpty - else { - return [] - } + let domainTrimmed = discovery.domainTrimmed + let ptrLines = discovery.ptrLines + let nameserver = self.tailscaleDNSResolver var beacons: [WideAreaGatewayBeacon] = [] for raw in ptrLines { @@ -148,68 +138,26 @@ enum WideAreaGatewayDiscovery { return output } - private static func findNameserver( - candidates: inout [String], + private static func loadWideAreaPtrRecords( remaining: () -> TimeInterval, - dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? + dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) + -> (domainTrimmed: String, ptrLines: [Substring])? { guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil } let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" + let budget = max(0, remaining()) + if budget <= 0 { return nil } - let ips = candidates - candidates.removeAll(keepingCapacity: true) - if ips.isEmpty { return nil } - - final class ProbeState: @unchecked Sendable { - let lock = NSLock() - var nextIndex = 0 - var found: String? - } - - let state = ProbeState() - let deadline = Date().addingTimeInterval(max(0, remaining())) - let workerCount = min(self.nameserverProbeConcurrency, ips.count) - let group = DispatchGroup() - - for _ in 0..<workerCount { - group.enter() - DispatchQueue.global(qos: .utility).async { - defer { group.leave() } - - while Date() < deadline { - state.lock.lock() - if state.found != nil { - state.lock.unlock() - return - } - let i = state.nextIndex - state.nextIndex += 1 - state.lock.unlock() - - if i >= ips.count { return } - let ip = ips[i] - let budget = deadline.timeIntervalSinceNow - if budget <= 0 { return } - - if let stdout = dig( - ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], - min(defaultTimeoutSeconds, budget)), - stdout.split(whereSeparator: \.isNewline).isEmpty == false - { - state.lock.lock() - if state.found == nil { - state.found = ip - } - state.lock.unlock() - return - } - } - } + guard let stdout = dig( + ["+short", "+time=1", "+tries=1", "@\(self.tailscaleDNSResolver)", probeName, "PTR"], + min(defaultTimeoutSeconds, budget)), + let ptrLines = stdout.split(whereSeparator: \.isNewline).nonEmpty + else { + return nil } - _ = group.wait(timeout: .now() + max(0.0, remaining())) - return state.found + return (domainTrimmed, ptrLines) } private static func runDig(args: [String], timeout: TimeInterval) -> String? {
apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift+79 −1 modified@@ -1,10 +1,37 @@ import Darwin +import Foundation import Testing @testable import OpenClawDiscovery +private final class NameserverQueryLog: @unchecked Sendable { + private let lock = NSLock() + private var nameservers: [String] = [] + + func record(_ nameserver: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.nameservers.append(nameserver) + } + + func count(matching nameserver: String) -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.nameservers.filter { $0 == nameserver }.count + } +} + +@Suite(.serialized) struct WideAreaGatewayDiscoveryTests { @Test func `discovers beacon from tailnet dns sd fallback`() { + let originalWideAreaDomain = getenv("OPENCLAW_WIDE_AREA_DOMAIN").map { String(cString: $0) } setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1) + defer { + if let originalWideAreaDomain { + setenv("OPENCLAW_WIDE_AREA_DOMAIN", originalWideAreaDomain, 1) + } else { + unsetenv("OPENCLAW_WIDE_AREA_DOMAIN") + } + } let statusJson = """ { "Self": { "TailscaleIPs": ["100.69.232.64"] }, @@ -20,7 +47,7 @@ struct WideAreaGatewayDiscoveryTests { let recordType = args.last ?? "" let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" if recordType == "PTR" { - if nameserver == "@100.123.224.76" { + if nameserver == "@100.100.100.100" { return "steipetacstudio-gateway._openclaw-gw._tcp.openclaw.internal.\n" } return "" @@ -47,4 +74,55 @@ struct WideAreaGatewayDiscoveryTests { #expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net") #expect(beacon.cliPath == "/Users/steipete/openclaw/src/entry.ts") } + + @Test func `attacker peer cannot become nameserver`() { + let originalWideAreaDomain = getenv("OPENCLAW_WIDE_AREA_DOMAIN").map { String(cString: $0) } + setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1) + defer { + if let originalWideAreaDomain { + setenv("OPENCLAW_WIDE_AREA_DOMAIN", originalWideAreaDomain, 1) + } else { + unsetenv("OPENCLAW_WIDE_AREA_DOMAIN") + } + } + let statusJson = """ + { + "Self": { "TailscaleIPs": ["100.64.0.1"] }, + "Peer": { + "attacker": { "TailscaleIPs": ["100.64.0.2"] } + } + } + """ + + let queriedNameservers = NameserverQueryLog() + let context = WideAreaGatewayDiscovery.DiscoveryContext( + tailscaleStatus: { statusJson }, + dig: { args, _ in + let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" + queriedNameservers.record(nameserver) + + let recordType = args.last ?? "" + if recordType == "PTR" { + if nameserver == "@100.64.0.2" { + return "evil._openclaw-gw._tcp.openclaw.internal.\n" + } + return "" + } + if recordType == "SRV" { + return "0 0 443 evil.ts.net." + } + if recordType == "TXT" { + return "\"displayName=Evil\"" + } + return "" + }) + + let beacons = WideAreaGatewayDiscovery.discover( + timeoutSeconds: 2.0, + context: context) + + #expect(queriedNameservers.count(matching: "@100.64.0.2") == 0) + #expect(queriedNameservers.count(matching: "@100.100.100.100") == 1) + #expect(beacons.isEmpty) + } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/a23c33a681f8c1b22dc793995acc4c5c4b568346nvdPatchWEB
- github.com/advisories/GHSA-q9w8-cf67-r238ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-q9w8-cf67-r238nvdVendor AdvisoryWEB
- www.vulncheck.com/advisories/openclaw-arbitrary-dns-authority-acceptance-and-credential-exfiltration-via-wide-area-discoverynvdThird Party Advisory
- github.com/openclaw/openclaw/releases/tag/v2026.3.31ghsaWEB
News mentions
0No linked articles in our index yet.