From ebd6364b265c79943508bca5bf79b64c3eccaddb Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Mon, 17 Aug 2026 19:17:02 +0200 Subject: [PATCH] 1-78-fix --- changelog.md | 5 + changelog_ru.md | 5 + src/dns/dns.go | 60 +++++++++++- src/dns/dns_test.go | 130 ++++++++++++++++++++++++++ src/dns/realworld_test.go | 115 +++++++++++++++++++++++ src/nfq/dns.go | 14 +-- src/nfq/dnstcp.go | 4 +- src/nfq/mutation.go | 3 +- src/sni/quic.go | 10 +- src/sni/quic_poison_test.go | 177 ++++++++++++++++++++++++++++++++++++ src/sni/realworld_test.go | 83 +++++++++++++++++ src/sni/tls.go | 4 +- 12 files changed, 592 insertions(+), 18 deletions(-) create mode 100644 src/dns/realworld_test.go create mode 100644 src/sni/quic_poison_test.go create mode 100644 src/sni/realworld_test.go diff --git a/changelog.md b/changelog.md index 11b21608..2b05d77d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,10 @@ # B4 - Bye Bye Big Bro +## [1.78.0] - 2026-08-17 + +- FIXED: **Traffic that merely used port 53 was read as a name lookup** - a VPN or proxy tunnel hidden on that port had its encrypted contents taken for a domain name and matched against sets. +- FIXED: **The server name read out of a QUIC connection was accepted whatever it contained** - any bytes of any length passed for a hostname, unlike the same name read from an ordinary HTTPS connection, which was checked. + ## [1.77.0] - 2026-08-15 - ADDED: **A Customize control on the dashboard: panels are reordered by dragging, widened or narrowed by dragging the right edge across a twelve column grid, and hidden with the eye, remembered per browser** - the order and the widths were fixed in the code, so a panel worth keeping an eye on, such as the MTProto proxy and the devices using it, sat below a domain list hundreds of rows long, there was no way to move it up, give it more room or drop a panel that never gets read, and a short panel beside a tall one left everything after it waiting below the taller one. diff --git a/changelog_ru.md b/changelog_ru.md index 0003338c..7a5860e3 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -1,5 +1,10 @@ # B4 - Bye Bye Big Bro +## [1.78.0] - 2026-08-17 + +- ИСПРАВЛЕНО: **Трафик, который лишь использовал порт 53, читался как запрос имени** - у VPN- или прокси-туннеля, спрятанного на этом порту, зашифрованное содержимое принималось за доменное имя и сопоставлялось с сетами. +- ИСПРАВЛЕНО: **Имя сервера, прочитанное из QUIC-соединения, принималось с любым содержимым** - за имя хоста проходили любые байты любой длины, в отличие от того же имени из обычного HTTPS-соединения, которое проверялось. + ## [1.77.0] - 2026-08-15 - ДОБАВЛЕНО: **Кнопка «Настроить» на главной странице: панели переставляются перетаскиванием, расширяются и сужаются потягиванием за правый край по сетке из двенадцати колонок, ненужные скрываются глазом, раскладка запоминается в браузере** - порядок и ширина были заданы в коде, поэтому панель, за которой хочется следить, например MTProto-прокси и устройства, которые им пользуются, стояла под списком доменов в сотни строк, не было способа поднять её выше, дать ей больше места или убрать панель, которую никто не читает, а низкая панель рядом с высокой заставляла всё, что идёт следом, ждать ниже высокой. diff --git a/src/dns/dns.go b/src/dns/dns.go index ecc1c967..9be14a4e 100644 --- a/src/dns/dns.go +++ b/src/dns/dns.go @@ -3,12 +3,21 @@ package dns import ( "encoding/binary" "net" + "strings" +) + +const ( + MaxLabelLen = 63 + MaxNameLen = 255 ) func ParseQueryDomain(payload []byte) (string, bool) { if len(payload) < 12 { return "", false } + if binary.BigEndian.Uint16(payload[4:6]) == 0 { + return "", false + } pos := 12 var domain []byte @@ -16,9 +25,19 @@ func ParseQueryDomain(payload []byte) (string, bool) { for pos < len(payload) { length := int(payload[pos]) if length == 0 { - break + if len(domain) == 0 || pos+5 > len(payload) { + return "", false + } + return string(domain), true } - if pos+1+length > len(payload) { + if length > MaxLabelLen || pos+1+length > len(payload) { + return "", false + } + need := length + if len(domain) > 0 { + need++ + } + if len(domain)+need > MaxNameLen { return "", false } if len(domain) > 0 { @@ -28,10 +47,41 @@ func ParseQueryDomain(payload []byte) (string, bool) { pos += 1 + length } - if len(domain) == 0 { - return "", false + return "", false +} + +const hexDigits = "0123456789abcdef" + +func nameByteUnsafe(c byte) bool { + return c < 0x20 || c == 0x7f +} + +func SafeName(name string) string { + unsafeAt := -1 + for i := 0; i < len(name); i++ { + if nameByteUnsafe(name[i]) { + unsafeAt = i + break + } } - return string(domain), true + if unsafeAt < 0 { + return name + } + + var b strings.Builder + b.Grow(len(name) + 16) + b.WriteString(name[:unsafeAt]) + for i := unsafeAt; i < len(name); i++ { + c := name[i] + if nameByteUnsafe(c) { + b.WriteString(`\x`) + b.WriteByte(hexDigits[c>>4]) + b.WriteByte(hexDigits[c&0x0f]) + continue + } + b.WriteByte(c) + } + return b.String() } func ParseTransactionID(payload []byte) (uint16, bool) { diff --git a/src/dns/dns_test.go b/src/dns/dns_test.go index 908ea3a4..53a11b6a 100644 --- a/src/dns/dns_test.go +++ b/src/dns/dns_test.go @@ -1,8 +1,11 @@ package dns import ( + "bytes" "encoding/binary" + "math/rand" "net" + "strconv" "strings" "testing" ) @@ -263,3 +266,130 @@ func TestParseResponseIPs(t *testing.T) { } }) } + +func dnsHeader(qdCount uint16) []byte { + h := make([]byte, 12) + binary.BigEndian.PutUint16(h[0:2], 0xBEEF) + binary.BigEndian.PutUint16(h[2:4], 0x0100) + binary.BigEndian.PutUint16(h[4:6], qdCount) + return h +} + +func walkExact(total int, labelLens ...int) []byte { + msg := dnsHeader(1) + for _, n := range labelLens { + msg = append(msg, byte(n)) + msg = append(msg, bytes.Repeat([]byte{0x41}, n)...) + } + if len(msg) != total { + panic("walkExact: built " + strconv.Itoa(len(msg)) + " want " + strconv.Itoa(total)) + } + return msg +} + +func TestParseQueryDomainAcceptsWellFormed(t *testing.T) { + cases := []string{ + "a.co", + "stun.cloudflare.com", + "_acme-challenge.example.org", + strings.Repeat("a", 63) + ".example.com", + } + for _, name := range cases { + got, ok := ParseQueryDomain(buildDNSQuery(0x1111, name, 1)) + if !ok || got != name { + t.Errorf("ParseQueryDomain(%q) = %q, %v; want %q, true", name, got, ok, name) + } + } +} + +func TestParseQueryDomainRejectsNonDNS(t *testing.T) { + tests := []struct { + name string + payload []byte + }{ + { + name: "qdcount zero", + payload: append(dnsHeader(0), encodeDNSName("example.com")...), + }, + { + name: "label length above 63", + payload: walkExact(1280, 200, 200, 200, 200, 200, 200, 61), + }, + { + name: "walk ends on buffer edge with no root label", + payload: walkExact(21, 3, 4), + }, + { + name: "compression pointer in question", + payload: append(dnsHeader(1), 0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01), + }, + { + name: "name longer than 255", + payload: append(append(dnsHeader(1), + encodeDNSName(strings.TrimSuffix(strings.Repeat(strings.Repeat("a", 63)+".", 5), "."))...), + 0x00, 0x01, 0x00, 0x01), + }, + { + name: "root label present but qtype truncated", + payload: append(dnsHeader(1), 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x01), + }, + { + name: "root label at first position", + payload: append(dnsHeader(1), 0x00, 0x00, 0x01, 0x00, 0x01), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got, ok := ParseQueryDomain(tc.payload); ok { + t.Errorf("accepted non-DNS payload as %q (%d bytes)", got, len(got)) + } + }) + } +} + +func TestParseQueryDomainBoundsTunnelPayloads(t *testing.T) { + rng := rand.New(rand.NewSource(0x5EED)) + accepted := 0 + const iterations = 20000 + + for i := 0; i < iterations; i++ { + payload := make([]byte, 1280) + rng.Read(payload) + name, ok := ParseQueryDomain(payload) + if !ok { + continue + } + accepted++ + if len(name) > MaxNameLen { + t.Fatalf("accepted name of %d bytes, cap is %d", len(name), MaxNameLen) + } + if strings.ContainsAny(SafeName(name), "\x00\n\r") { + t.Fatalf("SafeName left a log-breaking byte in %q", SafeName(name)) + } + } + + if rate := float64(accepted) / iterations; rate > 0.01 { + t.Errorf("accepted %.3f%% of random 1280-byte payloads, want <= 1%%", rate*100) + } + t.Logf("accepted %d/%d random payloads", accepted, iterations) +} + +func TestSafeName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"stun.cloudflare.com", "stun.cloudflare.com"}, + {"a\nb", `a\x0ab`}, + {"a\x00b", `a\x00b`}, + {"tail\x7f", `tail\x7f`}, + {"\x01\x02", `\x01\x02`}, + {"", ""}, + } + for _, tc := range tests { + if got := SafeName(tc.in); got != tc.want { + t.Errorf("SafeName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/src/dns/realworld_test.go b/src/dns/realworld_test.go new file mode 100644 index 00000000..cf28718a --- /dev/null +++ b/src/dns/realworld_test.go @@ -0,0 +1,115 @@ +package dns + +import ( + "encoding/binary" + "strings" + "testing" +) + +var realWorldNames = []string{ + "0.pool.ntp.org", + "1-2-3.test-host.example.org", + "1.pool.ntp.org", + "192.168.1.1", + "UPPER.Example.COM", + "_dmarc.example.com", + "a.co", + "a961.b.akamai.net", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "ae.iads.unity3d.com", + "afs.ampaeservices.com", + "analytics-ios.rayjump.com", + "api.miwifi.com", + "api16-core-ycru.tiktokv.com", + "assets.msn.com", + "beacons2.gvt2.com", + "cdn-v6.amp-endpoint3.com", + "cdn.activision.com", + "cdn.iads.unity3d.com", + "clck.yandex.net", + "d.applovin.com", + "graph.whatsapp.com", + "ipapi.co", + "kws2-1.web.telegram.org", + "kws2.web.telegram.org", + "kws203.onedaychamp.co.uk", + "localhost.localdomain", + "max.ru", + "mssdk-ru.tiktokv.com", + "p1.trex.media", + "pool.ntp.org", + "prefetch.monetization-sdk.chartboost.com", + "profile.gc.apple.com", + "pubads.g.doubleclick.net", + "quasar.yandex.net", + "sdkeventfnt-eu.dsp-api.moloco.com", + "ssl.gstatic.com", + "stun.cloudflare.com", + "sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.example.com", + "test-gateway.instagram.com", + "unagi-na.amazon.com", + "wps.apple.com", + "www.baidu.com", + "www.googleapis.com", + "x.io", + "xn--80ak6aa92e.com", + "xp.apple.com", +} + +func TestParseQueryDomainRealWorldNames(t *testing.T) { + qtypes := []uint16{1, 28, 65, 5, 16, 12, 33, 257} + + for _, name := range realWorldNames { + for _, qt := range qtypes { + query := buildDNSQuery(0x4242, name, qt) + if got, ok := ParseQueryDomain(query); !ok || got != name { + t.Errorf("query %q qtype=%d: got (%q,%v), want (%q,true)", name, qt, got, ok, name) + } + + block := BuildBlockResponse(query) + if block == nil { + t.Errorf("BuildBlockResponse(%q) = nil", name) + continue + } + if got, ok := ParseQueryDomain(block); !ok || got != name { + t.Errorf("nxdomain response for %q: got (%q,%v)", name, got, ok) + } + + servfail := BuildServfailResponse(query) + if servfail == nil { + t.Errorf("BuildServfailResponse(%q) = nil", name) + continue + } + if got, ok := ParseQueryDomain(servfail); !ok || got != name { + t.Errorf("servfail response for %q: got (%q,%v)", name, got, ok) + } + } + + built := BuildQuery(name, 0x1234, 1) + want := strings.TrimSuffix(strings.TrimSpace(name), ".") + if got, ok := ParseQueryDomain(built); !ok || got != want { + t.Errorf("BuildQuery round-trip for %q: got (%q,%v), want %q", name, got, ok, want) + } + } +} + +func TestParseQueryDomainToleratesExtraSections(t *testing.T) { + name := "cdn.activision.com" + query := buildDNSQuery(0x9999, name, 1) + + withOPT := append([]byte(nil), query...) + withOPT = append(withOPT, 0x00, 0x00, 0x29, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + binary.BigEndian.PutUint16(withOPT[10:12], 1) + if got, ok := ParseQueryDomain(withOPT); !ok || got != name { + t.Errorf("EDNS0 OPT query: got (%q,%v), want (%q,true)", got, ok, name) + } + + twoQuestions := append([]byte(nil), query...) + twoQuestions = append(twoQuestions, encodeDNSName("second.example.com")...) + twoQuestions = append(twoQuestions, 0x00, 0x01, 0x00, 0x01) + binary.BigEndian.PutUint16(twoQuestions[4:6], 2) + if got, ok := ParseQueryDomain(twoQuestions); !ok || got != name { + t.Errorf("QDCOUNT=2 query: got (%q,%v), want (%q,true)", got, ok, name) + } +} diff --git a/src/nfq/dns.go b/src/nfq/dns.go index 467f1b81..05b46df2 100644 --- a/src/nfq/dns.go +++ b/src/nfq/dns.go @@ -287,7 +287,7 @@ func logDNSEvent(proto string, set *config.SetConfig, domain string, clientIP, s if set != nil { setName = set.Name } - log.LogConnection(proto, setName, domain, clientIP.String(), clientPort, "", serverIP.String(), 53, srcMac, "", action) + log.LogConnection(proto, setName, dns.SafeName(domain), clientIP.String(), clientPort, "", serverIP.String(), 53, srcMac, "", action) } func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dport uint16, payload []byte) int { @@ -302,10 +302,10 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp matcher := w.getMatcher() if matchedSet, set := matcher.MatchSNIWithSource(domain, srcMac); matchedSet { cfg := w.getConfig() - log.Tracef("DNS query: %s matched set %s (src %s)", domain, set.Name, srcMac) + log.Tracef("DNS query: %s matched set %s (src %s)", dns.SafeName(domain), set.Name, srcMac) if escSet := w.escalatedSetFor(cfg, domain, srcMac); escSet != nil && escSet != set { - log.Tracef("DNS escalation hit for %s: %s -> %s", domain, set.Name, escSet.Name) + log.Tracef("DNS escalation hit for %s: %s -> %s", dns.SafeName(domain), set.Name, escSet.Name) set = escSet } @@ -330,7 +330,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp if !ipv6Disabled { if resp := dns.BuildBlockResponse(payload); resp != nil { w.sendDNSResponseToClient(ipVersion, originalDst, clientIP, sport, resp) - log.Tracef("DNS sinkhole: %s -> NXDOMAIN for %s (set: %s)", domain, clientIP, set.Name) + log.Tracef("DNS sinkhole: %s -> NXDOMAIN for %s (set: %s)", dns.SafeName(domain), clientIP, set.Name) logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsActionSinkhole) metrics.GetMetricsCollector().RecordBlock(domain, srcMac) vc.drop() @@ -359,7 +359,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp useDoH := set.DNS.DoHURL != "" if !(set.DNS.Enabled && (set.DNS.TargetDNS != "" || useDoH)) { - log.Tracef("DNS redirect: %s matched set %s but no redirect target configured, passing through", domain, set.Name) + log.Tracef("DNS redirect: %s matched set %s but no redirect target configured, passing through", dns.SafeName(domain), set.Name) logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsActionPassthrough) return vc.accept() } @@ -387,7 +387,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp if useDoH { target = set.DNS.DoHURL } - log.Tracef("DNS redirect: intercepting %s -> %s (set %s)", domain, target, set.Name) + log.Tracef("DNS redirect: intercepting %s -> %s (set %s)", dns.SafeName(domain), target, set.Name) logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsRedirectAction(set)) select { @@ -409,7 +409,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp }(set, cfg) return 0 } else { - log.Tracef("DNS query: %s matched no set (src %s), forwarding unchanged", domain, srcMac) + log.Tracef("DNS query: %s matched no set (src %s), forwarding unchanged", dns.SafeName(domain), srcMac) } } } diff --git a/src/nfq/dnstcp.go b/src/nfq/dnstcp.go index a383eb42..afb0ad52 100644 --- a/src/nfq/dnstcp.go +++ b/src/nfq/dnstcp.go @@ -242,13 +242,13 @@ func (s *dnsTCPServer) handle(client net.Conn) { matched, set := s.worker.getMatcher().MatchSNIWithSource(domain, srcMac) if !matched { - log.Tracef("DNS TCP: %s matched no set (src %s), forwarding unchanged", domain, srcMac) + log.Tracef("DNS TCP: %s matched no set (src %s), forwarding unchanged", dns.SafeName(domain), srcMac) s.passthrough(client, origIP, origPort, origErr, query) return } if escSet := s.worker.escalatedSetFor(cfg, domain, srcMac); escSet != nil && escSet != set { - log.Tracef("DNS TCP escalation hit for %s: %s -> %s", domain, set.Name, escSet.Name) + log.Tracef("DNS TCP escalation hit for %s: %s -> %s", dns.SafeName(domain), set.Name, escSet.Name) set = escSet } diff --git a/src/nfq/mutation.go b/src/nfq/mutation.go index ff0e71cd..460dbf7e 100644 --- a/src/nfq/mutation.go +++ b/src/nfq/mutation.go @@ -8,13 +8,14 @@ import ( "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/sni" "github.com/daniellavrushin/b4/sock" "github.com/daniellavrushin/b4/utils" ) const ( MaxTCPPacketSize = 1460 // Standard MTU minus headers - MaxSNILength = 255 // Max SNI length per spec + MaxSNILength = sni.MaxSNINameLen ) // GREASE values (RFC 8701) diff --git a/src/sni/quic.go b/src/sni/quic.go index 74460b78..c92ed4ce 100644 --- a/src/sni/quic.go +++ b/src/sni/quic.go @@ -1,6 +1,7 @@ package sni import ( + "github.com/daniellavrushin/b4/log" "github.com/daniellavrushin/b4/quic" "golang.org/x/crypto/cryptobyte" ) @@ -20,11 +21,16 @@ func ParseQUICClientHelloSNI(payload []byte) (string, bool) { return "", false } host, err := extractSNIFromQUIC(crypto) - if err != nil || host == nil || len(host) == 0 { + if err != nil || len(host) == 0 { + return "", false + } + name := string(host) + if !validateSNI(name) { + log.Tracef("QUIC: invalid SNI extracted: %q", name) return "", false } quic.ClearDCID(dcid) - return string(host), true + return name, true } func assembleSafe(dcid, plain []byte) ([]byte, bool) { diff --git a/src/sni/quic_poison_test.go b/src/sni/quic_poison_test.go new file mode 100644 index 00000000..617298ff --- /dev/null +++ b/src/sni/quic_poison_test.go @@ -0,0 +1,177 @@ +package sni + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "io" + "strings" + "testing" + + "golang.org/x/crypto/hkdf" +) + +var quicSaltV1 = []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a} + +func qu16(v int) []byte { + b := make([]byte, 2) + binary.BigEndian.PutUint16(b, uint16(v)) + return b +} + +func qvarint(v int) []byte { + switch { + case v < 64: + return []byte{byte(v)} + case v < 16384: + return []byte{0x40 | byte(v>>8), byte(v)} + default: + return []byte{0x80, byte(v >> 16), byte(v >> 8), byte(v)} + } +} + +func qExpandLabel(t *testing.T, secret []byte, label string, n int) []byte { + t.Helper() + full := "tls13 " + label + info := make([]byte, 2+1+len(full)+1) + info[0] = byte(n >> 8) + info[1] = byte(n) + info[2] = byte(len(full)) + copy(info[3:], full) + out := make([]byte, n) + if _, err := io.ReadFull(hkdf.Expand(sha256.New, secret, info), out); err != nil { + t.Fatalf("hkdf expand %q: %v", label, err) + } + return out +} + +func qBuildClientHello(serverName []byte) []byte { + entry := append([]byte{0x00}, qu16(len(serverName))...) + entry = append(entry, serverName...) + + list := append(qu16(len(entry)), entry...) + exts := append(qu16(0), qu16(len(list))...) + exts = append(exts, list...) + + ch := []byte{0x03, 0x03} + ch = append(ch, make([]byte, 32)...) + ch = append(ch, 0x00) + ch = append(ch, qu16(2)...) + ch = append(ch, 0x13, 0x01) + ch = append(ch, 0x01, 0x00) + ch = append(ch, qu16(len(exts))...) + ch = append(ch, exts...) + + return append([]byte{0x01, byte(len(ch) >> 16), byte(len(ch) >> 8), byte(len(ch))}, ch...) +} + +func qBuildInitial(t *testing.T, dcid, crypto []byte) []byte { + t.Helper() + + payload := append([]byte{0x06, 0x00}, qvarint(len(crypto))...) + payload = append(payload, crypto...) + if len(payload) < 1000 { + payload = append(payload, make([]byte, 1000-len(payload))...) + } + + m := hmac.New(sha256.New, quicSaltV1) + _, _ = m.Write(dcid) + client := qExpandLabel(t, m.Sum(nil), "client in", 32) + + blk, err := aes.NewCipher(qExpandLabel(t, client, "quic key", 16)) + if err != nil { + t.Fatalf("aes key: %v", err) + } + aead, err := cipher.NewGCM(blk) + if err != nil { + t.Fatalf("gcm: %v", err) + } + hpBlk, err := aes.NewCipher(qExpandLabel(t, client, "quic hp", 16)) + if err != nil { + t.Fatalf("aes hp: %v", err) + } + iv := qExpandLabel(t, client, "quic iv", 12) + + hdr := []byte{0xC0, 0x00, 0x00, 0x00, 0x01, byte(len(dcid))} + hdr = append(hdr, dcid...) + hdr = append(hdr, 0x00, 0x00) + hdr = append(hdr, qvarint(1+len(payload)+16)...) + + pnOff := len(hdr) + pkt := append(hdr, 0x00) + pkt = append(pkt, aead.Seal(nil, iv, payload, pkt)...) + + var mask [16]byte + hpBlk.Encrypt(mask[:], pkt[pnOff+4:pnOff+20]) + pkt[0] ^= mask[0] & 0x0f + pkt[pnOff] ^= mask[1] + + return pkt +} + +func TestParseQUICClientHelloSNIAcceptsRealName(t *testing.T) { + pkt := qBuildInitial(t, []byte{0xA0, 1, 2, 3, 4, 5, 6, 7}, qBuildClientHello([]byte("graph.whatsapp.com"))) + got, ok := ParseQUICClientHelloSNI(pkt) + if !ok || got != "graph.whatsapp.com" { + t.Fatalf("ParseQUICClientHelloSNI = %q, %v; want graph.whatsapp.com, true", got, ok) + } +} + +func TestParseQUICClientHelloSNIAcceptsMaxLengthName(t *testing.T) { + longest := strings.Repeat("a", MaxSNINameLen-4) + ".com" + pkt := qBuildInitial(t, []byte{0xC0, 1, 2, 3, 4, 5, 6, 7}, qBuildClientHello([]byte(longest))) + got, ok := ParseQUICClientHelloSNI(pkt) + if !ok || got != longest { + t.Fatalf("a %d byte hostname should be accepted: ok=%v len=%d", len(longest), ok, len(got)) + } +} + +func TestParseQUICClientHelloSNIRejectsHostileName(t *testing.T) { + tests := []struct { + name string + dcid []byte + sni []byte + }{ + { + name: "newline forges a log line", + dcid: []byte{0xB0, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte("evil.com\n2026/08/17 00:00:00.000000 [INFO] forged.example.com"), + }, + { + name: "nul byte", + dcid: []byte{0xB1, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte("evil.com\x00tail"), + }, + { + name: "carriage return", + dcid: []byte{0xB2, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte("evil.com\rtail"), + }, + { + name: "no dot", + dcid: []byte{0xB3, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte("notahostname"), + }, + { + name: "over max length", + dcid: []byte{0xB4, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte(strings.Repeat("a", MaxSNINameLen-3) + ".com"), + }, + { + name: "space", + dcid: []byte{0xB5, 1, 2, 3, 4, 5, 6, 7}, + sni: []byte("evil.com and more"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pkt := qBuildInitial(t, tc.dcid, qBuildClientHello(tc.sni)) + if got, ok := ParseQUICClientHelloSNI(pkt); ok { + t.Errorf("accepted hostile SNI %q (%d bytes)", got, len(got)) + } + }) + } +} diff --git a/src/sni/realworld_test.go b/src/sni/realworld_test.go new file mode 100644 index 00000000..058c5fb2 --- /dev/null +++ b/src/sni/realworld_test.go @@ -0,0 +1,83 @@ +package sni + +import ( + "testing" +) + +var realWorldSNIs = []string{ + "0.pool.ntp.org", + "1-2-3.test-host.example.org", + "1.pool.ntp.org", + "192.168.1.1", + "UPPER.Example.COM", + "_dmarc.example.com", + "a.co", + "a961.b.akamai.net", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "ae.iads.unity3d.com", + "afs.ampaeservices.com", + "analytics-ios.rayjump.com", + "api.miwifi.com", + "api16-core-ycru.tiktokv.com", + "assets.msn.com", + "beacons2.gvt2.com", + "cdn-v6.amp-endpoint3.com", + "cdn.activision.com", + "cdn.iads.unity3d.com", + "clck.yandex.net", + "d.applovin.com", + "graph.whatsapp.com", + "ipapi.co", + "kws2-1.web.telegram.org", + "kws2.web.telegram.org", + "kws203.onedaychamp.co.uk", + "localhost.localdomain", + "max.ru", + "mssdk-ru.tiktokv.com", + "p1.trex.media", + "pool.ntp.org", + "prefetch.monetization-sdk.chartboost.com", + "profile.gc.apple.com", + "pubads.g.doubleclick.net", + "quasar.yandex.net", + "sdkeventfnt-eu.dsp-api.moloco.com", + "ssl.gstatic.com", + "stun.cloudflare.com", + "sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.example.com", + "test-gateway.instagram.com", + "unagi-na.amazon.com", + "wps.apple.com", + "www.baidu.com", + "www.googleapis.com", + "x.io", + "xn--80ak6aa92e.com", + "xp.apple.com", +} + +func tlsRecordWithSNI(host string) []byte { + ch := qBuildClientHello([]byte(host)) + return append([]byte{0x16, 0x03, 0x01, byte(len(ch) >> 8), byte(len(ch))}, ch...) +} + +func TestSNIExtractionRealWorldNames(t *testing.T) { + for i, host := range realWorldSNIs { + dcid := []byte{0xD0, byte(i >> 8), byte(i), 0x11, 0x22, 0x33, 0x44, 0x55} + if got, ok := ParseQUICClientHelloSNI(qBuildInitial(t, dcid, qBuildClientHello([]byte(host)))); !ok || got != host { + t.Errorf("QUIC SNI %q (%d bytes): got (%q,%v)", host, len(host), got, ok) + } + + if got, _, ok := ParseTLSClientHelloSNI(tlsRecordWithSNI(host)); !ok || got != host { + t.Errorf("TLS SNI %q (%d bytes): got (%q,%v)", host, len(host), got, ok) + } + + ch := qBuildClientHello([]byte(host)) + if got, ok := ParseTLSClientHelloBodySNI(ch[4:]); !ok || got != host { + t.Errorf("TLS body SNI %q: got (%q,%v)", host, got, ok) + } + + if !IsValidSNI([]byte(host)) { + t.Errorf("IsValidSNI rejected real hostname %q (%d bytes)", host, len(host)) + } + } +} diff --git a/src/sni/tls.go b/src/sni/tls.go index ae3184b9..03e183c7 100644 --- a/src/sni/tls.go +++ b/src/sni/tls.go @@ -12,6 +12,8 @@ const ( tlsExtServerName uint16 = 0 ) +const MaxSNINameLen = 255 + type parseErr string func (e parseErr) Error() string { return string(e) } @@ -32,7 +34,7 @@ func isValidSNIChar(b byte) bool { } func validateSNI(sni string) bool { - if len(sni) == 0 { + if len(sni) == 0 || len(sni) > MaxSNINameLen { return false } for i := 0; i < len(sni); i++ {