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

- 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:
toto
2026-03-05 14:42:15 +01:00
parent 63c91175a2
commit d22b0634da
8 changed files with 881 additions and 24 deletions

View File

@ -53,6 +53,8 @@ type ConnectionFlow struct {
IPMeta api.IPMeta
TCPMeta api.TCPMeta
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
@ -264,25 +266,39 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
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)
payload := tcp.Payload
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
p.mu.RLock()
flow, flowExists := p.flows[key]
_, flowExists := p.flows[key]
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 {
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 {
return nil, nil
}
@ -296,6 +312,26 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
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
clientHello, err := parseClientHello(payload)
if err != nil {
@ -313,14 +349,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
// Generate ConnID from flow key
connID := key
// Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
IPMeta: flow.IPMeta,
TCPMeta: flow.TCPMeta,
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
@ -366,14 +403,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
// Generate ConnID from flow key
connID := key
// Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
IPMeta: flow.IPMeta,
TCPMeta: flow.TCPMeta,
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
@ -587,6 +625,12 @@ func extractTLSExtensions(payload []byte) (*TLSExtensionInfo, error) {
// Use tlsfingerprint to parse ALPN and TLS version
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 {
// Extract ALPN protocols
if len(fp.ALPNProtocols) > 0 {
@ -757,3 +801,84 @@ func joinStringSlice(slice []string, sep string) string {
}
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]
}