feat(tcp): add Minecraft analyzer for handshake detection
feat(tcp): implement FETAnalyzer with segment buffering feat(tcp): enhance TCP engine with window scale detection test(tcp): add tests for Minecraft and FET analyzers docs: add documentation for Minecraft analyzer and TCP evasion
This commit is contained in:
@@ -25,6 +25,27 @@ const (
|
||||
tcpVerdictDropStream = tcpVerdict(io.VerdictDropStream)
|
||||
)
|
||||
|
||||
const (
|
||||
// tcpMetaPropKey is the namespace under which the engine exposes TCP
|
||||
// handshake metadata to the ruleset. Analyzers only ever see reassembled
|
||||
// payload, so header-level signals have to come from here. It must not
|
||||
// collide with the name of any analyzer.
|
||||
tcpMetaPropKey = "tcpmeta"
|
||||
// tcpSynAckWindowMax is the SYN-ACK receive window at or below which we
|
||||
// record that metadata. Real stacks advertise tens of thousands of bytes;
|
||||
// a tiny window forces the peer to split its first request across segments,
|
||||
// which defeats analyzers that classify on the first segment alone. The
|
||||
// threshold is deliberately loose so that normal connections allocate
|
||||
// nothing - the ruleset decides what actually counts as suspicious.
|
||||
tcpSynAckWindowMax = 4096
|
||||
// tcpFirstSegLenMax is the size at or below which we record the length of
|
||||
// the client's first data segment. It is the other half of the same signal:
|
||||
// whatever the peer does to the advertised window, the observable result is
|
||||
// a first request split across segments. Normal first segments run to
|
||||
// hundreds of bytes, so nothing is recorded for them.
|
||||
tcpFirstSegLenMax = 64
|
||||
)
|
||||
|
||||
type tcpContext struct {
|
||||
*gopacket.PacketMetadata
|
||||
Verdict tcpVerdict
|
||||
@@ -85,6 +106,8 @@ func (f *tcpStreamFactory) New(ipFlow, tcpFlow gopacket.Flow, tcp *layers.TCP, a
|
||||
logger: f.Logger,
|
||||
ruleset: rs,
|
||||
activeEntries: entries,
|
||||
synAckWindow: -1,
|
||||
synAckWscale: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +126,9 @@ type tcpStream struct {
|
||||
activeEntries []*tcpStreamEntry
|
||||
doneEntries []*tcpStreamEntry
|
||||
lastVerdict tcpVerdict
|
||||
firstSegSeen bool // true once the client's first data segment was measured
|
||||
synAckWindow int // window of the observed SYN-ACK, -1 if none seen yet
|
||||
synAckWscale int // its window scale factor, -1 if the option was absent
|
||||
}
|
||||
|
||||
type tcpStreamEntry struct {
|
||||
@@ -113,6 +139,12 @@ type tcpStreamEntry struct {
|
||||
}
|
||||
|
||||
func (s *tcpStream) Accept(tcp *layers.TCP, ci gopacket.CaptureInfo, dir reassembly.TCPFlowDirection, nextSeq reassembly.Sequence, start *bool, ac reassembly.AssemblerContext) bool {
|
||||
if tcp.SYN && tcp.ACK && s.synAckWindow < 0 {
|
||||
s.synAckWindow, s.synAckWscale = int(tcp.Window), tcpWindowScale(tcp)
|
||||
if s.synAckWindow <= tcpSynAckWindowMax {
|
||||
s.tcpMeta()
|
||||
}
|
||||
}
|
||||
if len(s.activeEntries) > 0 || s.virgin {
|
||||
// Make sure every stream matches against the ruleset at least once,
|
||||
// even if there are no activeEntries, as the ruleset may have built-in
|
||||
@@ -125,12 +157,69 @@ func (s *tcpStream) Accept(tcp *layers.TCP, ci gopacket.CaptureInfo, dir reassem
|
||||
}
|
||||
}
|
||||
|
||||
// tcpWindowScale returns the window scale factor a segment negotiates, or -1
|
||||
// if it carries no such option.
|
||||
func tcpWindowScale(tcp *layers.TCP) int {
|
||||
for _, opt := range tcp.Options {
|
||||
if opt.OptionType == layers.TCPOptionKindWindowScale && len(opt.OptionData) == 1 {
|
||||
return int(opt.OptionData[0])
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// tcpMeta returns the engine's TCP metadata for this stream, creating it on
|
||||
// first use. Whatever prompted the call, the handshake fields are filled in
|
||||
// too, so that a rule reading one field never trips over a missing sibling.
|
||||
// Only streams worth reporting on get here, so normal traffic allocates nothing.
|
||||
//
|
||||
// Per RFC 7323 the window scale option does not apply to the window field of
|
||||
// the SYN-ACK itself, so synack_window is a literal byte count. A stack whose
|
||||
// receive buffer really is a few bytes would not also negotiate a large scale
|
||||
// factor, which makes a small synack_window paired with a non-zero
|
||||
// synack_wscale a strong sign that the field was rewritten in flight.
|
||||
func (s *tcpStream) tcpMeta() analyzer.PropMap {
|
||||
m := s.info.Props[tcpMetaPropKey]
|
||||
if m == nil {
|
||||
m = analyzer.PropMap{}
|
||||
if s.synAckWindow >= 0 {
|
||||
m["synack_window"] = s.synAckWindow
|
||||
if s.synAckWscale >= 0 {
|
||||
m["synack_wscale"] = s.synAckWscale
|
||||
}
|
||||
}
|
||||
s.info.Props[tcpMetaPropKey] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// recordFirstSegLen notes an unusually short first data segment from the client.
|
||||
// Only the first segment counts, whatever its size, so that a short segment
|
||||
// later in the stream is never mistaken for the opening one. It reports whether
|
||||
// anything was written, so the caller knows to re-run the ruleset.
|
||||
func (s *tcpStream) recordFirstSegLen(n int) bool {
|
||||
if s.firstSegSeen {
|
||||
return false
|
||||
}
|
||||
s.firstSegSeen = true
|
||||
if n > tcpFirstSegLenMax {
|
||||
return false
|
||||
}
|
||||
s.tcpMeta()["first_seg_len"] = n
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *tcpStream) ReassembledSG(sg reassembly.ScatterGather, ac reassembly.AssemblerContext) {
|
||||
dir, start, end, skip := sg.Info()
|
||||
rev := dir == reassembly.TCPDirServerToClient
|
||||
avail, _ := sg.Lengths()
|
||||
data := sg.Fetch(avail)
|
||||
updated := false
|
||||
if !rev && len(data) > 0 {
|
||||
// Force a ruleset match if this is worth reporting, since the analyzers
|
||||
// may not produce an update of their own on this segment.
|
||||
updated = s.recordFirstSegLen(len(data))
|
||||
}
|
||||
for i := len(s.activeEntries) - 1; i >= 0; i-- {
|
||||
// Important: reverse order so we can remove entries
|
||||
entry := s.activeEntries[i]
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/apernet/OpenGFW/analyzer"
|
||||
"github.com/apernet/OpenGFW/ruleset"
|
||||
|
||||
"github.com/google/gopacket/layers"
|
||||
)
|
||||
|
||||
func newTestStream() *tcpStream {
|
||||
return &tcpStream{
|
||||
info: ruleset.StreamInfo{Props: analyzer.CombinedPropMap{}},
|
||||
synAckWindow: -1,
|
||||
synAckWscale: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPWindowScale(t *testing.T) {
|
||||
mss := layers.TCPOption{OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xb4}}
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []layers.TCPOption
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "present",
|
||||
opts: []layers.TCPOption{mss, {OptionType: layers.TCPOptionKindWindowScale, OptionLength: 3, OptionData: []byte{7}}},
|
||||
want: 7,
|
||||
},
|
||||
{name: "absent", opts: []layers.TCPOption{mss}, want: -1},
|
||||
{
|
||||
name: "malformed is ignored",
|
||||
opts: []layers.TCPOption{{OptionType: layers.TCPOptionKindWindowScale, OptionLength: 2, OptionData: nil}},
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tcpWindowScale(&layers.TCP{Options: tt.opts}); got != tt.want {
|
||||
t.Errorf("wscale = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Whichever signal materializes the prop map, the handshake fields must come
|
||||
// along with it. Otherwise a rule like `tcpmeta != nil && tcpmeta.synack_window
|
||||
// <= 16` sees a map without that key and fails at runtime with a nil comparison.
|
||||
func TestTCPMetaAlwaysCarriesHandshakeFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
window int // -1 for a stream where no SYN-ACK was observed
|
||||
wscale int
|
||||
want analyzer.PropMap
|
||||
}{
|
||||
{
|
||||
name: "normal handshake, short first segment",
|
||||
window: 64240,
|
||||
wscale: 7,
|
||||
want: analyzer.PropMap{"synack_window": 64240, "synack_wscale": 7, "first_seg_len": 21},
|
||||
},
|
||||
{
|
||||
name: "rewritten window",
|
||||
window: 1,
|
||||
wscale: 7,
|
||||
want: analyzer.PropMap{"synack_window": 1, "synack_wscale": 7, "first_seg_len": 21},
|
||||
},
|
||||
{
|
||||
name: "no window scale negotiated",
|
||||
window: 64240,
|
||||
wscale: -1,
|
||||
want: analyzer.PropMap{"synack_window": 64240, "first_seg_len": 21},
|
||||
},
|
||||
{
|
||||
name: "return path not visible",
|
||||
window: -1,
|
||||
wscale: -1,
|
||||
want: analyzer.PropMap{"first_seg_len": 21},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestStream()
|
||||
s.synAckWindow, s.synAckWscale = tt.window, tt.wscale
|
||||
if !s.recordFirstSegLen(21) {
|
||||
t.Fatal("first segment not recorded")
|
||||
}
|
||||
if got := s.info.Props[tcpMetaPropKey]; !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("props = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPRecordFirstSegLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
segLen int
|
||||
recorded bool
|
||||
}{
|
||||
{name: "one byte segment", segLen: 1, recorded: true},
|
||||
{name: "at the threshold", segLen: tcpFirstSegLenMax, recorded: true},
|
||||
{name: "just over the threshold", segLen: tcpFirstSegLenMax + 1, recorded: false},
|
||||
{name: "normal first segment", segLen: 517, recorded: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestStream()
|
||||
if got := s.recordFirstSegLen(tt.segLen); got != tt.recorded {
|
||||
t.Errorf("recorded = %v, want %v", got, tt.recorded)
|
||||
}
|
||||
m := s.info.Props[tcpMetaPropKey]
|
||||
if !tt.recorded {
|
||||
// A normal connection must not allocate a prop map at all.
|
||||
if m != nil {
|
||||
t.Errorf("props = %#v, want none", m)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := m["first_seg_len"]; got != tt.segLen {
|
||||
t.Errorf("first_seg_len = %v, want %d", got, tt.segLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPRecordFirstSegLenIsOneShot(t *testing.T) {
|
||||
s := newTestStream()
|
||||
s.recordFirstSegLen(1)
|
||||
if s.recordFirstSegLen(5) {
|
||||
t.Error("a later segment was recorded as the first one")
|
||||
}
|
||||
if got := s.info.Props[tcpMetaPropKey]["first_seg_len"]; got != 1 {
|
||||
t.Errorf("first_seg_len = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stream that opens normally must not be able to look like a split one just
|
||||
// because a short segment shows up later.
|
||||
func TestTCPNormalFirstSegmentClosesTheWindow(t *testing.T) {
|
||||
s := newTestStream()
|
||||
if s.recordFirstSegLen(517) {
|
||||
t.Fatal("a normal first segment should not be recorded")
|
||||
}
|
||||
if s.recordFirstSegLen(1) {
|
||||
t.Error("a later short segment was recorded as the first one")
|
||||
}
|
||||
if m := s.info.Props[tcpMetaPropKey]; m != nil {
|
||||
t.Errorf("props = %#v, want none", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPSynAckWindowThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
window uint16
|
||||
record bool
|
||||
}{
|
||||
{window: 1, record: true},
|
||||
{window: tcpSynAckWindowMax, record: true},
|
||||
{window: tcpSynAckWindowMax + 1, record: false},
|
||||
{window: 64240, record: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := int(tt.window) <= tcpSynAckWindowMax; got != tt.record {
|
||||
t.Errorf("window %d: recorded = %v, want %v", tt.window, got, tt.record)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user