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:
2026-07-27 01:09:41 +08:00
parent 278d731b6f
commit f6342c4bcf
11 changed files with 914 additions and 8 deletions
+89
View File
@@ -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]