VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
a23c33a681f8

macOS: use MagicDNS for wide-area gateway discovery (#57833)

https://github.com/openclaw/openclawJacob TomlinsonMar 31, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.