Selaa lähdekoodia

-utls option and random TLS fingerprint selection.

David Fifield 4 vuotta sitten
vanhempi
sitoutus
fec00a2a78
8 muutettua tiedostoa jossa 435 lisäystä ja 24 poistoa
  1. 4 2
      CHANGELOG
  2. 12 1
      README
  3. 15 12
      dnstt-client/http.go
  4. 65 5
      dnstt-client/main.go
  5. 3 4
      dnstt-client/tls.go
  6. 34 0
      dnstt-client/utls.go
  7. 201 0
      dnstt-client/weightedlist.go
  8. 101 0
      dnstt-client/weightedlist_test.go

+ 4 - 2
CHANGELOG

@@ -1,6 +1,8 @@
 -doh and -dot mode use uTLS to camouflage their TLS Client Hello
-fingerprint. This change means that it is no longer possible to use a
-proxy in -doh mode by setting the HTTP_PROXY or HTTPS_PROXY environment
+fingerprint. The fingerprint to use is chosen randomly from a weighted
+distribution. You can control this distribution using the new -utls
+option. This change means that it is no longer possible to use a proxy
+in -doh mode by setting the HTTP_PROXY or HTTPS_PROXY environment
 variables; this was an undocumented side effect of using the Go net/http
 package with no TLS camouflage.
 

+ 12 - 1
README

@@ -284,7 +284,18 @@ tunnel server is acting as a proxy, for example), unless that data has
 been separately encrypted before being sent through the tunnel.
 
 dnstt-client disguises its TLS fingerprint using uTLS
-(https://github.com/refraction-networking/utls).
+(https://github.com/refraction-networking/utls). By default, a specific
+TLS Client Hello fingerprint is selected randomly from a weighted
+distribution. You can control the distribution of fingerprints (or
+select a specific single fingerprint) using the `-utls` option. The
+syntax of the option's argument is a comma-separated list of fingerprint
+names, each optionally preceded by an integer weight and `*`.
+```
+$ ./dnstt-client -utls '3*Firefox,2*Chrome,1*iOS' ...
+$ ./dnstt-client -utls Firefox ...
+```
+Run `./dnstt-client -help` to see the available fingerprint names and
+the default distribution.
 
 
 ## Encryption and authentication

+ 15 - 12
dnstt-client/http.go

@@ -18,13 +18,6 @@ import (
 // header in an HTTP response.
 const defaultRetryAfter = 10 * time.Second
 
-// The *http.Client shared by instances of HTTPPacketConn. We use this instead
-// of http.DefaultClient in order to set a timeout and a uTLS fingerprint.
-var httpClient = &http.Client{
-	Transport: NewUTLSRoundTripper(nil, utlsClientHelloID),
-	Timeout:   1 * time.Minute,
-}
-
 // HTTPPacketConn is an HTTP-based transport for DNS messages, used for DNS over
 // HTTPS (DoH). Its WriteTo and ReadFrom methods exchange DNS messages over HTTP
 // requests and responses.
@@ -35,6 +28,11 @@ var httpClient = &http.Client{
 //
 // https://tools.ietf.org/html/rfc8484
 type HTTPPacketConn struct {
+	// client is the http.Client used to make requests. We use this instead
+	// of http.DefaultClient in order to support setting a timeout and a
+	// uTLS fingerprint.
+	client *http.Client
+
 	// urlString is the URL to which HTTP requests will be sent, for example
 	// "https://doh.example/dns-query".
 	urlString string
@@ -57,11 +55,16 @@ type HTTPPacketConn struct {
 }
 
 // NewHTTPPacketConn creates a new HTTPPacketConn configured to use the HTTP
-// server at urlString as a DNS over HTTP resolver. urlString should include any
-// necessary path components; e.g., "/dns-query". numSenders is the number of
-// concurrent sender-receiver goroutines to run.
-func NewHTTPPacketConn(urlString string, numSenders int) (*HTTPPacketConn, error) {
+// server at urlString as a DNS over HTTP resolver. client is the http.Client
+// that will be used to make requests. urlString should include any necessary
+// path components; e.g., "/dns-query". numSenders is the number of concurrent
+// sender-receiver goroutines to run.
+func NewHTTPPacketConn(rt http.RoundTripper, urlString string, numSenders int) (*HTTPPacketConn, error) {
 	c := &HTTPPacketConn{
+		client: &http.Client{
+			Transport: rt,
+			Timeout:   1 * time.Minute,
+		},
 		urlString:       urlString,
 		QueuePacketConn: turbotunnel.NewQueuePacketConn(turbotunnel.DummyAddr{}, 0),
 	}
@@ -81,7 +84,7 @@ func (c *HTTPPacketConn) send(p []byte) error {
 	req.Header.Set("Accept", "application/dns-message")
 	req.Header.Set("Content-Type", "application/dns-message")
 	req.Header.Set("User-Agent", "") // Disable default "Go-http-client/1.1".
-	resp, err := httpClient.Do(req)
+	resp, err := c.client.Do(req)
 	if err != nil {
 		return err
 	}

+ 65 - 5
dnstt-client/main.go

@@ -23,9 +23,17 @@
 //
 // LOCALADDR is the TCP address that will listen for connections and forward
 // them over the tunnel.
+//
+// In -doh and -dot modes, the program's TLS fingerprint is camouflaged with
+// uTLS. By default, the specific TLS fingerprint is selected randomly from a
+// weighted distribution. You can set your own distribution (or specific single
+// fingerprint) using the -utls option:
+//     -utls '3*Firefox,2*Chrome,1*iOS'
+//     -utls Firefox
 package main
 
 import (
+	"context"
 	"errors"
 	"flag"
 	"fmt"
@@ -33,6 +41,7 @@ import (
 	"log"
 	"net"
 	"os"
+	"strings"
 	"sync"
 	"time"
 
@@ -47,9 +56,6 @@ import (
 // smux streams will be closed after this much time without receiving data.
 const idleTimeout = 2 * time.Minute
 
-// uTLS Client Hello fingerprint to use in all TLS connections.
-var utlsClientHelloID = &utls.HelloFirefox_Auto
-
 // dnsNameCapacity returns the number of bytes remaining for encoded data after
 // including domain in a DNS name.
 func dnsNameCapacity(domain dns.Name) int {
@@ -80,6 +86,26 @@ func readKeyFromFile(filename string) ([]byte, error) {
 	return noise.ReadKey(f)
 }
 
+// sampleUTLSDistribution parses a weighted uTLS Client Hello ID distribution
+// string of the form "3*Firefox,2*Chrome,1*iOS", matches each label to a
+// utls.ClientHelloID from utlsClientHelloIDMap, and randomly samples one
+// utls.ClientHelloID from the distribution.
+func sampleUTLSDistribution(spec string) (*utls.ClientHelloID, error) {
+	weights, labels, err := parseWeightedList(spec)
+	if err != nil {
+		return nil, err
+	}
+	ids := make([]*utls.ClientHelloID, 0, len(labels))
+	for _, label := range labels {
+		id := utlsLookup(label)
+		if id == nil {
+			return nil, fmt.Errorf("unknown TLS fingerprint %q", label)
+		}
+		ids = append(ids, id)
+	}
+	return ids[sampleWeighted(weights)], nil
+}
+
 func handle(local *net.TCPConn, sess *smux.Session, conv uint32) error {
 	stream, err := sess.OpenStream()
 	if err != nil {
@@ -204,6 +230,7 @@ func main() {
 	var pubkeyFilename string
 	var pubkeyString string
 	var udpAddr string
+	var utlsDistribution string
 
 	flag.Usage = func() {
 		fmt.Fprintf(flag.CommandLine.Output(), `Usage:
@@ -215,12 +242,35 @@ Examples:
 
 `, os.Args[0])
 		flag.PrintDefaults()
+		labels := make([]string, 0, len(utlsClientHelloIDMap))
+		for _, entry := range utlsClientHelloIDMap {
+			labels = append(labels, entry.Label)
+		}
+		fmt.Fprintf(flag.CommandLine.Output(), `
+Known TLS fingerprints for -utls are:
+`)
+		i := 0
+		for i < len(labels) {
+			var line strings.Builder
+			fmt.Fprintf(&line, "  %s", labels[i])
+			w := 2 + len(labels[i])
+			i++
+			for i < len(labels) && w+1+len(labels[i]) <= 72 {
+				fmt.Fprintf(&line, " %s", labels[i])
+				w += 1 + len(labels[i])
+				i++
+			}
+			fmt.Fprintln(flag.CommandLine.Output(), line.String())
+		}
 	}
 	flag.StringVar(&dohURL, "doh", "", "URL of DoH resolver")
 	flag.StringVar(&dotAddr, "dot", "", "address of DoT resolver")
 	flag.StringVar(&pubkeyString, "pubkey", "", fmt.Sprintf("server public key (%d hex digits)", noise.KeyLen*2))
 	flag.StringVar(&pubkeyFilename, "pubkey-file", "", "read server public key from file")
 	flag.StringVar(&udpAddr, "udp", "", "address of UDP DNS resolver")
+	flag.StringVar(&utlsDistribution, "utls",
+		"3*Firefox_65,1*Firefox_63,3*Chrome_83,1*Chrome_72,1*iOS_12_1",
+		"choose TLS fingerprint from weighted distribution")
 	flag.Parse()
 
 	log.SetFlags(log.LstdFlags | log.LUTC)
@@ -264,6 +314,13 @@ Examples:
 		os.Exit(1)
 	}
 
+	utlsClientHelloID, err := sampleUTLSDistribution(utlsDistribution)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "parsing -utls: %v\n", err)
+		os.Exit(1)
+	}
+	log.Printf("uTLS fingerprint %s %s", utlsClientHelloID.Client, utlsClientHelloID.Version)
+
 	// Iterate over the remote resolver address options and select one and
 	// only one.
 	var remoteAddr net.Addr
@@ -275,13 +332,16 @@ Examples:
 		// -doh
 		{dohURL, func(s string) (net.Addr, net.PacketConn, error) {
 			addr := turbotunnel.DummyAddr{}
-			pconn, err := NewHTTPPacketConn(dohURL, 32)
+			pconn, err := NewHTTPPacketConn(NewUTLSRoundTripper(nil, utlsClientHelloID), dohURL, 32)
 			return addr, pconn, err
 		}},
 		// -dot
 		{dotAddr, func(s string) (net.Addr, net.PacketConn, error) {
 			addr := turbotunnel.DummyAddr{}
-			pconn, err := NewTLSPacketConn(dotAddr)
+			dialTLSContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return utlsDialContext(ctx, network, addr, nil, utlsClientHelloID)
+			}
+			pconn, err := NewTLSPacketConn(dotAddr, dialTLSContext)
 			return addr, pconn, err
 		}},
 		// -udp

+ 3 - 4
dnstt-client/tls.go

@@ -10,7 +10,6 @@ import (
 	"sync"
 	"time"
 
-	utls "github.com/refraction-networking/utls"
 	"www.bamsoftware.com/git/dnstt.git/turbotunnel"
 )
 
@@ -37,11 +36,11 @@ type TLSPacketConn struct {
 // server at addr as a DNS over TLS resolver. It maintains a TLS connection to
 // the resolver, reconnecting as necessary. It closes the connection if any
 // reconnection attempt fails.
-func NewTLSPacketConn(addr string) (*TLSPacketConn, error) {
-	dial := func() (*utls.UConn, error) {
+func NewTLSPacketConn(addr string, dialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error)) (*TLSPacketConn, error) {
+	dial := func() (net.Conn, error) {
 		ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
 		defer cancel()
-		return utlsDialContext(ctx, "tcp", addr, nil, utlsClientHelloID)
+		return dialTLSContext(ctx, "tcp", addr)
 	}
 	// We maintain one TLS connection at a time, redialing it whenever it
 	// becomes disconnected. We do the first dial here, outside the

+ 34 - 0
dnstt-client/utls.go

@@ -9,12 +9,46 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"strings"
 	"sync"
 
 	utls "github.com/refraction-networking/utls"
 	"golang.org/x/net/http2"
 )
 
+// utlsClientHelloIDMap is a correspondence between human-readable labels and
+// supported utls.ClientHelloIDs.
+var utlsClientHelloIDMap = []struct {
+	Label string
+	ID    *utls.ClientHelloID
+}{
+	{"Firefox", &utls.HelloFirefox_Auto},
+	{"Firefox_55", &utls.HelloFirefox_55},
+	{"Firefox_56", &utls.HelloFirefox_56},
+	{"Firefox_63", &utls.HelloFirefox_63},
+	{"Firefox_65", &utls.HelloFirefox_65},
+	{"Chrome", &utls.HelloChrome_Auto},
+	{"Chrome_58", &utls.HelloChrome_58},
+	{"Chrome_62", &utls.HelloChrome_62},
+	{"Chrome_70", &utls.HelloChrome_70},
+	{"Chrome_72", &utls.HelloChrome_72},
+	{"Chrome_83", &utls.HelloChrome_83},
+	{"iOS", &utls.HelloIOS_Auto},
+	{"iOS_11_1", &utls.HelloIOS_11_1},
+	{"iOS_12_1", &utls.HelloIOS_12_1},
+}
+
+// utlsLookup returns a *utls.ClientHelloID from utlsClientHelloIDMap by a
+// case-insensitive label match, or nil if there is no match.
+func utlsLookup(label string) *utls.ClientHelloID {
+	for _, entry := range utlsClientHelloIDMap {
+		if strings.ToLower(label) == strings.ToLower(entry.Label) {
+			return entry.ID
+		}
+	}
+	return nil
+}
+
 // utlsDialContext connects to the given network address and initiates a TLS
 // handshake with the provided ClientHelloID, and returns the resulting TLS
 // connection.

+ 201 - 0
dnstt-client/weightedlist.go

@@ -0,0 +1,201 @@
+package main
+
+// Random selection from weighted distributions, and strings for specifying such
+// distributions.
+
+import (
+	cryptorand "crypto/rand"
+	"encoding/binary"
+	"fmt"
+	mathrand "math/rand"
+	"strconv"
+	"strings"
+)
+
+// parseWeightedList parses a list of text labels with optional numeric weights,
+// and returns parallel slices of weights and labels. If a weight is omitted for
+// a label, the weight is 1.
+//
+// An example weighted list string is "2*apple,orange,10*cookie". This example
+// results in the slices [2, 1, 10] and ["apple", "orange", "cookie"].
+// Bytes may be escaped by backslashes.
+//
+//   list ::= entry [ "," entry ] ;
+//   entry ::= [ weight, "*" ] label ;
+func parseWeightedList(s string) ([]uint32, []string, error) {
+	const (
+		kindEOF = iota
+		kindComma
+		kindAsterisk
+		kindText
+		kindError
+	)
+	type token struct {
+		Kind int
+		Text string
+	}
+
+	var i int
+	// nextToken incrementally consumes s and returns tokens.
+	nextToken := func() token {
+		if !(i < len(s)) {
+			return token{Kind: kindEOF}
+		}
+		if s[i] == ',' {
+			i++
+			return token{Kind: kindComma}
+		}
+		if s[i] == '*' {
+			i++
+			return token{Kind: kindAsterisk}
+		}
+		var text strings.Builder
+		for i < len(s) && s[i] != ',' && s[i] != '*' {
+			if s[i] == '\\' {
+				i++
+				if !(i < len(s)) {
+					return token{Kind: kindError, Text: fmt.Sprintf("%q at end of string", s[i])}
+				}
+			}
+			text.WriteByte(s[i])
+			i++
+		}
+		return token{Kind: kindText, Text: text.String()}
+	}
+	peekToken := func() token {
+		saved := i
+		t := nextToken()
+		i = saved
+		return t
+	}
+
+	const (
+		stateBeginEntry = iota
+		stateLabel
+		stateEndEntry
+		stateDone
+		stateUnexpected
+	)
+
+	var weights []uint32
+	var labels []string
+	var weightString, label string
+	var t token
+	for state := stateBeginEntry; state != stateDone; {
+		switch state {
+		// Beginning of a new entry (at the beginning of the input or
+		// after a comma).
+		case stateBeginEntry:
+			t = nextToken()
+			switch t.Kind {
+			case kindText:
+				// If the next token is an asterisk, this text
+				// represents a weight; otherwise it represents
+				// a label (with a weight of "1").
+				switch peekToken().Kind {
+				case kindAsterisk:
+					nextToken() // Consume the asterisk token.
+					weightString = t.Text
+					state = stateLabel
+				default:
+					weightString = "1"
+					label = t.Text
+					state = stateEndEntry
+				}
+			default:
+				state = stateUnexpected
+			}
+		// weightString is assigned and we have seen an asterisk, now
+		// expect a text label.
+		case stateLabel:
+			t = nextToken()
+			switch t.Kind {
+			case kindText:
+				label = t.Text
+				state = stateEndEntry
+			default:
+				state = stateUnexpected
+			}
+		// weightString and label are assigned, now emit the entry and
+		// expect a comma or EOF.
+		case stateEndEntry:
+			w, err := strconv.ParseUint(weightString, 10, 32)
+			if err != nil {
+				return nil, nil, err
+			}
+			weights = append(weights, uint32(w))
+			labels = append(labels, label)
+			t = nextToken()
+			switch t.Kind {
+			case kindEOF:
+				state = stateDone
+			case kindComma:
+				state = stateBeginEntry
+			default:
+				state = stateUnexpected
+			}
+		case stateUnexpected:
+			if t.Kind == kindError {
+				return nil, nil, fmt.Errorf("%s", t.Text)
+			} else {
+				var ttext string
+				switch t.Kind {
+				case kindEOF:
+					ttext = "end of string"
+				case kindComma:
+					ttext = "\",\""
+				case kindAsterisk:
+					ttext = "\"*\""
+				case kindText:
+					ttext = fmt.Sprintf("%+q", t.Text)
+				}
+				return nil, nil, fmt.Errorf("unexpected %s", ttext)
+			}
+		default:
+			panic(state)
+		}
+	}
+
+	return weights, labels, nil
+}
+
+// cryptoSource is a math/rand Source that reads from the crypto/rand Reader.
+// The Seed method does not affect the sequence of numbers returned from the
+// Int63 method.
+type cryptoSource struct{}
+
+func (s cryptoSource) Seed(_ int64) {}
+
+func (s cryptoSource) Int63() int64 {
+	var n int64
+	err := binary.Read(cryptorand.Reader, binary.BigEndian, &n)
+	if err != nil {
+		panic(err)
+	}
+	n &= (1 << 63) - 1
+	return n
+}
+
+// sampleWeighted returns the index of a randomly selected element of the
+// weights slice, weighted by the values stored in the slice. Panics if
+// the sum of the weights is zero or does not fit in an int64.
+func sampleWeighted(weights []uint32) int {
+	var sum int64 = 0
+	for _, w := range weights {
+		sum += int64(w)
+		if sum < int64(w) {
+			panic("weights overflow")
+		}
+	}
+	if sum == 0 {
+		panic("total weight is zero")
+	}
+	r := uint64(mathrand.New(&cryptoSource{}).Int63n(sum))
+	for i, w := range weights {
+		if r < uint64(w) {
+			return i
+		}
+		r -= uint64(w)
+	}
+	panic("impossible")
+}

+ 101 - 0
dnstt-client/weightedlist_test.go

@@ -0,0 +1,101 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestParseWeightedList(t *testing.T) {
+	// Good inputs.
+	for _, test := range []struct {
+		input           string
+		expectedWeights []uint32
+		expectedLabels  []string
+	}{
+		{"a", []uint32{1}, []string{"a"}},
+		{"apple", []uint32{1}, []string{"apple"}},
+		{"1*apple", []uint32{1}, []string{"apple"}},
+		{"apple,2*carrot,1*apple", []uint32{1, 2, 1}, []string{"apple", "carrot", "apple"}},
+		{"\\a", []uint32{1}, []string{"a"}},
+		{"\\*", []uint32{1}, []string{"*"}},
+		{"\\,", []uint32{1}, []string{","}},
+		{"3\\*apple\\,car\\rot,100*orange", []uint32{1, 100}, []string{"3*apple,carrot", "orange"}},
+	} {
+		weights, labels, err := parseWeightedList(test.input)
+		if err != nil {
+			t.Errorf("%+q resulted in error: %v", test.input, err)
+			continue
+		}
+		i := 0
+		for ; i < len(weights) && i < len(labels) && i < len(test.expectedWeights) && i < len(test.expectedLabels); i++ {
+			if weights[i] != test.expectedWeights[i] {
+				break
+			}
+			if labels[i] != test.expectedLabels[i] {
+				break
+			}
+		}
+		if i < len(test.expectedWeights) || i < len(test.expectedLabels) {
+			t.Errorf("%+q: expected %v, %v, got %v, %v", test.input,
+				test.expectedWeights, test.expectedLabels, weights, labels)
+			continue
+		}
+	}
+
+	// Bad inputs.
+	for _, input := range []string{
+		"",
+		"apple*1",
+		",",
+		",apple",
+		"apple,",
+		"apple,,carrot",
+		"*",
+		"**",
+		"5*apple*5",
+		"-5*apple",
+		"5.5*apple",
+	} {
+		_, _, err := parseWeightedList(input)
+		if err == nil {
+			t.Errorf("%+q resulted in no error", input)
+			continue
+		}
+	}
+}
+
+func TestSampleWeighted(t *testing.T) {
+	// Total weight of zero should result in a panic.
+	for _, weights := range [][]uint32{
+		{},
+		{0},
+		{0, 0, 0, 0, 0},
+	} {
+		func() {
+			defer func() {
+				r := recover()
+				if r == nil {
+					t.Errorf("%v: expected panic", weights)
+				}
+			}()
+			sampleWeighted(weights)
+		}()
+	}
+
+	// If there is only one nonzero weight, it should be always selected.
+	for _, test := range []struct {
+		weights []uint32
+		index   int
+	}{
+		{[]uint32{1}, 0},
+		{[]uint32{1, 0, 0, 0, 0}, 0},
+		{[]uint32{0, 0, 0, 0, 1}, 4},
+		{[]uint32{0, 0, 0xffffffff, 0, 1}, 2},
+	} {
+		for i := 0; i < 100; i++ {
+			index := sampleWeighted(test.weights)
+			if index != test.index {
+				t.Errorf("%v: expected %d, got %d", test.weights, test.index, index)
+			}
+		}
+	}
+}