mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-13 00:22:49 +00:00
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package ja4plus
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// greaseFilter returns true if the provided value is a GREASE entry as defined in
|
|
// https://www.rfc-editor.org/rfc/rfc8701.html
|
|
func greaseFilter(suite uint16) bool {
|
|
return suite&0x000F == 0x000A && // low word is 0x*A
|
|
suite>>8 == (suite&0x00FF) // high word is equal to low word
|
|
}
|
|
|
|
// JA4 generates a JA4 fingerprint from the given [tls.ClientHelloInfo].
|
|
// It extracts TLS Version, Cipher Suites, Extensions, and ALPN Protocols.
|
|
func JA4(hello *tls.ClientHelloInfo) string {
|
|
out := make([]byte, 0, 36)
|
|
|
|
// Determine protocol type based on the network type
|
|
if hello.Conn != nil {
|
|
switch hello.Conn.LocalAddr().Network() {
|
|
case "udp", "sctp":
|
|
out = append(out, 'd')
|
|
case "quic":
|
|
out = append(out, 'q')
|
|
default:
|
|
out = append(out, 't')
|
|
}
|
|
} else {
|
|
out = append(out, 't')
|
|
}
|
|
|
|
// Extract TLS version
|
|
supportedVersions := slices.DeleteFunc(slices.Sorted(slices.Values(hello.SupportedVersions)), greaseFilter)
|
|
switch supportedVersions[len(supportedVersions)-1] {
|
|
case tls.VersionTLS10:
|
|
out = append(out, '1', '0')
|
|
case tls.VersionTLS11:
|
|
out = append(out, '1', '1')
|
|
case tls.VersionTLS12:
|
|
out = append(out, '1', '2')
|
|
case tls.VersionTLS13:
|
|
out = append(out, '1', '3')
|
|
case tls.VersionSSL30: // deprecated, but still seen in the wild
|
|
out = append(out, 's', '3')
|
|
case 0x0002: // unsupported by go; still seen in the wild
|
|
out = append(out, 's', '2')
|
|
case 0xfeff: // DTLS 1.0
|
|
out = append(out, 'd', '1')
|
|
case 0xfefd: // DTLS 1.2
|
|
out = append(out, 'd', '2')
|
|
case 0xfefc: // DTLS 1.3
|
|
out = append(out, 'd', '3')
|
|
default:
|
|
out = append(out, '0', '0')
|
|
}
|
|
|
|
// Check for presence of SNI
|
|
if hello.ServerName != "" {
|
|
out = append(out, 'd')
|
|
} else {
|
|
out = append(out, 'i')
|
|
}
|
|
|
|
// Count cipher suites; copy to avoid modifying the original
|
|
filteredCipherSuites := slices.DeleteFunc(slices.Clone(hello.CipherSuites), greaseFilter)
|
|
out = fmt.Appendf(out, "%02d", min(len(filteredCipherSuites), 99))
|
|
|
|
// Count extensions; copy to avoid modifying the original
|
|
filteredExtensions := slices.DeleteFunc(slices.Clone(hello.Extensions), greaseFilter)
|
|
out = fmt.Appendf(out, "%02d", min(len(filteredExtensions), 99))
|
|
|
|
// Extract first ALPN value
|
|
var firstALPN string
|
|
for _, proto := range hello.SupportedProtos {
|
|
// Protocols are tecnically strings, but grease values are 2-byte non-printable, so we convert.
|
|
// see: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids
|
|
if len(proto) >= 2 && !greaseFilter(binary.BigEndian.Uint16([]byte(proto[:2]))) {
|
|
firstALPN = proto
|
|
break
|
|
}
|
|
}
|
|
if firstALPN != "" {
|
|
out = append(out, firstALPN[0], firstALPN[len(firstALPN)-1])
|
|
} else {
|
|
out = append(out, '0', '0')
|
|
}
|
|
|
|
out = append(out, '_')
|
|
|
|
out = hex.AppendEncode(out, cipherSuiteHash(filteredCipherSuites))
|
|
|
|
out = append(out, '_')
|
|
|
|
out = hex.AppendEncode(out, extensionHash(filteredExtensions, hello.SignatureSchemes))
|
|
|
|
return string(out)
|
|
}
|
|
|
|
// cipherSuiteHash computes the truncated SHA256 of sorted cipher suites.
|
|
// The input must be filtered for GREASE values.
|
|
// The return value is an unencoded byte slice of the hash.
|
|
func cipherSuiteHash(filteredCipherSuites []uint16) []byte {
|
|
if len(filteredCipherSuites) > 0 {
|
|
slices.Sort(filteredCipherSuites)
|
|
cipherSuiteList := make([]string, 0, len(filteredCipherSuites))
|
|
for _, suite := range filteredCipherSuites {
|
|
cipherSuiteList = append(cipherSuiteList, fmt.Sprintf("%04x", suite))
|
|
}
|
|
cipherSuiteHash := sha256.Sum256([]byte(strings.Join(cipherSuiteList, ",")))
|
|
return cipherSuiteHash[:6]
|
|
} else {
|
|
return []byte{0, 0, 0, 0, 0, 0}
|
|
}
|
|
}
|
|
|
|
// extensionHash computes the truncated SHA256 of sorted and filtered extensions and unsorted signature algorithms.
|
|
// The provided extensions must be filtered for GREASE values.
|
|
// It sorts the provided extensions in-place.
|
|
// The return value is an unencoded byte slice of the hash.
|
|
func extensionHash(filteredExtensions []uint16, signatureSchemes []tls.SignatureScheme) []byte {
|
|
slices.Sort(filteredExtensions)
|
|
extensionsList := make([]string, 0, len(filteredExtensions))
|
|
for _, ext := range filteredExtensions {
|
|
// SNI and ALPN are counted above, but MUST be ignored for the hash.
|
|
if ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ {
|
|
continue
|
|
}
|
|
extensionsList = append(extensionsList, fmt.Sprintf("%04x", ext))
|
|
}
|
|
if len(extensionsList) == 0 {
|
|
return []byte{0, 0, 0, 0, 0, 0}
|
|
}
|
|
|
|
extensionsListRendered := strings.Join(extensionsList, ",")
|
|
if len(signatureSchemes) > 0 {
|
|
signatureSchemeList := make([]string, 0, len(signatureSchemes))
|
|
for _, sig := range signatureSchemes {
|
|
if greaseFilter(uint16(sig)) {
|
|
continue
|
|
}
|
|
signatureSchemeList = append(signatureSchemeList, fmt.Sprintf("%04x", uint16(sig)))
|
|
}
|
|
extensionsListRendered += "_" + strings.Join(signatureSchemeList, ",")
|
|
}
|
|
extensionsHash := sha256.Sum256([]byte(extensionsListRendered))
|
|
return extensionsHash[:6]
|
|
}
|