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

@ -2,6 +2,7 @@
package fingerprint
import (
"encoding/binary"
"fmt"
"ja4sentinel/api"
@ -29,8 +30,14 @@ func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints,
// Parse the ClientHello using tlsfingerprint
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
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)
// Try to sanitize truncated extensions and retry
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
@ -67,3 +74,93 @@ func extractJA4Hash(ja4 string) string {
}
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]
}

View File

@ -5,6 +5,8 @@ import (
"testing"
"ja4sentinel/api"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
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
// 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")
}
})
}