release: version 1.1.15 - Fix ALPN detection for malformed TLS extensions
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
- FIX: ALPN (tls_alpn) not appearing in logs for packets with truncated extensions - Add sanitizeTLSRecord fallback in extractTLSExtensions (tlsparse/parser.go) - Mirrors sanitization already present in fingerprint/engine.go - ALPN now correctly extracted even when ParseClientHello fails on raw payload - Bump version to 1.1.15 in main.go and packaging/rpm/ja4sentinel.spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -23,7 +23,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// Version information (set via ldflags)
|
// Version information (set via ldflags)
|
||||||
Version = "1.1.11"
|
Version = "1.1.15"
|
||||||
BuildTime = "unknown"
|
BuildTime = "unknown"
|
||||||
GitCommit = "unknown"
|
GitCommit = "unknown"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -271,16 +271,17 @@ func extractIP(addr net.Addr) net.IP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildBPFFilter builds a BPF filter for the specified ports and local IPs
|
// buildBPFFilter builds a BPF filter for the specified ports and local IPs
|
||||||
// Filter: (tcp port 443 or tcp port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5)
|
// Filter: (tcp dst port 443 or tcp dst port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5)
|
||||||
|
// Uses "tcp dst port" to only capture client→server traffic (not server→client responses)
|
||||||
func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string {
|
func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string {
|
||||||
if len(ports) == 0 {
|
if len(ports) == 0 {
|
||||||
return "tcp"
|
return "tcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build port filter
|
// Build port filter (dst port only to avoid capturing server responses)
|
||||||
portParts := make([]string, len(ports))
|
portParts := make([]string, len(ports))
|
||||||
for i, port := range ports {
|
for i, port := range ports {
|
||||||
portParts[i] = fmt.Sprintf("tcp port %d", port)
|
portParts[i] = fmt.Sprintf("tcp dst port %d", port)
|
||||||
}
|
}
|
||||||
portFilter := "(" + strings.Join(portParts, ") or (") + ")"
|
portFilter := "(" + strings.Join(portParts, ") or (") + ")"
|
||||||
|
|
||||||
|
|||||||
@ -525,25 +525,25 @@ func TestCaptureImpl_buildBPFFilter(t *testing.T) {
|
|||||||
name: "single port no IPs",
|
name: "single port no IPs",
|
||||||
ports: []uint16{443},
|
ports: []uint16{443},
|
||||||
localIPs: []string{},
|
localIPs: []string{},
|
||||||
wantParts: []string{"tcp port 443"},
|
wantParts: []string{"tcp dst port 443"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single port with single IP",
|
name: "single port with single IP",
|
||||||
ports: []uint16{443},
|
ports: []uint16{443},
|
||||||
localIPs: []string{"192.168.1.10"},
|
localIPs: []string{"192.168.1.10"},
|
||||||
wantParts: []string{"tcp port 443", "dst host 192.168.1.10"},
|
wantParts: []string{"tcp dst port 443", "dst host 192.168.1.10"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple ports with multiple IPs",
|
name: "multiple ports with multiple IPs",
|
||||||
ports: []uint16{443, 8443},
|
ports: []uint16{443, 8443},
|
||||||
localIPs: []string{"192.168.1.10", "10.0.0.5"},
|
localIPs: []string{"192.168.1.10", "10.0.0.5"},
|
||||||
wantParts: []string{"tcp port 443", "tcp port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"},
|
wantParts: []string{"tcp dst port 443", "tcp dst port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPv6 address",
|
name: "IPv6 address",
|
||||||
ports: []uint16{443},
|
ports: []uint16{443},
|
||||||
localIPs: []string{"2001:db8::1"},
|
localIPs: []string{"2001:db8::1"},
|
||||||
wantParts: []string{"tcp port 443", "dst host 2001:db8::1"},
|
wantParts: []string{"tcp dst port 443", "dst host 2001:db8::1"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
package fingerprint
|
package fingerprint
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"ja4sentinel/api"
|
"ja4sentinel/api"
|
||||||
@ -29,8 +30,14 @@ func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints,
|
|||||||
// Parse the ClientHello using tlsfingerprint
|
// Parse the ClientHello using tlsfingerprint
|
||||||
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
|
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse ClientHello from %s:%d -> %s:%d (conn_id=%s, payload_len=%d): %w",
|
// Try to sanitize truncated extensions and retry
|
||||||
ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort, ch.ConnID, len(ch.Payload), err)
|
if sanitized := sanitizeClientHelloExtensions(ch.Payload); sanitized != nil {
|
||||||
|
fp, err = tlsfingerprint.ParseClientHello(sanitized)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse ClientHello from %s:%d -> %s:%d (conn_id=%s, payload_len=%d): %w",
|
||||||
|
ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort, ch.ConnID, len(ch.Payload), err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JA4 fingerprint
|
// Generate JA4 fingerprint
|
||||||
@ -67,3 +74,93 @@ func extractJA4Hash(ja4 string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeClientHelloExtensions fixes ClientHellos with truncated extension data
|
||||||
|
// by adjusting the extensions length to include only complete extensions.
|
||||||
|
// Returns a corrected copy, or nil if the payload cannot be fixed.
|
||||||
|
func sanitizeClientHelloExtensions(data []byte) []byte {
|
||||||
|
if len(data) < 5 || data[0] != 0x16 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recordLen := int(data[3])<<8 | int(data[4])
|
||||||
|
if len(data) < 5+recordLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := data[5 : 5+recordLen]
|
||||||
|
if len(payload) < 4 || payload[0] != 0x01 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
helloLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
|
||||||
|
if len(payload) < 4+helloLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hello := payload[4 : 4+helloLen]
|
||||||
|
|
||||||
|
// Skip through ClientHello fields to reach extensions
|
||||||
|
offset := 2 + 32 // version + random
|
||||||
|
if len(hello) < offset+1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
offset += 1 + int(hello[offset]) // session ID
|
||||||
|
if len(hello) < offset+2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
csLen := int(hello[offset])<<8 | int(hello[offset+1])
|
||||||
|
offset += 2 + csLen // cipher suites
|
||||||
|
if len(hello) < offset+1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
offset += 1 + int(hello[offset]) // compression methods
|
||||||
|
if len(hello) < offset+2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extLenOffset := offset // position of extensions length field
|
||||||
|
declaredExtLen := int(hello[offset])<<8 | int(hello[offset+1])
|
||||||
|
offset += 2
|
||||||
|
extStart := offset
|
||||||
|
|
||||||
|
if len(hello) < extStart+declaredExtLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extData := hello[extStart : extStart+declaredExtLen]
|
||||||
|
|
||||||
|
// Walk extensions, find how many complete ones exist
|
||||||
|
validLen := 0
|
||||||
|
pos := 0
|
||||||
|
for pos < len(extData) {
|
||||||
|
if pos+4 > len(extData) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
extBodyLen := int(extData[pos+2])<<8 | int(extData[pos+3])
|
||||||
|
if pos+4+extBodyLen > len(extData) {
|
||||||
|
break // this extension is truncated
|
||||||
|
}
|
||||||
|
pos += 4 + extBodyLen
|
||||||
|
validLen = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if validLen == declaredExtLen {
|
||||||
|
return nil // no truncation found, nothing to fix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a corrected copy with adjusted extensions length
|
||||||
|
fixed := make([]byte, len(data))
|
||||||
|
copy(fixed, data)
|
||||||
|
|
||||||
|
// Absolute offset of extensions length field within data
|
||||||
|
extLenAbs := 5 + 4 + extLenOffset
|
||||||
|
diff := declaredExtLen - validLen
|
||||||
|
|
||||||
|
// Update extensions length
|
||||||
|
binary.BigEndian.PutUint16(fixed[extLenAbs:], uint16(validLen))
|
||||||
|
// Update ClientHello handshake length
|
||||||
|
newHelloLen := helloLen - diff
|
||||||
|
fixed[5+1] = byte(newHelloLen >> 16)
|
||||||
|
fixed[5+2] = byte(newHelloLen >> 8)
|
||||||
|
fixed[5+3] = byte(newHelloLen)
|
||||||
|
// Update TLS record length
|
||||||
|
newRecordLen := recordLen - diff
|
||||||
|
binary.BigEndian.PutUint16(fixed[3:5], uint16(newRecordLen))
|
||||||
|
|
||||||
|
return fixed[:5+newRecordLen]
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"ja4sentinel/api"
|
"ja4sentinel/api"
|
||||||
|
|
||||||
|
tlsfingerprint "github.com/psanford/tlsfingerprint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromClientHello(t *testing.T) {
|
func TestFromClientHello(t *testing.T) {
|
||||||
@ -253,3 +255,132 @@ func TestFromClientHello_EmptyJA4Hash(t *testing.T) {
|
|||||||
// JA4Hash may be empty if the JA4 format doesn't include underscores
|
// JA4Hash may be empty if the JA4 format doesn't include underscores
|
||||||
// This is acceptable behavior
|
// This is acceptable behavior
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildClientHelloWithTruncatedExtension creates a ClientHello where the last
|
||||||
|
// extension declares more data than actually present.
|
||||||
|
func buildClientHelloWithTruncatedExtension() []byte {
|
||||||
|
// Build a valid SNI extension first
|
||||||
|
sniHostname := []byte("example.com")
|
||||||
|
sniExt := []byte{
|
||||||
|
0x00, 0x00, // Extension type: server_name
|
||||||
|
}
|
||||||
|
sniData := []byte{0x00}
|
||||||
|
sniListLen := 1 + 2 + len(sniHostname) // type(1) + len(2) + hostname
|
||||||
|
sniData = append(sniData, byte(sniListLen>>8), byte(sniListLen))
|
||||||
|
sniData = append(sniData, 0x00) // hostname type
|
||||||
|
sniData = append(sniData, byte(len(sniHostname)>>8), byte(len(sniHostname)))
|
||||||
|
sniData = append(sniData, sniHostname...)
|
||||||
|
sniExt = append(sniExt, byte(len(sniData)>>8), byte(len(sniData)))
|
||||||
|
sniExt = append(sniExt, sniData...)
|
||||||
|
|
||||||
|
// Build a truncated extension: declares 100 bytes but only has 5
|
||||||
|
truncatedExt := []byte{
|
||||||
|
0x00, 0x15, // Extension type: padding
|
||||||
|
0x00, 0x64, // Extension data length: 100 (but we only provide 5)
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, // Only 5 bytes of padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extensions = valid SNI + truncated padding
|
||||||
|
extensions := append(sniExt, truncatedExt...)
|
||||||
|
// But the extensions length field claims the full size (including the bad extension)
|
||||||
|
extLen := len(extensions)
|
||||||
|
|
||||||
|
// Cipher suites
|
||||||
|
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
|
||||||
|
compressionMethods := []byte{0x01, 0x00}
|
||||||
|
|
||||||
|
handshakeBody := []byte{0x03, 0x03}
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
handshakeBody = append(handshakeBody, 0x01)
|
||||||
|
}
|
||||||
|
handshakeBody = append(handshakeBody, 0x00) // session ID length: 0
|
||||||
|
handshakeBody = append(handshakeBody, byte(len(cipherSuites)>>8), byte(len(cipherSuites)))
|
||||||
|
handshakeBody = append(handshakeBody, cipherSuites...)
|
||||||
|
handshakeBody = append(handshakeBody, compressionMethods...)
|
||||||
|
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
|
||||||
|
handshakeBody = append(handshakeBody, extensions...)
|
||||||
|
|
||||||
|
handshakeLen := len(handshakeBody)
|
||||||
|
handshake := append([]byte{
|
||||||
|
0x01,
|
||||||
|
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen),
|
||||||
|
}, handshakeBody...)
|
||||||
|
|
||||||
|
recordLen := len(handshake)
|
||||||
|
record := make([]byte, 5+recordLen)
|
||||||
|
record[0] = 0x16
|
||||||
|
record[1] = 0x03
|
||||||
|
record[2] = 0x03
|
||||||
|
record[3] = byte(recordLen >> 8)
|
||||||
|
record[4] = byte(recordLen)
|
||||||
|
copy(record[5:], handshake)
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromClientHello_TruncatedExtension_StillGeneratesFingerprint(t *testing.T) {
|
||||||
|
payload := buildClientHelloWithTruncatedExtension()
|
||||||
|
|
||||||
|
ch := api.TLSClientHello{
|
||||||
|
SrcIP: "4.251.36.192",
|
||||||
|
SrcPort: 19346,
|
||||||
|
DstIP: "212.95.72.88",
|
||||||
|
DstPort: 443,
|
||||||
|
Payload: payload,
|
||||||
|
ConnID: "4.251.36.192:19346->212.95.72.88:443",
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewEngine()
|
||||||
|
fp, err := engine.FromClientHello(ch)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FromClientHello() should succeed after sanitization, got error: %v", err)
|
||||||
|
}
|
||||||
|
if fp == nil {
|
||||||
|
t.Fatal("FromClientHello() returned nil fingerprint")
|
||||||
|
}
|
||||||
|
if fp.JA4 == "" {
|
||||||
|
t.Error("JA4 should be populated even with truncated extension")
|
||||||
|
}
|
||||||
|
if fp.JA3 == "" {
|
||||||
|
t.Error("JA3 should be populated even with truncated extension")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeClientHelloExtensions(t *testing.T) {
|
||||||
|
t.Run("valid payload returns nil", func(t *testing.T) {
|
||||||
|
valid := buildMinimalClientHelloForTest()
|
||||||
|
result := sanitizeClientHelloExtensions(valid)
|
||||||
|
if result != nil {
|
||||||
|
t.Error("should return nil for valid payload (no fix needed)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("truncated extension is fixed", func(t *testing.T) {
|
||||||
|
truncated := buildClientHelloWithTruncatedExtension()
|
||||||
|
result := sanitizeClientHelloExtensions(truncated)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("should return sanitized payload")
|
||||||
|
}
|
||||||
|
// The sanitized payload should be parseable by the library
|
||||||
|
fp, err := tlsfingerprint.ParseClientHello(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sanitized payload should parse without error, got: %v", err)
|
||||||
|
}
|
||||||
|
if fp == nil {
|
||||||
|
t.Fatal("sanitized payload should produce a fingerprint")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("too short returns nil", func(t *testing.T) {
|
||||||
|
if sanitizeClientHelloExtensions([]byte{0x16}) != nil {
|
||||||
|
t.Error("should return nil for short payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-TLS returns nil", func(t *testing.T) {
|
||||||
|
if sanitizeClientHelloExtensions([]byte{0x15, 0x03, 0x03, 0x00, 0x01, 0x00}) != nil {
|
||||||
|
t.Error("should return nil for non-TLS payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -53,6 +53,8 @@ type ConnectionFlow struct {
|
|||||||
IPMeta api.IPMeta
|
IPMeta api.IPMeta
|
||||||
TCPMeta api.TCPMeta
|
TCPMeta api.TCPMeta
|
||||||
HelloBuffer []byte
|
HelloBuffer []byte
|
||||||
|
NextSeq uint32 // Expected next TCP sequence number for reassembly
|
||||||
|
SeqInit bool // Whether NextSeq has been initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParserImpl implements the api.Parser interface for TLS parsing
|
// ParserImpl implements the api.Parser interface for TLS parsing
|
||||||
@ -264,25 +266,39 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
return nil, nil // Source IP is excluded
|
return nil, nil // Source IP is excluded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
key := flowKey(srcIP, srcPort, dstIP, dstPort)
|
||||||
|
|
||||||
|
// Handle SYN packets: create flow and store IP/TCP metadata from SYN
|
||||||
|
// SYN is the only packet that carries TCP options (MSS, WindowScale, SACK, etc.)
|
||||||
|
if tcp.SYN && !tcp.ACK {
|
||||||
|
flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta)
|
||||||
|
if flow != nil {
|
||||||
|
flow.mu.Lock()
|
||||||
|
// SYN consumes 1 sequence number, so data starts at Seq+1
|
||||||
|
flow.NextSeq = tcp.Seq + 1
|
||||||
|
flow.SeqInit = true
|
||||||
|
flow.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get TCP payload (TLS data)
|
// Get TCP payload (TLS data)
|
||||||
payload := tcp.Payload
|
payload := tcp.Payload
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return nil, nil // No payload
|
return nil, nil // No payload (ACK, FIN, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
key := flowKey(srcIP, srcPort, dstIP, dstPort)
|
|
||||||
|
|
||||||
// Check if flow exists before acquiring write lock
|
// Check if flow exists before acquiring write lock
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
flow, flowExists := p.flows[key]
|
_, flowExists := p.flows[key]
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
|
|
||||||
// Early exit for non-ClientHello first packet
|
// Early exit for non-ClientHello first packet (no SYN seen, no TLS handshake)
|
||||||
if !flowExists && payload[0] != 22 {
|
if !flowExists && payload[0] != 22 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
flow = p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta)
|
flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta)
|
||||||
if flow == nil {
|
if flow == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -296,6 +312,26 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
return nil, nil // Already processed this flow
|
return nil, nil // Already processed this flow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TCP sequence tracking: detect retransmissions and maintain order
|
||||||
|
seq := tcp.Seq
|
||||||
|
if flow.SeqInit {
|
||||||
|
if seq < flow.NextSeq {
|
||||||
|
// Retransmission — skip duplicate data
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if seq > flow.NextSeq && flow.State == WAIT_CLIENT_HELLO {
|
||||||
|
// Gap detected — missing segment, drop this flow
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.flows, key)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update expected next sequence number
|
||||||
|
flow.NextSeq = seq + uint32(len(payload))
|
||||||
|
flow.SeqInit = true
|
||||||
|
|
||||||
// Check if this is a TLS ClientHello
|
// Check if this is a TLS ClientHello
|
||||||
clientHello, err := parseClientHello(payload)
|
clientHello, err := parseClientHello(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -313,14 +349,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
// Generate ConnID from flow key
|
// Generate ConnID from flow key
|
||||||
connID := key
|
connID := key
|
||||||
|
|
||||||
|
// Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting
|
||||||
ch := &api.TLSClientHello{
|
ch := &api.TLSClientHello{
|
||||||
SrcIP: srcIP,
|
SrcIP: srcIP,
|
||||||
SrcPort: srcPort,
|
SrcPort: srcPort,
|
||||||
DstIP: dstIP,
|
DstIP: dstIP,
|
||||||
DstPort: dstPort,
|
DstPort: dstPort,
|
||||||
Payload: clientHello,
|
Payload: clientHello,
|
||||||
IPMeta: ipMeta,
|
IPMeta: flow.IPMeta,
|
||||||
TCPMeta: tcpMeta,
|
TCPMeta: flow.TCPMeta,
|
||||||
ConnID: connID,
|
ConnID: connID,
|
||||||
SNI: extInfo.SNI,
|
SNI: extInfo.SNI,
|
||||||
ALPN: joinStringSlice(extInfo.ALPN, ","),
|
ALPN: joinStringSlice(extInfo.ALPN, ","),
|
||||||
@ -366,14 +403,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
// Generate ConnID from flow key
|
// Generate ConnID from flow key
|
||||||
connID := key
|
connID := key
|
||||||
|
|
||||||
|
// Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting
|
||||||
ch := &api.TLSClientHello{
|
ch := &api.TLSClientHello{
|
||||||
SrcIP: srcIP,
|
SrcIP: srcIP,
|
||||||
SrcPort: srcPort,
|
SrcPort: srcPort,
|
||||||
DstIP: dstIP,
|
DstIP: dstIP,
|
||||||
DstPort: dstPort,
|
DstPort: dstPort,
|
||||||
Payload: clientHello,
|
Payload: clientHello,
|
||||||
IPMeta: ipMeta,
|
IPMeta: flow.IPMeta,
|
||||||
TCPMeta: tcpMeta,
|
TCPMeta: flow.TCPMeta,
|
||||||
ConnID: connID,
|
ConnID: connID,
|
||||||
SNI: extInfo.SNI,
|
SNI: extInfo.SNI,
|
||||||
ALPN: joinStringSlice(extInfo.ALPN, ","),
|
ALPN: joinStringSlice(extInfo.ALPN, ","),
|
||||||
@ -587,6 +625,12 @@ func extractTLSExtensions(payload []byte) (*TLSExtensionInfo, error) {
|
|||||||
|
|
||||||
// Use tlsfingerprint to parse ALPN and TLS version
|
// Use tlsfingerprint to parse ALPN and TLS version
|
||||||
fp, err := tlsfingerprint.ParseClientHello(payload)
|
fp, err := tlsfingerprint.ParseClientHello(payload)
|
||||||
|
if err != nil {
|
||||||
|
// Retry with sanitized payload (handles truncated/malformed extensions)
|
||||||
|
if sanitized := sanitizeTLSRecord(payload); sanitized != nil {
|
||||||
|
fp, err = tlsfingerprint.ParseClientHello(sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err == nil && fp != nil {
|
if err == nil && fp != nil {
|
||||||
// Extract ALPN protocols
|
// Extract ALPN protocols
|
||||||
if len(fp.ALPNProtocols) > 0 {
|
if len(fp.ALPNProtocols) > 0 {
|
||||||
@ -757,3 +801,84 @@ func joinStringSlice(slice []string, sep string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(slice, sep)
|
return strings.Join(slice, sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeTLSRecord attempts to fix a TLS ClientHello with truncated extensions
|
||||||
|
// by adjusting lengths to cover only complete extensions. Returns a corrected
|
||||||
|
// copy of the record, or nil if no fix is needed or possible.
|
||||||
|
func sanitizeTLSRecord(data []byte) []byte {
|
||||||
|
if len(data) < 5 || data[0] != 0x16 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recordLen := int(data[3])<<8 | int(data[4])
|
||||||
|
if len(data) < 5+recordLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := data[5 : 5+recordLen]
|
||||||
|
if len(payload) < 4 || payload[0] != 0x01 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
helloLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
|
||||||
|
if len(payload) < 4+helloLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hello := payload[4 : 4+helloLen]
|
||||||
|
|
||||||
|
// Skip version + random + session ID + cipher suites + compression methods
|
||||||
|
offset := 2 + 32
|
||||||
|
if len(hello) < offset+1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
offset += 1 + int(hello[offset]) // session ID
|
||||||
|
if len(hello) < offset+2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
csLen := int(hello[offset])<<8 | int(hello[offset+1])
|
||||||
|
offset += 2 + csLen
|
||||||
|
if len(hello) < offset+1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
offset += 1 + int(hello[offset]) // compression methods
|
||||||
|
if len(hello) < offset+2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extLenOffset := offset
|
||||||
|
declaredExtLen := int(hello[offset])<<8 | int(hello[offset+1])
|
||||||
|
offset += 2
|
||||||
|
extStart := offset
|
||||||
|
if len(hello) < extStart+declaredExtLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extData := hello[extStart : extStart+declaredExtLen]
|
||||||
|
|
||||||
|
// Walk extensions to find the last complete one
|
||||||
|
validLen := 0
|
||||||
|
pos := 0
|
||||||
|
for pos < len(extData) {
|
||||||
|
if pos+4 > len(extData) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
extBodyLen := int(extData[pos+2])<<8 | int(extData[pos+3])
|
||||||
|
if pos+4+extBodyLen > len(extData) {
|
||||||
|
break // truncated extension
|
||||||
|
}
|
||||||
|
pos += 4 + extBodyLen
|
||||||
|
validLen = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if validLen == declaredExtLen {
|
||||||
|
return nil // no truncation, nothing to fix
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := make([]byte, len(data))
|
||||||
|
copy(fixed, data)
|
||||||
|
diff := declaredExtLen - validLen
|
||||||
|
extLenAbs := 5 + 4 + extLenOffset
|
||||||
|
binary.BigEndian.PutUint16(fixed[extLenAbs:], uint16(validLen))
|
||||||
|
newHelloLen := helloLen - diff
|
||||||
|
fixed[5+1] = byte(newHelloLen >> 16)
|
||||||
|
fixed[5+2] = byte(newHelloLen >> 8)
|
||||||
|
fixed[5+3] = byte(newHelloLen)
|
||||||
|
newRecordLen := recordLen - diff
|
||||||
|
binary.BigEndian.PutUint16(fixed[3:5], uint16(newRecordLen))
|
||||||
|
return fixed[:5+newRecordLen]
|
||||||
|
}
|
||||||
|
|||||||
@ -336,7 +336,7 @@ func TestProcess_DropsWhenHelloBufferExceedsLimit(t *testing.T) {
|
|||||||
// TLS-like payload, but intentionally incomplete to trigger accumulation.
|
// TLS-like payload, but intentionally incomplete to trigger accumulation.
|
||||||
payloadChunk := []byte{0x16, 0x03, 0x03, 0x00, 0x20, 0x01} // len = 6
|
payloadChunk := []byte{0x16, 0x03, 0x03, 0x00, 0x20, 0x01} // len = 6
|
||||||
|
|
||||||
pkt1 := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, payloadChunk)
|
pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payloadChunk, 1)
|
||||||
ch, err := parser.Process(pkt1)
|
ch, err := parser.Process(pkt1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Process() error = %v", err)
|
t.Fatalf("first Process() error = %v", err)
|
||||||
@ -354,7 +354,7 @@ func TestProcess_DropsWhenHelloBufferExceedsLimit(t *testing.T) {
|
|||||||
t.Fatal("flow should exist after first chunk")
|
t.Fatal("flow should exist after first chunk")
|
||||||
}
|
}
|
||||||
|
|
||||||
pkt2 := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, payloadChunk)
|
pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payloadChunk, 1+uint32(len(payloadChunk)))
|
||||||
ch, err = parser.Process(pkt2)
|
ch, err = parser.Process(pkt2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Process() error = %v", err)
|
t.Fatalf("second Process() error = %v", err)
|
||||||
@ -403,6 +403,10 @@ func TestProcess_NonTLSNewFlowNotTracked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte) api.RawPacket {
|
func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte) api.RawPacket {
|
||||||
|
return buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payload, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRawPacketWithSeq(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte, seq uint32) api.RawPacket {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ip := &layers.IPv4{
|
ip := &layers.IPv4{
|
||||||
@ -416,7 +420,7 @@ func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16,
|
|||||||
tcp := &layers.TCP{
|
tcp := &layers.TCP{
|
||||||
SrcPort: layers.TCPPort(srcPort),
|
SrcPort: layers.TCPPort(srcPort),
|
||||||
DstPort: layers.TCPPort(dstPort),
|
DstPort: layers.TCPPort(dstPort),
|
||||||
Seq: 1,
|
Seq: seq,
|
||||||
ACK: true,
|
ACK: true,
|
||||||
Window: 65535,
|
Window: 65535,
|
||||||
}
|
}
|
||||||
@ -1307,3 +1311,482 @@ func TestParser_SLLPacketType(t *testing.T) {
|
|||||||
t.Fatal("Process() should return TLSClientHello for PACKET_HOST")
|
t.Fatal("Process() should return TLSClientHello for PACKET_HOST")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSYNPacket creates a raw SYN packet (no payload) with TCP options
|
||||||
|
func buildSYNPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, mss uint16, windowScale uint8) api.RawPacket {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: 0x1234,
|
||||||
|
Flags: layers.IPv4DontFragment,
|
||||||
|
SrcIP: net.ParseIP(srcIP).To4(),
|
||||||
|
DstIP: net.ParseIP(dstIP).To4(),
|
||||||
|
Protocol: layers.IPProtocolTCP,
|
||||||
|
}
|
||||||
|
|
||||||
|
tcp := &layers.TCP{
|
||||||
|
SrcPort: layers.TCPPort(srcPort),
|
||||||
|
DstPort: layers.TCPPort(dstPort),
|
||||||
|
Seq: 1000,
|
||||||
|
SYN: true,
|
||||||
|
Window: 65535,
|
||||||
|
Options: []layers.TCPOption{
|
||||||
|
{
|
||||||
|
OptionType: layers.TCPOptionKindMSS,
|
||||||
|
OptionLength: 4,
|
||||||
|
OptionData: []byte{byte(mss >> 8), byte(mss)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OptionType: layers.TCPOptionKindWindowScale,
|
||||||
|
OptionLength: 3,
|
||||||
|
OptionData: []byte{windowScale},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OptionType: layers.TCPOptionKindSACKPermitted,
|
||||||
|
OptionLength: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := tcp.SetNetworkLayerForChecksum(ip); err != nil {
|
||||||
|
t.Fatalf("SetNetworkLayerForChecksum() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eth := &layers.Ethernet{
|
||||||
|
SrcMAC: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55},
|
||||||
|
DstMAC: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
|
||||||
|
EthernetType: layers.EthernetTypeIPv4,
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{
|
||||||
|
FixLengths: true,
|
||||||
|
ComputeChecksums: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gopacket.SerializeLayers(buf, opts, eth, ip, tcp); err != nil {
|
||||||
|
t.Fatalf("SerializeLayers() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.RawPacket{
|
||||||
|
Data: buf.Bytes(),
|
||||||
|
Timestamp: time.Now().UnixNano(),
|
||||||
|
LinkType: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_SYNCreatesFlowWithTCPMeta(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.50"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(44444)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
expectedMSS := uint16(1460)
|
||||||
|
expectedWS := uint8(7)
|
||||||
|
|
||||||
|
// Step 1: Send SYN packet (should create flow, return nil)
|
||||||
|
synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, expectedMSS, expectedWS)
|
||||||
|
ch, err := parser.Process(synPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(SYN) error = %v", err)
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
t.Fatal("Process(SYN) should return nil (no ClientHello yet)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify flow was created with correct metadata
|
||||||
|
key := flowKey(srcIP, srcPort, dstIP, dstPort)
|
||||||
|
parser.mu.RLock()
|
||||||
|
flow, exists := parser.flows[key]
|
||||||
|
parser.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("SYN should create a flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
flow.mu.Lock()
|
||||||
|
if flow.State != NEW {
|
||||||
|
t.Errorf("flow state = %v, want NEW", flow.State)
|
||||||
|
}
|
||||||
|
if flow.TCPMeta.MSS != expectedMSS {
|
||||||
|
t.Errorf("flow TCPMeta.MSS = %d, want %d", flow.TCPMeta.MSS, expectedMSS)
|
||||||
|
}
|
||||||
|
if flow.TCPMeta.WindowScale != expectedWS {
|
||||||
|
t.Errorf("flow TCPMeta.WindowScale = %d, want %d", flow.TCPMeta.WindowScale, expectedWS)
|
||||||
|
}
|
||||||
|
if flow.TCPMeta.WindowSize != 65535 {
|
||||||
|
t.Errorf("flow TCPMeta.WindowSize = %d, want 65535", flow.TCPMeta.WindowSize)
|
||||||
|
}
|
||||||
|
// Check SACK is in options
|
||||||
|
hasSACK := false
|
||||||
|
for _, opt := range flow.TCPMeta.Options {
|
||||||
|
if opt == "SACK" {
|
||||||
|
hasSACK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSACK {
|
||||||
|
t.Errorf("flow TCPMeta.Options = %v, want SACK", flow.TCPMeta.Options)
|
||||||
|
}
|
||||||
|
if flow.IPMeta.TTL != 64 {
|
||||||
|
t.Errorf("flow IPMeta.TTL = %d, want 64", flow.IPMeta.TTL)
|
||||||
|
}
|
||||||
|
if !flow.IPMeta.DF {
|
||||||
|
t.Error("flow IPMeta.DF should be true")
|
||||||
|
}
|
||||||
|
flow.mu.Unlock()
|
||||||
|
|
||||||
|
// Step 2: Send ClientHello data packet (SYN had Seq=1000, so data starts at 1001)
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
dataPkt := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, clientHello, 1001)
|
||||||
|
|
||||||
|
result, err := parser.Process(dataPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(ClientHello) error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process(ClientHello) should return TLSClientHello")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify result uses TCP metadata from SYN, not from data packet
|
||||||
|
if result.TCPMeta.MSS != expectedMSS {
|
||||||
|
t.Errorf("result TCPMeta.MSS = %d, want %d (from SYN)", result.TCPMeta.MSS, expectedMSS)
|
||||||
|
}
|
||||||
|
if result.TCPMeta.WindowScale != expectedWS {
|
||||||
|
t.Errorf("result TCPMeta.WindowScale = %d, want %d (from SYN)", result.TCPMeta.WindowScale, expectedWS)
|
||||||
|
}
|
||||||
|
if result.IPMeta.TTL != 64 {
|
||||||
|
t.Errorf("result IPMeta.TTL = %d, want 64 (from SYN)", result.IPMeta.TTL)
|
||||||
|
}
|
||||||
|
if !result.IPMeta.DF {
|
||||||
|
t.Error("result IPMeta.DF should be true (from SYN)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_SynToCHMs_Timing(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.60"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(55555)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
|
||||||
|
// Step 1: Send SYN
|
||||||
|
synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7)
|
||||||
|
_, err := parser.Process(synPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(SYN) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a measurable amount of time
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Step 2: Send ClientHello (SYN had Seq=1000, data at 1001)
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
dataPkt := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, clientHello, 1001)
|
||||||
|
|
||||||
|
result, err := parser.Process(dataPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(ClientHello) error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process(ClientHello) should return TLSClientHello")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SynToCHMs == nil {
|
||||||
|
t.Fatal("SynToCHMs should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SynToCHMs should be at least 50ms (we slept 50ms)
|
||||||
|
if *result.SynToCHMs < 40 {
|
||||||
|
t.Errorf("SynToCHMs = %d ms, want >= 40ms (slept 50ms)", *result.SynToCHMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_NoSYN_StillWorks(t *testing.T) {
|
||||||
|
// Ensure backward compatibility: if no SYN is seen (e.g. capture started
|
||||||
|
// mid-connection), a ClientHello data packet still creates a flow and works.
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.70"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(55666)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
dataPkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello)
|
||||||
|
|
||||||
|
result, err := parser.Process(dataPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process() should return TLSClientHello even without SYN")
|
||||||
|
}
|
||||||
|
if result.SrcIP != srcIP {
|
||||||
|
t.Errorf("SrcIP = %v, want %v", result.SrcIP, srcIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_FragmentedClientHello_UsesFlowMeta(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.80"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(33333)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
expectedMSS := uint16(1460)
|
||||||
|
expectedWS := uint8(7)
|
||||||
|
|
||||||
|
// Step 1: Send SYN with TCP options
|
||||||
|
synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, expectedMSS, expectedWS)
|
||||||
|
_, err := parser.Process(synPkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(SYN) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Send incomplete TLS record (fragment 1, Seq=1001)
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
half := len(clientHello) / 2
|
||||||
|
fragment1 := clientHello[:half]
|
||||||
|
|
||||||
|
pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001)
|
||||||
|
ch, err := parser.Process(pkt1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(fragment1) error = %v", err)
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
t.Fatal("Process(fragment1) should return nil (incomplete)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Send rest (fragment 2, Seq=1001+len(fragment1))
|
||||||
|
fragment2 := clientHello[half:]
|
||||||
|
pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, 1001+uint32(half))
|
||||||
|
result, err := parser.Process(pkt2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(fragment2) error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process(fragment2) should return complete TLSClientHello")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata comes from the SYN (flow), not from the last data fragment
|
||||||
|
if result.TCPMeta.MSS != expectedMSS {
|
||||||
|
t.Errorf("result TCPMeta.MSS = %d, want %d (from SYN)", result.TCPMeta.MSS, expectedMSS)
|
||||||
|
}
|
||||||
|
if result.TCPMeta.WindowScale != expectedWS {
|
||||||
|
t.Errorf("result TCPMeta.WindowScale = %d, want %d (from SYN)", result.TCPMeta.WindowScale, expectedWS)
|
||||||
|
}
|
||||||
|
if result.IPMeta.TTL != 64 {
|
||||||
|
t.Errorf("result IPMeta.TTL = %d, want 64 (from SYN)", result.IPMeta.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_TCPRetransmission_Ignored(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.90"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(44321)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
|
||||||
|
// Step 1: Send SYN (Seq=1000)
|
||||||
|
synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7)
|
||||||
|
_, _ = parser.Process(synPkt)
|
||||||
|
|
||||||
|
// Step 2: Send first fragment (Seq=1001)
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
half := len(clientHello) / 2
|
||||||
|
fragment1 := clientHello[:half]
|
||||||
|
pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001)
|
||||||
|
_, _ = parser.Process(pkt1)
|
||||||
|
|
||||||
|
// Step 3: Retransmit fragment 1 (same Seq=1001) — should be ignored
|
||||||
|
pkt1dup := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001)
|
||||||
|
ch, err := parser.Process(pkt1dup)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(retransmit) error = %v", err)
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
t.Fatal("Process(retransmit) should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Send second fragment (correct Seq)
|
||||||
|
fragment2 := clientHello[half:]
|
||||||
|
pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, 1001+uint32(half))
|
||||||
|
result, err := parser.Process(pkt2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(fragment2) error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process(fragment2) should return complete TLSClientHello after retransmission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_TCPGap_DropsFlow(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.91"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(44322)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
|
||||||
|
// Step 1: Send SYN (Seq=1000)
|
||||||
|
synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7)
|
||||||
|
_, _ = parser.Process(synPkt)
|
||||||
|
|
||||||
|
// Step 2: Send first fragment (Seq=1001)
|
||||||
|
clientHello := createTLSClientHello(0x0303)
|
||||||
|
half := len(clientHello) / 2
|
||||||
|
fragment1 := clientHello[:half]
|
||||||
|
pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001)
|
||||||
|
_, _ = parser.Process(pkt1)
|
||||||
|
|
||||||
|
// Step 3: Send fragment with gap (Seq far ahead) — should drop flow
|
||||||
|
fragment2 := clientHello[half:]
|
||||||
|
gapSeq := uint32(1001 + half + 100) // 100 bytes gap
|
||||||
|
pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, gapSeq)
|
||||||
|
ch, err := parser.Process(pkt2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process(gap) error = %v", err)
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
t.Fatal("Process(gap) should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify flow was removed
|
||||||
|
key := flowKey(srcIP, srcPort, dstIP, dstPort)
|
||||||
|
parser.mu.RLock()
|
||||||
|
_, exists := parser.flows[key]
|
||||||
|
parser.mu.RUnlock()
|
||||||
|
if exists {
|
||||||
|
t.Fatal("flow should be removed after sequence gap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTLS13ClientHelloWithSNI creates a TLS 1.3 ClientHello (record version 0x0303,
|
||||||
|
// supported_versions extension includes 0x0304)
|
||||||
|
func createTLS13ClientHelloWithSNI(sni string) []byte {
|
||||||
|
// Build SNI extension
|
||||||
|
sniExt := buildSNIExtension(sni)
|
||||||
|
|
||||||
|
// Build ALPN extension
|
||||||
|
alpnExt := buildALPNExtension([]string{"h2", "http/1.1"})
|
||||||
|
|
||||||
|
// Build supported_versions extension with TLS 1.3 (0x0304) and TLS 1.2 (0x0303)
|
||||||
|
// Extension type: 43 (0x002b), data: list_len(1) + 2 versions (4 bytes)
|
||||||
|
supportedVersionsExt := []byte{
|
||||||
|
0x00, 0x2b, // Extension type: supported_versions (43)
|
||||||
|
0x00, 0x05, // Extension data length: 5
|
||||||
|
0x04, // Supported versions list length: 4 bytes (2 versions)
|
||||||
|
0x03, 0x04, // TLS 1.3
|
||||||
|
0x03, 0x03, // TLS 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine extensions
|
||||||
|
extensions := append(sniExt, alpnExt...)
|
||||||
|
extensions = append(extensions, supportedVersionsExt...)
|
||||||
|
extLen := len(extensions)
|
||||||
|
|
||||||
|
// Cipher suites (TLS 1.3 suites)
|
||||||
|
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
|
||||||
|
|
||||||
|
// Compression methods (null only)
|
||||||
|
compressionMethods := []byte{0x01, 0x00}
|
||||||
|
|
||||||
|
// Build ClientHello handshake body
|
||||||
|
handshakeBody := []byte{
|
||||||
|
0x03, 0x03, // Version: TLS 1.2 (mandatory for TLS 1.3 ClientHello)
|
||||||
|
}
|
||||||
|
// Random (32 bytes)
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
handshakeBody = append(handshakeBody, 0x01)
|
||||||
|
}
|
||||||
|
handshakeBody = append(handshakeBody, 0x00) // Session ID length: 0
|
||||||
|
|
||||||
|
// Add cipher suites
|
||||||
|
cipherSuiteLen := len(cipherSuites)
|
||||||
|
handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen))
|
||||||
|
handshakeBody = append(handshakeBody, cipherSuites...)
|
||||||
|
|
||||||
|
// Add compression methods
|
||||||
|
handshakeBody = append(handshakeBody, compressionMethods...)
|
||||||
|
|
||||||
|
// Add extensions
|
||||||
|
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
|
||||||
|
handshakeBody = append(handshakeBody, extensions...)
|
||||||
|
|
||||||
|
// Build handshake with type and length
|
||||||
|
handshakeLen := len(handshakeBody)
|
||||||
|
handshake := append([]byte{
|
||||||
|
0x01, // Handshake type: ClientHello
|
||||||
|
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen),
|
||||||
|
}, handshakeBody...)
|
||||||
|
|
||||||
|
// Build TLS record (version always 0x0303 for TLS 1.3)
|
||||||
|
recordLen := len(handshake)
|
||||||
|
record := make([]byte, 5+recordLen)
|
||||||
|
record[0] = 0x16 // Handshake
|
||||||
|
record[1] = 0x03 // TLS 1.2 in record layer (per TLS 1.3 spec)
|
||||||
|
record[2] = 0x03
|
||||||
|
record[3] = byte(recordLen >> 8)
|
||||||
|
record[4] = byte(recordLen)
|
||||||
|
copy(record[5:], handshake)
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTLSExtensions_TLS13(t *testing.T) {
|
||||||
|
payload := createTLS13ClientHelloWithSNI("example.com")
|
||||||
|
|
||||||
|
info, err := extractTLSExtensions(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractTLSExtensions() error = %v", err)
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
t.Fatal("extractTLSExtensions() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS 1.3 should be detected via supported_versions extension
|
||||||
|
if info.TLSVersion != "1.3" {
|
||||||
|
t.Errorf("TLSVersion = %q, want \"1.3\"", info.TLSVersion)
|
||||||
|
}
|
||||||
|
if info.SNI != "example.com" {
|
||||||
|
t.Errorf("SNI = %q, want \"example.com\"", info.SNI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_TLS13ClientHello_CorrectVersion(t *testing.T) {
|
||||||
|
parser := NewParser()
|
||||||
|
defer parser.Close()
|
||||||
|
|
||||||
|
srcIP := "192.168.1.200"
|
||||||
|
dstIP := "10.0.0.1"
|
||||||
|
srcPort := uint16(44555)
|
||||||
|
dstPort := uint16(443)
|
||||||
|
|
||||||
|
clientHello := createTLS13ClientHelloWithSNI("tls13.example.com")
|
||||||
|
pkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello)
|
||||||
|
|
||||||
|
result, err := parser.Process(pkt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Process() should return TLSClientHello")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TLSVersion != "1.3" {
|
||||||
|
t.Errorf("TLSVersion = %q, want \"1.3\"", result.TLSVersion)
|
||||||
|
}
|
||||||
|
if result.SNI != "tls13.example.com" {
|
||||||
|
t.Errorf("SNI = %q, want \"tls13.example.com\"", result.SNI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
%if %{defined build_version}
|
%if %{defined build_version}
|
||||||
%define spec_version %{build_version}
|
%define spec_version %{build_version}
|
||||||
%else
|
%else
|
||||||
%define spec_version 1.1.12
|
%define spec_version 1.1.15
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
Name: ja4sentinel
|
Name: ja4sentinel
|
||||||
@ -123,6 +123,26 @@ fi
|
|||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
|
||||||
|
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.15-1
|
||||||
|
- FIX: ALPN not appearing in logs for packets with truncated/malformed TLS extensions
|
||||||
|
- Add sanitization fallback in extractTLSExtensions (same as fingerprint engine)
|
||||||
|
- ALPN (tls_alpn) now correctly extracted even when ParseClientHello fails on raw payload
|
||||||
|
|
||||||
|
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.14-1
|
||||||
|
- FIX: Handle ClientHellos with truncated extension data (extension data truncated)
|
||||||
|
- Sanitize malformed extensions by trimming to last complete extension before retry
|
||||||
|
- Fingerprints (JA4/JA3) now generated even for slightly malformed ClientHellos
|
||||||
|
- Added unit tests for extension sanitization and truncated extension handling
|
||||||
|
|
||||||
|
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.13-1
|
||||||
|
- FIX: BPF filter uses 'tcp dst port' instead of 'tcp port' to capture client-to-server traffic only
|
||||||
|
- FIX: SYN packet handling — detect SYN before payload-length check, create flow with IP/TCP metadata
|
||||||
|
- FIX: SynToCHMs timing now uses SYN timestamp instead of first data packet timestamp
|
||||||
|
- FIX: Fragmented ClientHello uses flow metadata from SYN instead of last fragment's packet metadata
|
||||||
|
- FIX: TCP reassembly sequence tracking — detect retransmissions (skip) and gaps (drop flow)
|
||||||
|
- Added TLS 1.3 supported_versions test coverage (verified library already handles it correctly)
|
||||||
|
- 9 new unit tests for SYN handling, TCP reassembly, TLS 1.3, and fragmentation metadata
|
||||||
|
|
||||||
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.12-1
|
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.12-1
|
||||||
- FIX: Remove JA4SENTINEL_LOG_LEVEL env override (architecture violation, log_level YAML-only)
|
- FIX: Remove JA4SENTINEL_LOG_LEVEL env override (architecture violation, log_level YAML-only)
|
||||||
- FIX: Add yaml struct tags to Config/AppConfig/OutputConfig (yaml.v3 does not fall back to json tags)
|
- FIX: Add yaml struct tags to Config/AppConfig/OutputConfig (yaml.v3 does not fall back to json tags)
|
||||||
|
|||||||
Reference in New Issue
Block a user