worktree-wt-issue-430-431 (#436)

This commit is contained in:
Daniel Lavrushin 2026-10-16 00:55:50 +02:00 • committed by GitHub
parent 9c415c6571
commit 6e8f65e6b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1037 additions and 39 deletions

View file

@ -15,6 +15,10 @@
- FIXED: **System Info listed tools such as jq, sha256sum and ipset without a version when they were on the USB drive b4 is installed on** - b4 asked a tool for its version only when it sat in a standard system directory such as `/usr/bin` or `/opt/bin`.
- FIXED: **A `regexp:` entry listed in two sets, such as a set and its copy made with Duplicate, worked only in the set higher in the list, so when one of them was limited by Source devices, some devices got the wrong set or none for the names that entry matches** - b4 kept each pattern only for the first set that listed it.
- FIXED: **A device added by hand by its IPv6 address, typed in another form such as in capitals or with leading zeros, was not recognised when b4 matched its traffic to a set's domains, so a set that selects it under Source devices skipped it and one with All devices except selected applied to it** - b4 looked packets up by the standard form of their address, while it kept the device's address as typed.
- FIXED: **Apps that use TCP Fast Open could not open sites through a set with a strategy** - such an app puts the start of its secure connection into the packet that opens the connection. b4 split that packet like ordinary data, and every piece opened the connection again with different numbering. The site kept one of them and could not read the start. [#431](https://github.com/DanielLavrushin/b4/issues/431)
- FIXED: **Window Mode sent malformed fake packets on connections that use TCP timestamps, which Linux turns on by default** - each fake kept the header length of the original packet but carried only the first part of the header.
- FIXED: **SYN MD5 Signature could send its fake SYN without a usable MD5 option, such as for an app that uses TCP Fast Open** - when the real SYN's options left no room, the option was left out. When they ended with an end-of-options marker, it went after that marker, where servers stop reading. A server it reached could take the fake for a real SYN.
- FIXED: **b4 could stop on a malformed TCP packet sent to an address a set covers** - the packet's header claimed to be shorter than any TCP header can be. b4 read past the end of the packet while applying the set's strategy.
## [1.86.0] - 2026-10-09

View file

@ -15,6 +15,10 @@
- ИСПРАВЛЕНО: **Информация о системе показывала утилиты, например jq, sha256sum и ipset, без версии, если они лежали на USB-накопителе, на который установлен b4** - b4 спрашивал версию только у утилиты из стандартного системного каталога, например `/usr/bin` или `/opt/bin`.
- ИСПРАВЛЕНО: **Запись `regexp:`, указанная в двух сетах, например в сете и его копии, созданной кнопкой «Дублировать», работала только в том из них, что выше в списке, поэтому, если один из них был ограничен устройствами-источниками, часть устройств получала для имён, совпадающих с этой записью, не тот сет или никакой** - b4 закреплял каждый шаблон только за первым сетом, в котором он встретился.
- ИСПРАВЛЕНО: **Устройство, добавленное вручную по IPv6-адресу, введённому в другой записи, например заглавными буквами или с ведущими нулями, не распознавалось, когда b4 сопоставлял его трафик с доменами сетов, поэтому сет, в устройствах-источниках которого оно выбрано, его пропускал, а сет с режимом «Все устройства, кроме выбранных» к нему применялся** - b4 искал пакеты по стандартной записи их адреса, а адрес устройства хранил в том виде, в каком его ввели.
- ИСПРАВЛЕНО: **Приложения с TCP Fast Open не могли открыть сайты через сет со стратегией** - такое приложение кладёт начало защищённого соединения в пакет, который открывает соединение. b4 разрезал этот пакет как обычные данные, и каждая часть заново открывала соединение со своей нумерацией. Сайт оставлял одну из них и не мог прочитать начало. [#431](https://github.com/DanielLavrushin/b4/issues/431)
- ИСПРАВЛЕНО: **«Режим окна» отправлял повреждённые фейковые пакеты на соединениях с метками времени TCP, которые Linux включает по умолчанию** - каждый фейк сохранял длину заголовка исходного пакета, но нёс только первую часть заголовка.
- ИСПРАВЛЕНО: **С включённым SYN MD5 Signature фейковый SYN мог уйти без рабочей опции MD5, например у приложения с TCP Fast Open** - если опции настоящего SYN не оставляли места, опция не добавлялась. Если они заканчивались маркером конца опций, она вставала после него, а там серверы уже ничего не читают. Сервер, до которого доходил такой фейк, мог принять его за настоящий SYN.
- ИСПРАВЛЕНО: **b4 мог остановиться из-за повреждённого TCP-пакета, отправленного на адрес из сета** - заголовок пакета объявлял длину меньше, чем бывает у любого TCP-заголовка. Применяя стратегию сета, b4 читал данные за концом пакета.
## [1.86.0] - 2026-10-09

View file

@ -494,7 +494,14 @@ func TestARetransmittedFastOpenSynKeepsTheName(t *testing.T) {
const key = "10.0.0.1:12345->1.2.3.4:443"
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
w.ProcessPacket(makeV4TCPPacketFlags(hello, isn, 0x02))
sent := captureInjected(t, w)
syn := makeV4TCPPacketFlags(hello, isn, 0x02)
w.ProcessPacket(syn)
if got := sent(); len(got) != 1 {
t.Fatalf("a fast-open SYN must go out as one SYN without the ClientHello, not split like data, got %d packets", len(got))
} else {
assertSynWithoutPayload(t, got[0], syn)
}
w.ProcessPacket(makeV4TCPPacketFlags(nil, isn, 0x02))
if host, _, found := w.tlsCache.Lookup(key); !found || host != "i.ytimg.com" {
t.Fatalf("a retransmitted SYN of the same connection must keep the name its first SYN carried, got %q found=%v", host, found)

View file

@ -302,7 +302,7 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi
return vc.accept()
}
datOff := int((tcp[12]>>4)&0x0f) * 4
if len(tcp) < datOff {
if datOff < TCPHeaderMinLen || len(tcp) < datOff {
return vc.accept()
}
payload := tcp[datOff:]
@ -463,22 +463,29 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi
observeFlow(cfg, pkt, sport, dport, set, "")
syn := pkt.raw
if len(payload) > 0 && needsTCPInjection(set) {
if stripped, ok := synWithoutPayload(pkt.raw, pkt.ver, pkt.ihl, datOff); ok {
syn = stripped
}
}
if pkt.ver == IPv4 {
if set.TCP.SynFake {
w.sendFakeSyn(set, pkt.raw, pkt.ihl, datOff)
w.sendFakeSyn(set, syn, pkt.ihl, datOff)
}
if set.Faking.TCPMD5 {
w.sendFakeSynWithMD5(set, pkt.raw, pkt.ihl, pkt.dst)
w.sendFakeSynWithMD5(set, syn, pkt.ihl, pkt.dst)
}
_ = w.sock.SendIPv4(pkt.raw, pkt.dst)
_ = w.sock.SendIPv4(syn, pkt.dst)
} else {
if set.TCP.SynFake {
w.sendFakeSynV6(set, pkt.raw, pkt.ihl, datOff)
w.sendFakeSynV6(set, syn, pkt.ihl, datOff)
}
if set.Faking.TCPMD5 {
w.sendFakeSynWithMD5V6(set, pkt.raw, pkt.dst)
w.sendFakeSynWithMD5V6(set, syn, pkt.dst)
}
_ = w.sock.SendIPv6(pkt.raw, pkt.dst)
_ = w.sock.SendIPv6(syn, pkt.dst)
}
if set.TCP.Incoming.Mode != config.ConfigOff || set.TCP.RSTProtection.Enabled || set.Escalate.Active() {
@ -678,10 +685,15 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi
observeFlow(cfg, pkt, sport, dport, flowSet, host)
}
var strippedSyn []byte
if isSyn && len(payload) > 0 && matched && set != nil && !routeHandsOff && needsTCPInjection(set) {
strippedSyn, _ = synWithoutPayload(pkt.raw, pkt.ver, pkt.ihl, datOff)
}
stallCount := 0
stallFirstSeen := time.Time{}
stallTracked := false
if matched && isClientHello && host != "" && cfg.IsTCPPort(dport) &&
if matched && isClientHello && strippedSyn == nil && host != "" && cfg.IsTCPPort(dport) &&
(set.TCP.IPBlockDetect.Enabled || set.Escalate.Active()) {
stallCount, stallFirstSeen = w.destState.RecordClientHello(connKey, host)
stallTracked = true
@ -774,6 +786,28 @@ func (w *Worker) handleTCPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi
return vc.accept()
}
if isSyn && len(payload) > 0 {
if strippedSyn == nil {
return vc.accept()
}
if set.TCP.DropSACK {
if pkt.ver == 4 {
strippedSyn = sock.StripSACKFromTCP(strippedSyn)
} else {
strippedSyn = sock.StripSACKFromTCPv6(strippedSyn)
}
}
if !vc.drop() {
return 0
}
if pkt.ver == 4 {
_ = w.sock.SendIPv4(strippedSyn, pkt.dst)
} else {
_ = w.sock.SendIPv6(strippedSyn, pkt.dst)
}
return 0
}
if !injectAcquire() {
return vc.accept()
}
@ -884,7 +918,7 @@ func (w *Worker) handleUDPPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Confi
namedByHello := false
helloDatagram := false
if isQUIC && (host == "" || coalescesInitials(set)) {
if isQUIC && (host == "" || coalescesInitials(set) || coalescesInitials(w.escalatedSetFor(cfg, host, pkt.srcMac))) {
if h, ok := sni.ParseQUICClientHelloSNI(payload); ok {
helloDatagram = true
host = h

View file

@ -444,7 +444,11 @@ func (w *Worker) sendFakeSNISequence(cfg *config.SetConfig, original []byte, dst
}
badsum := !sock.TCPChecksumValid(fake)
if fk.MD5OnFake {
fake = sock.AddTCPMD5Option(fake, false)
withMD5, ok := sock.AddTCPMD5Option(fake, false)
if !ok {
log.Tracef("Fake SNI to %s: no room for the TCP MD5 option, sending it without", dst.String())
}
fake = withMD5
if badsum {
sock.FixTCPChecksum(fake)
sock.CorruptTCPChecksum(fake)

View file

@ -359,7 +359,11 @@ func (w *Worker) sendFakeSNISequencev6(cfg *config.SetConfig, original []byte, d
}
badsum := !sock.TCPChecksumValidV6(fake)
if faking.MD5OnFake {
fake = sock.AddTCPMD5Option(fake, true)
withMD5, ok := sock.AddTCPMD5Option(fake, true)
if !ok {
log.Tracef("Fake SNI v6 to %s: no room for the TCP MD5 option, sending it without", dst.String())
}
fake = withMD5
if badsum {
sock.FixTCPChecksumV6(fake)
sock.CorruptTCPChecksumV6(fake)

View file

@ -288,6 +288,44 @@ func TestAnAddressLearnedByACoalescingSetDoesNotHideABlockedName(t *testing.T) {
}
}
func TestAnEscalationToACoalescingSetCoalescesOnALearnedAddress(t *testing.T) {
fallback := newDomainOnlyQUICSet(config.UDPModeCoalesce)
fallback.Id = "coalesce-fallback"
fallback.Name = "CoalesceFallback"
fallback.Targets.SNIDomains = []string{"fallback.example"}
learning := newDomainOnlyQUICSet(config.ConfigOff)
learning.Id = "learning"
learning.Name = "Learning"
learning.Targets.SNIDomains = []string{"escalate.example"}
learning.Targets.DomainOnly = false
learning.Escalate.To = fallback.Id
for _, escalated := range []bool{false, true} {
w := newQUICFlowWorker(t, &learning, &fallback)
sent := captureInjected(t, w)
if v := sendQUIC(w, buildQUICInitialWithSNI(t, []byte{0xa0, 1, 2, 3, 4, 5, 6, 7}, "www.escalate.example"), 50401); v != engine.VerdictAccept {
t.Fatalf("the learning set leaves QUIC alone, got %v", v)
}
if escalated {
w.destState.SetEscalation("www.escalate.example", fallback.Id, escalateReasonStall, time.Minute)
}
hello := buildQUICInitialWithSNI(t, []byte{0xa1, 1, 2, 3, 4, 5, 6, 7}, "www.escalate.example")
v := sendQUIC(w, hello, 50402)
got := sent()
if !escalated {
if v != engine.VerdictAccept || len(got) != 0 {
t.Errorf("without escalation the learning set leaves the ClientHello datagram alone, got %v and %d packets", v, len(got))
}
continue
}
if v != engine.VerdictDrop || len(got) != 1 || len(got[0]) <= 28+len(hello) {
t.Errorf("after escalation to a coalescing set, a ClientHello datagram to the learned address must go out coalesced, got %v and %d packets", v, len(got))
}
}
}
func TestAStaticAddressSetKeepsTheLaterDatagramsOfAQUICFlow(t *testing.T) {
named := newDomainOnlyQUICSet(config.UDPModeDrop)
static := config.NewSetConfig()

View file

@ -0,0 +1,42 @@
package nfq
import (
"syscall"
"testing"
"github.com/daniellavrushin/b4/sock"
)
func captureInjected(t *testing.T, w *Worker) func() [][]byte {
t.Helper()
pair := func() [2]int {
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_SEQPACKET|syscall.SOCK_CLOEXEC, 0)
if err != nil {
t.Fatalf("socketpair: %v", err)
}
return [2]int{fds[0], fds[1]}
}
v4, v6 := pair(), pair()
w.sock = sock.NewSenderWithFDs(v4[0], v6[0])
t.Cleanup(func() {
w.wg.Wait()
for _, fd := range []int{v4[0], v4[1], v6[0], v6[1]} {
_ = syscall.Close(fd)
}
})
return func() [][]byte {
w.wg.Wait()
var sent [][]byte
buf := make([]byte, 65536)
for _, fd := range []int{v4[1], v6[1]} {
for {
n, _, err := syscall.Recvfrom(fd, buf, syscall.MSG_DONTWAIT)
if err != nil {
break
}
sent = append(sent, append([]byte(nil), buf[:n]...))
}
}
return sent
}
}

View file

@ -149,10 +149,15 @@ func (w *Worker) sendFakeSynWithMD5(set *config.SetConfig, raw []byte, ihl int,
sock.FixIPv4Checksum(fakeSyn[:ihl])
sock.FixTCPChecksum(fakeSyn)
// Add MD5 option
fakeSyn = sock.AddTCPMD5Option(fakeSyn, false)
signed, ok := sock.AddTCPMD5Option(fakeSyn, false)
if !ok {
signed, ok = sock.AddTCPMD5Option(withoutTCPOptions(fakeSyn, ihl), false)
}
if !ok {
return
}
_ = w.sock.SendIPv4(fakeSyn, dst)
_ = w.sock.SendIPv4(signed, dst)
}
func (w *Worker) sendFakeSynWithMD5V6(set *config.SetConfig, raw []byte, dst net.IP) {
@ -172,8 +177,54 @@ func (w *Worker) sendFakeSynWithMD5V6(set *config.SetConfig, raw []byte, dst net
seq := binary.BigEndian.Uint32(fakeSyn[ipv6HdrLen+4 : ipv6HdrLen+8])
binary.BigEndian.PutUint32(fakeSyn[ipv6HdrLen+4:ipv6HdrLen+8], seq-10000)
// Add MD5 option (also fixes checksums)
fakeSyn = sock.AddTCPMD5Option(fakeSyn, true)
signed, ok := sock.AddTCPMD5Option(fakeSyn, true)
if !ok {
signed, ok = sock.AddTCPMD5Option(withoutTCPOptions(fakeSyn, ipv6HdrLen), true)
}
if !ok {
return
}
_ = w.sock.SendIPv6(fakeSyn, dst)
_ = w.sock.SendIPv6(signed, dst)
}
func withoutTCPOptions(pkt []byte, l3Len int) []byte {
if len(pkt) < l3Len+20 {
return pkt
}
tcpHL := int(pkt[l3Len+12]>>4) * 4
if tcpHL < 20 || len(pkt) < l3Len+tcpHL {
return pkt
}
out := make([]byte, len(pkt)-tcpHL+20)
copy(out, pkt[:l3Len+20])
copy(out[l3Len+20:], pkt[l3Len+tcpHL:])
out[l3Len+12] = 5<<4 | out[l3Len+12]&0x0f
if out[0]>>4 == IPv6 {
binary.BigEndian.PutUint16(out[4:6], uint16(len(out)-IPv6HeaderLen))
sock.FixTCPChecksumV6(out)
} else {
binary.BigEndian.PutUint16(out[2:4], uint16(len(out)))
sock.FixIPv4Checksum(out[:l3Len])
sock.FixTCPChecksum(out)
}
return out
}
func synWithoutPayload(raw []byte, ver byte, l3Len, tcpHdrLen int) ([]byte, bool) {
end := l3Len + tcpHdrLen
if tcpHdrLen < TCPHeaderMinLen || len(raw) <= end || (ver == IPv6 && !hasPlainIPv6Header(raw, ipProtoTCP)) {
return nil, false
}
syn := make([]byte, end)
copy(syn, raw[:end])
if ver == IPv4 {
binary.BigEndian.PutUint16(syn[2:4], uint16(end))
sock.FixIPv4Checksum(syn[:l3Len])
sock.FixTCPChecksum(syn)
} else {
binary.BigEndian.PutUint16(syn[4:6], uint16(end-IPv6HeaderLen))
sock.FixTCPChecksumV6(syn)
}
return syn, true
}

116
src/nfq/tcpheader_test.go Normal file
View file

@ -0,0 +1,116 @@
package nfq
import (
"fmt"
"net"
"testing"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/sock"
)
func mustNotPanic(t *testing.T, what string, f func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Errorf("%s panicked: %v", what, r)
}
}()
f()
}
func packetWithDataOffset(ver byte, words, flags byte) []byte {
if ver == IPv4 {
pkt := makeV4TCPPacket(make([]byte, 64), 1000)
pkt[20+12] = words << 4
pkt[20+13] = flags
return pkt
}
pkt := makeV6TCPPacket(make([]byte, 64), 1000)
pkt[IPv6HeaderLen+12] = words << 4
pkt[IPv6HeaderLen+13] = flags
return pkt
}
func TestAPacketWithATCPHeaderShorterThan20BytesPassesUntouched(t *testing.T) {
type scenario struct {
name string
flags byte
apply func(*config.SetConfig)
}
scenarios := []scenario{
{"dropsack syn", 0x02, func(s *config.SetConfig) { s.TCP.DropSACK = true }},
{"syn fake", 0x02, func(s *config.SetConfig) { s.TCP.SynFake = true }},
{"md5 syn", 0x02, func(s *config.SetConfig) { s.Faking.TCPMD5 = true }},
{"window", 0x18, func(s *config.SetConfig) { s.TCP.Win.Mode = "oscillate" }},
}
for _, strategy := range []string{"tcp", "ip", "oob", "tls", "disorder", "extsplit", "firstbyte", "combo", "hybrid"} {
scenarios = append(scenarios, scenario{"strategy " + strategy, 0x18, func(s *config.SetConfig) { s.Fragmentation.Strategy = strategy }})
}
for words := byte(0); words < 5; words++ {
for _, ver := range []byte{IPv4, IPv6} {
for _, sc := range scenarios {
t.Run(fmt.Sprintf("v%d/offset%d/%s", ver, words, sc.name), func(t *testing.T) {
set := newStaticAddressSet(true)
set.Targets.IpsToMatch = []string{"1.2.3.4", "2001:db8::2"}
sc.apply(&set)
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
mustNotPanic(t, "ProcessPacket", func() {
if v := w.ProcessPacket(packetWithDataOffset(ver, words, sc.flags)); v != engine.VerdictAccept {
t.Errorf("a packet with a %d-byte TCP header must pass untouched, got %v", int(words)*4, v)
}
})
if got := sent(); len(got) != 0 {
t.Errorf("nothing may be injected for a malformed header, sent %d packets", len(got))
}
})
}
}
}
}
func TestRewritersRefuseATCPHeaderShorterThan20Bytes(t *testing.T) {
for words := byte(0); words < 5; words++ {
for _, ver := range []byte{IPv4, IPv6} {
t.Run(fmt.Sprintf("v%d/offset%d", ver, words), func(t *testing.T) {
l3 := 20
if ver == IPv6 {
l3 = IPv6HeaderLen
}
syn := packetWithDataOffset(ver, words, 0x02)
mustNotPanic(t, "synWithoutPayload", func() {
if _, ok := synWithoutPayload(syn, ver, l3, int(words)*4); ok {
t.Error("synWithoutPayload must refuse a TCP header shorter than 20 bytes")
}
})
mustNotPanic(t, "AddTCPMD5Option", func() {
if _, ok := sock.AddTCPMD5Option(syn, ver == IPv6); ok {
t.Error("AddTCPMD5Option must refuse a TCP header shorter than 20 bytes")
}
})
set := newFragmentingYTImagesSet()
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
data := packetWithDataOffset(ver, words, 0x18)
for _, mode := range []string{"oscillate", "random", "escalate", "zero"} {
set.TCP.Win.Mode = mode
mustNotPanic(t, "window manipulation "+mode, func() {
if ver == IPv4 {
w.ManipulateWindowIPv4(&set, data, net.IP{1, 2, 3, 4})
} else {
w.ManipulateWindowIPv6(&set, data, net.ParseIP("2001:db8::2"))
}
})
}
if got := sent(); len(got) != 0 {
t.Errorf("window manipulation must send nothing for a malformed header, sent %d packets", len(got))
}
})
}
}
}

439
src/nfq/tfo_test.go Normal file
View file

@ -0,0 +1,439 @@
package nfq
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/engine"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/sock"
)
var fastOpenSynOptions = []byte{
0x02, 0x04, 0x05, 0xb4,
0x04, 0x02,
0x08, 0x0a, 0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00,
0x01,
0x03, 0x03, 0x07,
0x22, 0x0a, 0xc0, 0x0c, 0x1e, 0xc0, 0x0c, 0x1e, 0xc0, 0x0c,
0x01, 0x01,
}
func fastOpenSyn(ver byte, payload []byte, isn uint32) []byte {
return synWithOptions(ver, fastOpenSynOptions, payload, isn)
}
func synWithOptions(ver byte, opts, payload []byte, isn uint32) []byte {
segment := append(append([]byte(nil), opts...), payload...)
doff := byte((20+len(opts))/4) << 4
if ver == IPv4 {
pkt := makeV4TCPPacket(segment, isn)
pkt[20+12] = doff
pkt[20+13] = 0x02
sock.FixIPv4Checksum(pkt[:20])
sock.FixTCPChecksum(pkt)
return pkt
}
pkt := makeV6TCPPacket(segment, isn)
pkt[IPv6HeaderLen+12] = doff
pkt[IPv6HeaderLen+13] = 0x02
sock.FixTCPChecksumV6(pkt)
return pkt
}
func newDualStackInjectingWorker(t *testing.T, sets ...*config.SetConfig) *Worker {
t.Helper()
cfg := config.NewConfig()
cfg.Sets = sets
cfg.Queue.IPv6Enabled = true
w := newTestWorker(t, &cfg)
w.sock = &sock.Sender{}
t.Cleanup(w.wg.Wait)
return w
}
func ipv4HeaderChecksumValid(h []byte) bool {
var sum uint32
for i := 0; i+1 < len(h); i += 2 {
sum += uint32(binary.BigEndian.Uint16(h[i : i+2]))
}
for sum>>16 != 0 {
sum = sum&0xffff + sum>>16
}
return sum == 0xffff
}
func tcpOptionKinds(pkt []byte) []byte {
l3 := int(pkt[0]&0x0f) * 4
if pkt[0]>>4 == IPv6 {
l3 = IPv6HeaderLen
}
opts := pkt[l3+20 : l3+int(pkt[l3+12]>>4)*4]
var kinds []byte
for i := 0; i < len(opts) && opts[i] != 0; {
if opts[i] == 1 {
i++
continue
}
kinds = append(kinds, opts[i])
if i+1 >= len(opts) || opts[i+1] < 2 {
break
}
i += int(opts[i+1])
}
return kinds
}
func assertSynWithoutPayload(t *testing.T, got, orig []byte) {
t.Helper()
v4 := orig[0]>>4 == IPv4
l3 := IPv6HeaderLen
if v4 {
l3 = int(orig[0]&0x0f) * 4
}
tcpHL := int(orig[l3+12]>>4) * 4
if len(got) != l3+tcpHL {
t.Fatalf("the SYN must keep its headers and lose its payload: %d bytes, want %d", len(got), l3+tcpHL)
}
for i := range got {
lengthField := (v4 && (i == 2 || i == 3)) || (!v4 && (i == 4 || i == 5))
checksum := (v4 && (i == 10 || i == 11)) || i == l3+16 || i == l3+17
if !lengthField && !checksum && got[i] != orig[i] {
t.Fatalf("header byte %d changed: %#x, want %#x", i, got[i], orig[i])
}
}
if v4 {
if n := int(binary.BigEndian.Uint16(got[2:4])); n != len(got) {
t.Errorf("IPv4 total length %d, want %d", n, len(got))
}
if !ipv4HeaderChecksumValid(got[:l3]) || !sock.TCPChecksumValid(got) {
t.Error("the stripped SYN must carry valid checksums")
}
return
}
if n := int(binary.BigEndian.Uint16(got[4:6])); n != tcpHL {
t.Errorf("IPv6 payload length %d, want %d", n, tcpHL)
}
if !sock.TCPChecksumValidV6(got) {
t.Error("the stripped SYN must carry a valid checksum")
}
}
func TestEveryStrategySendsAFastOpenSynWithoutItsPayload(t *testing.T) {
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
for _, strategy := range []string{"tcp", "ip", "oob", "tls", "disorder", "extsplit", "firstbyte", "combo", "hybrid"} {
for _, ver := range []byte{IPv4, IPv6} {
name, l3 := strategy+"/v4", 20
if ver == IPv6 {
name, l3 = strategy+"/v6", IPv6HeaderLen
}
t.Run(name, func(t *testing.T) {
set := newFragmentingYTImagesSet()
set.Fragmentation.Strategy = strategy
data := fastOpenSyn(ver, hello, 1000)
data[l3+13] = 0x18
control := newDualStackInjectingWorker(t, &set)
controlSent := captureInjected(t, control)
if v := control.ProcessPacket(data); v != engine.VerdictDrop || len(controlSent()) == 0 {
t.Fatalf("the ClientHello as a data segment must get the set's strategy, got %v", v)
}
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
syn := fastOpenSyn(ver, hello, 1000)
if v := w.ProcessPacket(syn); v != engine.VerdictDrop {
t.Fatalf("a fast-open SYN of a set that processes the hello must be taken over, got %v", v)
}
got := sent()
if len(got) != 1 {
t.Fatalf("a fast-open SYN must go out as one SYN, got %d packets", len(got))
}
assertSynWithoutPayload(t, got[0], syn)
})
}
}
}
func TestTheHelloResentAfterAStrippedSynGetsTheSetsStrategy(t *testing.T) {
set := newFragmentingYTImagesSet()
set.TCP.IPBlockDetect.Enabled = true
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
ch, _ := log.GetConnectionHub().Subscribe()
defer log.GetConnectionHub().Unsubscribe(ch)
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
const isn = uint32(1000)
syn := fastOpenSyn(IPv4, hello, isn)
w.ProcessPacket(syn)
if got := loggedSet(t, ch); got != set.Name {
t.Fatalf("the fast-open SYN row: want set %q, got %q", set.Name, got)
}
if got := sent(); len(got) != 1 {
t.Fatalf("the fast-open SYN must go out as one SYN, got %d packets", len(got))
} else {
assertSynWithoutPayload(t, got[0], syn)
}
if n := len(w.destState.conns); n != 0 {
t.Errorf("a ClientHello cut off the SYN never reaches the server and must not be counted, got %d tracked connections", n)
}
if v := w.ProcessPacket(makeV4TCPPacketFlags(hello, isn+1, 0x18)); v != engine.VerdictDrop {
t.Fatalf("the ClientHello the client resends after the handshake must get the set's strategy, got %v", v)
}
if got := loggedSet(t, ch); got != set.Name {
t.Errorf("the resent ClientHello row: want set %q, got %q", set.Name, got)
}
pieces := sent()
if len(pieces) != 2 {
t.Fatalf("the tcp strategy must split the resent ClientHello in two, got %d packets", len(pieces))
}
for _, p := range pieces {
if p[20+13]&0x02 != 0 {
t.Error("a piece of the resent ClientHello must not be a SYN")
}
}
if e := w.destState.conns["10.0.0.1:12345->1.2.3.4:443"]; e == nil || e.retransmits != 1 {
t.Errorf("the ClientHello the SYN carried never reached the server and must not count as a retransmission, got %+v", e)
}
}
func TestAFastOpenSynThatKeepsItsHelloCountsForStallDetection(t *testing.T) {
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
ext := makeV6ExtHeader(6, 8)
extSyn := makeV6TCPPacketWithExt(hello, 1000, 60, ext)
extSyn[IPv6HeaderLen+len(ext)+13] = 0x02
detecting := newPassiveSet()
detecting.TCP.IPBlockDetect.Enabled = true
escalating := newPassiveSet()
escalating.Escalate.To = "next-set"
behindExt := newFragmentingYTImagesSet()
behindExt.TCP.IPBlockDetect.Enabled = true
cases := []struct {
name string
set config.SetConfig
syn []byte
}{
{"a set that leaves the hello alone, IP block detection", detecting, fastOpenSyn(IPv4, hello, 1000)},
{"a set that leaves the hello alone, escalation", escalating, fastOpenSyn(IPv4, hello, 1000)},
{"IPv6 extension headers, IP block detection", behindExt, extSyn},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
set := tc.set
w := newDualStackInjectingWorker(t, &set)
if v := w.ProcessPacket(tc.syn); v != engine.VerdictAccept {
t.Fatalf("this fast-open SYN must go out with its ClientHello, got %v", v)
}
if len(w.destState.conns) != 1 {
t.Fatalf("a ClientHello that reaches the server in the SYN must be counted once, got %d tracked connections", len(w.destState.conns))
}
for key, e := range w.destState.conns {
if e.retransmits != 1 {
t.Errorf("%s: counted %d times, want 1", key, e.retransmits)
}
}
})
}
}
func fillInjectionBacklog(t *testing.T) {
t.Helper()
added := 0
for len(injectInflight) < cap(injectInflight) {
injectInflight <- struct{}{}
added++
}
t.Cleanup(func() {
for i := 0; i < added; i++ {
<-injectInflight
}
})
}
func TestAFullInjectionBacklogStillStripsAFastOpenSynAndCountsWhatGoesOutUnmodified(t *testing.T) {
set := newFragmentingYTImagesSet()
set.TCP.IPBlockDetect.Enabled = true
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
fillInjectionBacklog(t)
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
syn := fastOpenSyn(IPv4, hello, 1000)
if v := w.ProcessPacket(syn); v != engine.VerdictDrop {
t.Fatalf("a full injection backlog must not let the fast-open SYN out with its ClientHello, got %v", v)
}
if got := sent(); len(got) != 1 {
t.Fatalf("the fast-open SYN must go out as one SYN, got %d packets", len(got))
} else {
assertSynWithoutPayload(t, got[0], syn)
}
if n := len(w.destState.conns); n != 0 {
t.Errorf("the ClientHello cut off the SYN must not be counted, got %d tracked connections", n)
}
if v := w.ProcessPacket(makeV4TCPPacketFlags(hello, 1001, 0x18)); v != engine.VerdictAccept {
t.Fatalf("with the backlog full the resent ClientHello goes out unmodified, got %v", v)
}
if e := w.destState.conns["10.0.0.1:12345->1.2.3.4:443"]; e == nil || e.retransmits != 1 {
t.Errorf("a ClientHello that goes out unmodified must be counted once, got %+v", e)
}
}
func TestAFastOpenSynKeepsItsPayloadWhenTheSetLeavesTheHelloAlone(t *testing.T) {
set := newPassiveSet()
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
if v := w.ProcessPacket(fastOpenSyn(IPv4, buildClientHello("i.ytimg.com", 16, 0xAB), 1000)); v != engine.VerdictAccept {
t.Errorf("a set that leaves the ClientHello alone must leave the fast-open SYN alone, got %v", v)
}
if got := sent(); len(got) != 0 {
t.Errorf("nothing may be injected for it, got %d packets", len(got))
}
}
func TestAFastOpenSynBehindIPv6ExtensionHeadersGoesOutUnchanged(t *testing.T) {
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
ext := makeV6ExtHeader(6, 8)
set := newFragmentingYTImagesSet()
control := newDualStackInjectingWorker(t, &set)
controlSent := captureInjected(t, control)
if v := control.ProcessPacket(makeV6TCPPacketWithExt(hello, 1000, 60, ext)); v != engine.VerdictDrop || len(controlSent()) != 1 {
t.Fatalf("the ClientHello as data behind an extension header must reach the injector, got %v", v)
}
syn := makeV6TCPPacketWithExt(hello, 1000, 60, ext)
syn[IPv6HeaderLen+len(ext)+13] = 0x02
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
if v := w.ProcessPacket(syn); v != engine.VerdictAccept {
t.Errorf("a fast-open SYN whose payload cannot be cut off must go out as the client sent it, got %v", v)
}
if got := sent(); len(got) != 0 {
t.Errorf("nothing may be injected for it, got %d packets", len(got))
}
}
func TestDropSACKTreatsAFastOpenSynLikeABareSyn(t *testing.T) {
for _, ver := range []byte{IPv4, IPv6} {
set := newStaticAddressSet(true)
set.Targets.IpsToMatch = []string{"1.2.3.4", "2001:db8::2"}
set.TCP.DropSACK = true
var out [][]byte
for _, payload := range [][]byte{nil, buildClientHello("i.ytimg.com", 16, 0xAB)} {
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
if v := w.ProcessPacket(fastOpenSyn(ver, payload, 1000)); v != engine.VerdictDrop {
t.Fatalf("v%d payload %d: a DropSACK set rewrites the SYN, got %v", ver, len(payload), v)
}
got := sent()
if len(got) != 1 {
t.Fatalf("v%d payload %d: want one SYN, got %d packets", ver, len(payload), len(got))
}
out = append(out, got[0])
}
if bytes.IndexByte(tcpOptionKinds(out[0]), 4) >= 0 {
t.Errorf("v%d: DropSACK must take SACK-permitted off a bare SYN", ver)
}
if !bytes.Equal(out[0], out[1]) {
t.Errorf("v%d: a fast-open SYN must leave as the same SYN a bare one does:\n bare %x\n tfo %x", ver, out[0], out[1])
}
}
}
func TestTheSynFakePathSendsAFastOpenSynWithoutItsPayload(t *testing.T) {
set := newStaticAddressSet(true)
set.TCP.SynFake = true
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
syn := fastOpenSyn(IPv4, buildClientHello("i.ytimg.com", 16, 0xAB), 1000)
if v := w.ProcessPacket(syn); v != engine.VerdictDrop {
t.Fatalf("the SYN fake path takes the SYN over, got %v", v)
}
got := sent()
if len(got) != 2 {
t.Fatalf("want the fake SYN and the real one, got %d packets", len(got))
}
if sock.TCPChecksumValid(got[0]) || len(got[0]) != 20+20+len(fastOpenSynOptions) {
t.Error("the fake SYN keeps its broken checksum and carries no ClientHello")
}
assertSynWithoutPayload(t, got[1], syn)
}
func TestTheMD5FakeSynAlwaysCarriesTheMD5Option(t *testing.T) {
roomy := []byte{0x02, 0x04, 0x05, 0xb4, 0x04, 0x02, 0x08, 0x0a, 0, 0, 0, 1, 0, 0, 0, 0, 0x01, 0x03, 0x03, 0x07}
tight := []byte{0x02, 0x04, 0x05, 0xb4, 0x01, 0x03, 0x03, 0x06, 0x01, 0x01, 0x08, 0x0a, 0, 0, 0, 1, 0, 0, 0, 0, 0x04, 0x02, 0x00, 0x00}
hello := buildClientHello("i.ytimg.com", 16, 0xAB)
cases := []struct {
name string
opts, payload []byte
keepsOptions bool
}{
{"options that leave room", roomy, nil, true},
{"options ending in EOL", []byte{0x02, 0x04, 0x05, 0xb4, 0x00, 0x00, 0x00, 0x00}, nil, true},
{"24 bytes of options", tight, nil, false},
{"fast open", fastOpenSynOptions, hello, false},
}
for _, ver := range []byte{IPv4, IPv6} {
for _, tc := range cases {
t.Run(fmt.Sprintf("v%d/%s", ver, tc.name), func(t *testing.T) {
set := newStaticAddressSet(true)
set.Targets.IpsToMatch = []string{"1.2.3.4", "2001:db8::2"}
set.Faking.TCPMD5 = true
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
const isn = uint32(50000)
syn := synWithOptions(ver, tc.opts, tc.payload, isn)
if v := w.ProcessPacket(syn); v != engine.VerdictDrop {
t.Fatalf("the MD5 SYN path takes the SYN over, got %v", v)
}
got := sent()
if len(got) != 2 {
t.Fatalf("want the MD5 fake and the real SYN, got %d packets", len(got))
}
fake, real := got[0], got[1]
l3, ttl := 20, fake[8]
if ver == IPv6 {
l3, ttl = IPv6HeaderLen, fake[7]
}
if seq := binary.BigEndian.Uint32(fake[l3+4 : l3+8]); seq != isn-10000 || ttl >= 64 {
t.Errorf("the fake SYN: seq %d ttl %d, want seq %d and a low TTL", seq, ttl, isn-10000)
}
want := []byte{19}
if tc.keepsOptions {
want = append(tcpOptionKinds(syn), 19)
}
if kinds := tcpOptionKinds(fake); !bytes.Equal(kinds, want) {
t.Errorf("the fake SYN's options %v, want %v", kinds, want)
}
if len(fake) != l3+int(fake[l3+12]>>4)*4 {
t.Error("the fake SYN must not carry the ClientHello")
}
if ver == IPv4 {
if int(binary.BigEndian.Uint16(fake[2:4])) != len(fake) || !ipv4HeaderChecksumValid(fake[:l3]) || !sock.TCPChecksumValid(fake) {
t.Error("the fake SYN's length or checksums are wrong")
}
} else if int(binary.BigEndian.Uint16(fake[4:6])) != len(fake)-IPv6HeaderLen || !sock.TCPChecksumValidV6(fake) {
t.Error("the fake SYN's length or checksum is wrong")
}
if len(tc.payload) > 0 {
assertSynWithoutPayload(t, real, syn)
} else if !bytes.Equal(real, syn) {
t.Error("a SYN without payload must go out unchanged")
}
})
}
}
}

View file

@ -44,7 +44,7 @@ func (w *Worker) ManipulateWindowIPv4(cfg *config.SetConfig, packet []byte, dst
}
tcpHdrLen := int((packet[ipHdrLen+12] >> 4) * 4)
if len(packet) < ipHdrLen+tcpHdrLen {
if tcpHdrLen < TCPHeaderMinLen || len(packet) < ipHdrLen+tcpHdrLen {
return
}
@ -64,14 +64,20 @@ func (w *Worker) ManipulateWindowIPv4(cfg *config.SetConfig, packet []byte, dst
}
}
func windowFake(packet []byte, l3Len int) []byte {
end := l3Len + int(packet[l3Len+12]>>4)*4
fake := make([]byte, end)
copy(fake, packet[:end])
return fake
}
// sendOscillatingWindows sends fake packets with oscillating window sizes
func (w *Worker) sendOscillatingWindows(packet []byte, dst net.IP, ipHdrLen int, wm *WindowManipulator) {
log.Tracef("Window manipulation: oscillating mode")
// Send fake packets with different windows BEFORE real packet
for i, winSize := range wm.values {
fake := make([]byte, ipHdrLen+20) // Just headers, no payload for fakes
copy(fake, packet[:ipHdrLen+20])
fake := windowFake(packet, ipHdrLen)
// Set window size
binary.BigEndian.PutUint16(fake[ipHdrLen+14:ipHdrLen+16], uint16(winSize))
@ -143,8 +149,7 @@ func (w *Worker) sendRandomWindows(packet []byte, dst net.IP, ipHdrLen int, wm *
numFakes := 3 + r.Intn(3)
for i := 0; i < numFakes; i++ {
fake := make([]byte, ipHdrLen+20)
copy(fake, packet[:ipHdrLen+20])
fake := windowFake(packet, ipHdrLen)
// Random window from configured values or fully random
var winSize uint16
@ -181,8 +186,7 @@ func (w *Worker) sendEscalatingWindows(packet []byte, dst net.IP, ipHdrLen int)
windows := []uint16{0, 100, 500, 1460, 8192, 32768, 65535}
for i, win := range windows {
fake := make([]byte, ipHdrLen+20)
copy(fake, packet[:ipHdrLen+20])
fake := windowFake(packet, ipHdrLen)
binary.BigEndian.PutUint16(fake[ipHdrLen+14:ipHdrLen+16], win)
@ -220,6 +224,11 @@ func (w *Worker) ManipulateWindowIPv6(cfg *config.SetConfig, packet []byte, dst
return
}
tcpHdrLen := int(packet[ipv6HdrLen+12]>>4) * 4
if tcpHdrLen < TCPHeaderMinLen || len(packet) < ipv6HdrLen+tcpHdrLen {
return
}
wm := NewWindowManipulator(&cfg.TCP)
switch wm.mode {
@ -240,8 +249,7 @@ func (w *Worker) sendOscillatingWindowsV6(packet []byte, dst net.IP, wm *WindowM
ipv6HdrLen := 40
for i, winSize := range wm.values {
fake := make([]byte, ipv6HdrLen+20)
copy(fake, packet[:ipv6HdrLen+20])
fake := windowFake(packet, ipv6HdrLen)
// Set window
binary.BigEndian.PutUint16(fake[ipv6HdrLen+14:ipv6HdrLen+16], uint16(winSize))
@ -253,7 +261,7 @@ func (w *Worker) sendOscillatingWindowsV6(packet []byte, dst net.IP, wm *WindowM
}
// Update payload length
binary.BigEndian.PutUint16(fake[4:6], 20)
binary.BigEndian.PutUint16(fake[4:6], uint16(len(fake)-ipv6HdrLen))
// ACK only
fake[ipv6HdrLen+13] = 0x10
@ -295,8 +303,7 @@ func (w *Worker) sendRandomWindowsV6(packet []byte, dst net.IP, wm *WindowManipu
numFakes := 3 + r.Intn(3)
for i := 0; i < numFakes; i++ {
fake := make([]byte, ipv6HdrLen+20)
copy(fake, packet[:ipv6HdrLen+20])
fake := windowFake(packet, ipv6HdrLen)
var winSize uint16
if len(wm.values) > 0 {
@ -311,7 +318,7 @@ func (w *Worker) sendRandomWindowsV6(packet []byte, dst net.IP, wm *WindowManipu
fake[7] = 1
}
binary.BigEndian.PutUint16(fake[4:6], 20)
binary.BigEndian.PutUint16(fake[4:6], uint16(len(fake)-ipv6HdrLen))
sock.FixTCPChecksumV6(fake)
_ = w.sock.SendIPv6(fake, dst)
@ -325,8 +332,7 @@ func (w *Worker) sendEscalatingWindowsV6(packet []byte, dst net.IP) {
windows := []uint16{0, 100, 500, 1460, 8192, 32768, 65535}
for i, win := range windows {
fake := make([]byte, ipv6HdrLen+20)
copy(fake, packet[:ipv6HdrLen+20])
fake := windowFake(packet, ipv6HdrLen)
binary.BigEndian.PutUint16(fake[ipv6HdrLen+14:ipv6HdrLen+16], win)
fake[7] = uint8(10 - i)
@ -334,7 +340,7 @@ func (w *Worker) sendEscalatingWindowsV6(packet []byte, dst net.IP) {
fake[7] = 1
}
binary.BigEndian.PutUint16(fake[4:6], 20)
binary.BigEndian.PutUint16(fake[4:6], uint16(len(fake)-ipv6HdrLen))
sock.FixTCPChecksumV6(fake)
_ = w.sock.SendIPv6(fake, dst)

80
src/nfq/win_test.go Normal file
View file

@ -0,0 +1,80 @@
package nfq
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"testing"
"github.com/daniellavrushin/b4/sock"
)
func dataPacketWithOptions(ver byte, opts []byte) []byte {
segment := append(append([]byte(nil), opts...), tlsAppData(64)...)
doff := byte((20+len(opts))/4) << 4
if ver == IPv4 {
pkt := makeV4TCPPacket(segment, 5000)
pkt[20+12] = doff
sock.FixIPv4Checksum(pkt[:20])
sock.FixTCPChecksum(pkt)
return pkt
}
pkt := makeV6TCPPacket(segment, 5000)
pkt[IPv6HeaderLen+12] = doff
sock.FixTCPChecksumV6(pkt)
return pkt
}
func TestWindowFakesCarryTheWholeTCPHeader(t *testing.T) {
timestamps := []byte{0x01, 0x01, 0x08, 0x0a, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, 0x20, 0x02}
for _, mode := range []string{"oscillate", "random", "escalate", "zero"} {
for _, ver := range []byte{IPv4, IPv6} {
for _, opts := range [][]byte{nil, timestamps} {
t.Run(fmt.Sprintf("%s/v%d/options=%d", mode, ver, len(opts)), func(t *testing.T) {
set := newFragmentingYTImagesSet()
set.TCP.Win.Mode = mode
w := newDualStackInjectingWorker(t, &set)
sent := captureInjected(t, w)
pkt := dataPacketWithOptions(ver, opts)
l3 := 20
if ver == IPv4 {
w.ManipulateWindowIPv4(&set, pkt, net.IP{1, 2, 3, 4})
} else {
l3 = IPv6HeaderLen
w.ManipulateWindowIPv6(&set, pkt, net.ParseIP("2001:db8::2"))
}
tcpHL := 20 + len(opts)
want := l3 + tcpHL
if mode == "zero" {
want = len(pkt)
}
fakes := sent()
if len(fakes) == 0 {
t.Fatal("window manipulation sent nothing")
}
for i, f := range fakes {
if len(f) != want {
t.Fatalf("fake %d: %d bytes, want %d", i, len(f), want)
}
if hl := int(f[l3+12]>>4) * 4; hl != tcpHL {
t.Fatalf("fake %d: TCP header length %d, want %d", i, hl, tcpHL)
}
if !bytes.Equal(f[l3+20:l3+tcpHL], pkt[l3+20:l3+tcpHL]) {
t.Errorf("fake %d: the TCP options must be the original's", i)
}
if ver == IPv4 {
if n := int(binary.BigEndian.Uint16(f[2:4])); n != len(f) || !ipv4HeaderChecksumValid(f[:l3]) || !sock.TCPChecksumValid(f) {
t.Errorf("fake %d: total length %d of %d bytes, or a bad checksum", i, n, len(f))
}
} else if n := int(binary.BigEndian.Uint16(f[4:6])); n != len(f)-IPv6HeaderLen || !sock.TCPChecksumValidV6(f) {
t.Errorf("fake %d: payload length %d of %d bytes, or a bad checksum", i, n, len(f))
}
}
})
}
}
}
}

View file

@ -255,7 +255,10 @@ func TestBadChecksumSurvivesTheMD5Option(t *testing.T) {
if !badsum {
t.Fatal("tcp_check must produce a bad checksum")
}
signed := AddTCPMD5Option(fake, false)
signed, ok := AddTCPMD5Option(fake, false)
if !ok {
t.Fatal("a 20-byte TCP header has room for the MD5 option")
}
if !TCPChecksumValid(signed) {
t.Fatal("AddTCPMD5Option is expected to recompute the checksum; the caller must corrupt it again")
}

View file

@ -113,6 +113,10 @@ func NewSender(mark int) (*Sender, error) {
return NewSenderWithMark(mark)
}
func NewSenderWithFDs(fd4, fd6 int) *Sender {
return &Sender{fd4: fd4, fd6: fd6}
}
func (s *Sender) SendIPv4(packet []byte, destIP net.IP) error {
if log.Level(log.CurLevel.Load()) >= log.LevelTrace {
log.Tracef("Sending IPv4 packet to %s, len=%d", destIP.String(), len(packet))

View file

@ -5,7 +5,7 @@ import (
"encoding/binary"
)
func AddTCPMD5Option(packet []byte, isIPv6 bool) []byte {
func AddTCPMD5Option(packet []byte, isIPv6 bool) ([]byte, bool) {
var ipHdrLen int
if isIPv6 {
ipHdrLen = 40
@ -14,11 +14,14 @@ func AddTCPMD5Option(packet []byte, isIPv6 bool) []byte {
}
if len(packet) < ipHdrLen+20 {
return packet
return packet, false
}
tcpHdrLen := int((packet[ipHdrLen+12] >> 4) * 4)
payloadStart := ipHdrLen + tcpHdrLen
if tcpHdrLen < 20 || len(packet) < payloadStart {
return packet, false
}
md5Opt := make([]byte, 20)
md5Opt[0] = 1
@ -29,7 +32,7 @@ func AddTCPMD5Option(packet []byte, isIPv6 bool) []byte {
newTCPHdrLen := tcpHdrLen + 20
if newTCPHdrLen > 60 {
return packet
return packet, false
}
newPacket := make([]byte, len(packet)+20)
@ -39,6 +42,9 @@ func AddTCPMD5Option(packet []byte, isIPv6 bool) []byte {
if tcpHdrLen > 20 {
copy(newPacket[ipHdrLen+20:ipHdrLen+tcpHdrLen], packet[ipHdrLen+20:ipHdrLen+tcpHdrLen])
if !prepareOptionsForAppend(newPacket[ipHdrLen+20 : ipHdrLen+tcpHdrLen]) {
return packet, false
}
}
copy(newPacket[ipHdrLen+tcpHdrLen:ipHdrLen+tcpHdrLen+20], md5Opt)
@ -58,5 +64,25 @@ func AddTCPMD5Option(packet []byte, isIPv6 bool) []byte {
FixTCPChecksum(newPacket)
}
return newPacket
return newPacket, true
}
func prepareOptionsForAppend(opts []byte) bool {
for i := 0; i < len(opts); {
switch opts[i] {
case 0:
for j := i; j < len(opts); j++ {
opts[j] = 1
}
return true
case 1:
i++
default:
if i+1 >= len(opts) || opts[i+1] < 2 || i+int(opts[i+1]) > len(opts) {
return false
}
i += int(opts[i+1])
}
}
return true
}

136
src/sock/tcp_md5_test.go Normal file
View file

@ -0,0 +1,136 @@
package sock
import (
"bytes"
"encoding/binary"
"testing"
)
func withTCPOptions(pkt []byte, l3 int, opts []byte) []byte {
out := append(append(append([]byte(nil), pkt[:l3+20]...), opts...), pkt[l3+20:]...)
out[l3+12] = byte((20+len(opts))/4) << 4
if l3 == 40 {
binary.BigEndian.PutUint16(out[4:6], uint16(len(out)-40))
FixTCPChecksumV6(out)
} else {
binary.BigEndian.PutUint16(out[2:4], uint16(len(out)))
FixIPv4Checksum(out[:20])
FixTCPChecksum(out)
}
return out
}
func TestAddTCPMD5OptionAddsTheOptionOrSaysItCannot(t *testing.T) {
cases := []struct {
name string
v6 bool
opts int
fits bool
}{
{"v4 without options", false, 0, true},
{"v4 with 20 bytes of options", false, 20, true},
{"v4 with 24 bytes of options", false, 24, false},
{"v6 without options", true, 0, true},
{"v6 with 24 bytes of options", true, 24, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
base, l3 := buildMinimalIPv4TCPPacket(30), 20
if tc.v6 {
base, l3 = buildMinimalIPv6TCPPacket(30), 40
}
pkt := withTCPOptions(base, l3, bytes.Repeat([]byte{0x01}, tc.opts))
got, ok := AddTCPMD5Option(pkt, tc.v6)
if ok != tc.fits {
t.Fatalf("ok=%v, want %v", ok, tc.fits)
}
if !tc.fits {
if !bytes.Equal(got, pkt) {
t.Error("a packet without room for the option must come back unchanged")
}
return
}
hdr := l3 + 20 + tc.opts
if len(got) != len(pkt)+20 || int(got[l3+12]>>4)*4 != 20+tc.opts+20 {
t.Fatalf("want 20 more option bytes, got %d bytes with a %d-byte TCP header", len(got), int(got[l3+12]>>4)*4)
}
if !bytes.Equal(got[hdr:hdr+4], []byte{1, 1, 19, 18}) {
t.Errorf("the MD5 option is missing: %x", got[hdr:hdr+4])
}
if !bytes.Equal(got[hdr+20:], pkt[hdr:]) {
t.Error("the payload must follow the option unchanged")
}
if tc.v6 {
if int(binary.BigEndian.Uint16(got[4:6])) != len(got)-40 || !TCPChecksumValidV6(got) {
t.Error("payload length or checksum is wrong")
}
} else if int(binary.BigEndian.Uint16(got[2:4])) != len(got) || !TCPChecksumValid(got) {
t.Error("total length or checksum is wrong")
}
})
}
}
func optionKindsAReceiverReads(pkt []byte, l3 int) []byte {
opts := pkt[l3+20 : l3+int(pkt[l3+12]>>4)*4]
var kinds []byte
for i := 0; i < len(opts) && opts[i] != 0; {
if opts[i] == 1 {
i++
continue
}
kinds = append(kinds, opts[i])
if i+1 >= len(opts) || opts[i+1] < 2 {
break
}
i += int(opts[i+1])
}
return kinds
}
func TestAddTCPMD5OptionStaysReadableAfterAnEndOfOptionList(t *testing.T) {
for _, v6 := range []bool{false, true} {
base, l3 := buildMinimalIPv4TCPPacket(30), 20
if v6 {
base, l3 = buildMinimalIPv6TCPPacket(30), 40
}
pkt := withTCPOptions(base, l3, []byte{0x02, 0x04, 0x05, 0xb4, 0x00, 0x00, 0x00, 0x00})
got, ok := AddTCPMD5Option(pkt, v6)
if !ok {
t.Fatalf("v6=%v: an 8-byte option list has room for the MD5 option", v6)
}
if kinds := optionKindsAReceiverReads(got, l3); !bytes.Equal(kinds, []byte{2, 19}) {
t.Errorf("v6=%v: a receiver reads options %v, want MSS and MD5", v6, kinds)
}
if !bytes.Equal(got[l3+20:l3+24], pkt[l3+20:l3+24]) || !bytes.Equal(got[l3+48:], pkt[l3+28:]) {
t.Errorf("v6=%v: the MSS option and the payload must be kept", v6)
}
}
}
func TestAddTCPMD5OptionRefusesMalformedOptions(t *testing.T) {
for _, opts := range [][]byte{{0x02, 0x00, 0x01, 0x01}, {0x02, 0x08, 0x05, 0xb4}} {
pkt := withTCPOptions(buildMinimalIPv4TCPPacket(30), 20, opts)
if got, ok := AddTCPMD5Option(pkt, false); ok || !bytes.Equal(got, pkt) {
t.Errorf("options %x: a malformed option length must be refused and the packet returned unchanged", opts)
}
}
}
func TestAddTCPMD5OptionRefusesAHeaderLongerThanThePacket(t *testing.T) {
pkt := buildMinimalIPv4TCPPacket(0)
pkt[20+12] = 0xa0
if _, ok := AddTCPMD5Option(pkt, false); ok {
t.Error("a TCP header that claims more bytes than the packet has must be refused")
}
}
func TestAddTCPMD5OptionRefusesAHeaderShorterThan20Bytes(t *testing.T) {
for words := byte(0); words < 5; words++ {
pkt := buildMinimalIPv4TCPPacket(64)
pkt[20+12] = words << 4
if got, ok := AddTCPMD5Option(pkt, false); ok || !bytes.Equal(got, pkt) {
t.Errorf("a %d-byte TCP header must be refused and returned unchanged", int(words)*4)
}
}
}