net/tlsdial: record a health warning when MitM is detected

When system roots verify a certificate for any *.tailscale.com and
*.tailscale.io domain, but Let's Encrypt doesn't, report a health
warning. This can be intentional, like a corporate DPI proxy, or it can
be a sign of an attack.

Updates https://github.com/tailscale/corp/issues/24852
Updates #3198

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
Andrew Lytvynov 2024-11-27 13:31:51 -08:00
parent bb80f14ff4
commit 602ea59818
No known key found for this signature in database
2 changed files with 117 additions and 32 deletions

View File

@ -20,6 +20,7 @@ import (
"net"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
@ -55,6 +56,17 @@ var mitmBlockWarnable = health.Register(&health.Warnable{
ImpactsConnectivity: true,
})
var mitmDetectWarnable = health.Register(&health.Warnable{
Code: "mitm-detected",
Title: "Network may be intercepting Tailscale traffic",
Text: func(args health.Args) string {
return fmt.Sprintf("This network may be intercepting Tailscale TLS traffic. The TLS certificate for one of Tailscale's domains is signed by %q, but all real Tailscale domains use Let's Encrypt. If this is not an intentional setup connect to another network, or contact your network administrator for assistance.", args["issuer"])
},
Severity: health.SeverityMedium,
})
var systemCertPool = x509.SystemCertPool
// Config returns a tls.Config for connecting to a server.
// If base is non-nil, it's cloned as the base config before
// being configured and returned.
@ -98,12 +110,12 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
// Perform some health checks on this certificate before we do
// any verification.
var cert *x509.Certificate
var selfSignedIssuer string
var issuer string
var selfSignedIssuer bool
if certs := cs.PeerCertificates; len(certs) > 0 {
cert = certs[0]
if certIsSelfSigned(cert) {
selfSignedIssuer = cert.Issuer.String()
}
issuer = cert.Issuer.CommonName
selfSignedIssuer = certIsSelfSigned(cert)
}
if ht != nil {
defer func() {
@ -120,18 +132,18 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
} else {
ht.SetHealthy(mitmBlockWarnable)
}
if retErr != nil && selfSignedIssuer != "" {
if retErr != nil && selfSignedIssuer {
// Self-signed certs are never valid.
//
// TODO(bradfitz): plumb down the selfSignedIssuer as a
// structured health warning argument.
ht.SetTLSConnectionError(cs.ServerName, fmt.Errorf("likely intercepted connection; certificate is self-signed by %v", selfSignedIssuer))
ht.SetTLSConnectionError(cs.ServerName, fmt.Errorf("likely intercepted connection; certificate is self-signed by %v", issuer))
} else {
// Ensure we clear any error state for this ServerName.
ht.SetTLSConnectionError(cs.ServerName, nil)
if selfSignedIssuer != "" {
if selfSignedIssuer {
// Log the self-signed issuer, but don't treat it as an error.
log.Printf("tlsdial: warning: server cert for %q passed x509 validation but is self-signed by %q", host, selfSignedIssuer)
log.Printf("tlsdial: warning: server cert for %q passed x509 validation but is self-signed by %q", host, issuer)
}
}
}()
@ -143,6 +155,11 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
DNSName: cs.ServerName,
Intermediates: x509.NewCertPool(),
}
sysRoots, err := systemCertPool()
if err != nil {
return fmt.Errorf("failed loading system CA roots: %w", err)
}
opts.Roots = sysRoots
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
@ -167,6 +184,19 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
}
}
}
// If validation with system roots succeeded but Let's Encrypt roots
// didn't, then we have some custom CA in system roots that's used to
// impersonate a Tailscale domain.
if strings.HasSuffix(cs.ServerName, "tailscale.com") || strings.HasSuffix(cs.ServerName, "tailscale.io") {
// TODO(awly): there should be an MDM or prefs toggle to disable
// this detection, for companies that legitimately do stuff like
// DPI.
if errSys == nil && bakedErr != nil {
ht.SetUnhealthy(mitmDetectWarnable, health.Args{"issuer": issuer})
} else {
ht.SetHealthy(mitmDetectWarnable)
}
}
if errSys == nil {
return nil

View File

@ -4,10 +4,13 @@
package tlsdial
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@ -40,30 +43,7 @@ func TestFallbackRootWorks(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("test assumes Linux")
}
d := t.TempDir()
crtFile := filepath.Join(d, "tlsdial.test.crt")
keyFile := filepath.Join(d, "tlsdial.test.key")
caFile := filepath.Join(d, "rootCA.pem")
cmd := exec.Command("go",
"run", "filippo.io/mkcert",
"--cert-file="+crtFile,
"--key-file="+keyFile,
"tlsdial.test")
cmd.Env = append(os.Environ(), "CAROOT="+d)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("mkcert: %v, %s", err, out)
}
if debug {
t.Logf("Ran: %s", out)
dents, err := os.ReadDir(d)
if err != nil {
t.Fatal(err)
}
for _, de := range dents {
t.Logf(" - %v", de)
}
}
crtFile, keyFile, caFile := mkcert(t, "tlsdial.test")
caPEM, err := os.ReadFile(caFile)
if err != nil {
@ -128,6 +108,81 @@ func TestFallbackRootWorks(t *testing.T) {
}
}
func TestMITMDetection(t *testing.T) {
defer resetOnce()
if runtime.GOOS != "linux" {
t.Skip("test assumes Linux")
}
crtFile, keyFile, caFile := mkcert(t, "test.tailscale.com")
oldSystemCertPool := systemCertPool
defer func() { systemCertPool = oldSystemCertPool }()
systemCertPool = func() (*x509.CertPool, error) {
roots := x509.NewCertPool()
caPEM, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
if !roots.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA file %q", caFile)
}
return roots, nil
}
crt, err := tls.LoadX509KeyPair(crtFile, keyFile)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(http.HandlerFunc(sayHi))
srv.TLS = &tls.Config{
Certificates: []tls.Certificate{crt},
}
srv.StartTLS()
defer srv.Close()
srv.Client()
ht := new(health.Tracker)
c := &http.Client{Transport: &http.Transport{
Dial: func(network, addr string) (net.Conn, error) {
return net.Dial("tcp", srv.Listener.Addr().String())
},
TLSClientConfig: Config("test.tailscale.com", ht, nil),
}}
res, err := c.Get("https://test.tailscale.com/")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatal(res.Status)
}
detected := ht.CurrentState().Warnings[mitmDetectWarnable.Code].BrokenSince != nil
if !detected {
t.Errorf("mitmDetectWarnable did not become unhealthy after the request")
}
}
func mkcert(t *testing.T, domain string) (crtFile, keyFile, caFile string) {
d := t.TempDir()
crtFile = filepath.Join(d, domain+".crt")
keyFile = filepath.Join(d, domain+".key")
caFile = filepath.Join(d, "rootCA.pem")
cmd := exec.Command("go",
"run", "filippo.io/mkcert",
"--cert-file="+crtFile,
"--key-file="+keyFile,
domain)
cmd.Env = append(os.Environ(), "CAROOT="+d)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("mkcert: %v, %s", err, out)
}
return crtFile, keyFile, caFile
}
func sayHi(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "hi")
}