mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-08-19 14:03:46 +00:00
1-78-fix
This commit is contained in:
parent
df4796da34
commit
ebd6364b26
12 changed files with 592 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-прокси и устройства, которые им пользуются, стояла под списком доменов в сотни строк, не было способа поднять её выше, дать ей больше места или убрать панель, которую никто не читает, а низкая панель рядом с высокой заставляла всё, что идёт следом, ждать ниже высокой.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
src/dns/realworld_test.go
Normal file
115
src/dns/realworld_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
177
src/sni/quic_poison_test.go
Normal file
177
src/sni/quic_poison_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
src/sni/realworld_test.go
Normal file
83
src/sni/realworld_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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++ {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue