Fingerprinting TLS Clients with JA4 on F5 BIG-IP

What is JA4?

JA4 is a subset of the larger JA4+ set of network fingerprints.  JA4+ is a set of simple network fingerprints for a number of protocols that are intended to be both human and machine readable, and replaces the JA3 TLS fingerprinting standard from 2017 (Salesforce is no longer maintaining JA3).  Currently, JA4+ includes JA4/S/H/L/X/SSH, or JA4+ for short.

JA4+ Fingerprints

JA4 — TLS Client
JA4S — TLS Server Response
JA4H — HTTP Client
JA4L — Light Distance/Location
JA4X — X509 TLS Certificate
JA4SSH — SSH Traffic

More fingerprints are in development and will be added to the JA4+ family as they are released.

From the previous table, JA4 is the network fingerprint for TLS clients.  If you are not familiar with network fingerprinting or TLS fingerprinting, please see John Althouse's excellent article detailing the release of JA4+.

Like JA3 (the predecessor to JA4), JA4 generates the fingerprint through passive analysis of the TLS CLIENT_HELLO packet.  Using the information provided in this protocol packet, an easily readable and sharable fingerprint is generated using the following format:

JA4 TLS Client Fingerprint Format

 For details on the technical implementation of JA4+ please see the JA4+ github page.

Like all of the other JA4+ fingerprints, the JA4 client TLS fingerprint is broken into three parts in an a_b_c format.  The intent of this format is to allow for tracking of similar, but not identical, fingerprints.  For example, as John explains in his blog, one vendor already implementing JA4 into their product has "an actor who scans the internet with a constantly changing single TLS cipher. This generates a massive amount of completely different JA3 fingerprints but with JA4, only the b part of the JA4 fingerprint changes, parts a and c remain the same. As such, they can track the actor by looking at the JA4_ac fingerprint (joining a+c, dropping b)".

Implementing JA4 on F5 BIG-IP

While F5 MAY implement JA4 natively into its various products and services at some point, that doesn't help us NOW.  So, how can we take advantage of JA4 on F5 BIGIP TODAY?  F5 iRules, of course!!!

Following the technical implementation detailed in the JA4+ github documentation, I wrote an iRule to generate JA4 client TLS fingerprints.  I will not explain all the details of the iRule, as I tried to include enough inline comments in the iRule itself sufficiently to document what each section of the iRule is doing.

Update Jan 19, 2024: There was a change to the JA4 spec (prior to it's release) that I did not capture in the previous version of this iRule.  The iRule has been updated to include this change.

JA4 Client TLS Fingerprint iRule:

proc getCipherList { payload rlen outer inner clientip serverip } {

    upvar cipher_cnt cipher_cnt

    ## Define GREASE values so these can be excluded from cipher list
    set greaseList "0a0a 1a1a 2a2a 3a3a 4a4a 5a5a 6a6a 7a7a 8a8a 9a9a aaaa baba caca dada eaea fafa"

    ## Skip over first 43 bytes (contains tls_type hello_len tls_ver, which we don't need)
    set field_offset 43

    ## Grab the session ID length value and increment field_offset.
    binary scan ${payload} @${field_offset}c sessID_len
    set field_offset [expr {${field_offset} + 1 + ${sessID_len}}]

    ## Grab ciphersuite list length (binary and hex values).
    binary scan ${payload} @${field_offset}S cipherList_len
    binary scan ${payload} @${field_offset}H4 cipherList_len_hex
    set cipherList_len_hex_text ${cipherList_len_hex}

    ## increment field_offset and get the ciphersuite list.
    set field_offset [expr {${field_offset} + 2}]
    set cipherList_len_hex [expr {${cipherList_len} * 2}]
    binary scan ${payload} @${field_offset}H${cipherList_len_hex} cipherlist

    ## Parse through cipherlist, add each non-GREASE cipher to cipherSuite list.
    set cipher_cnt 0
    set parsed_cl $cipherlist
    set cipherSuite {}
    set cl_offset 0
    while {[scan $parsed_cl %4s%n cipherhex length] == 2} {
        if { [lsearch -sorted -inline $greaseList $cipherhex] eq "" } {
            lappend cipherSuite $cipherhex
            incr cipher_cnt
        } else {
            #log local0. "CipherList: Found GREASE cipher '${cipherhex}'"
        }
        set parsed_cl [string range $parsed_cl $length end]
    }
    ## Sort cipherSuite list.
    set cipherSuite [lsort $cipherSuite]
    ## Convert list to string.
    set cipher_list ""
    foreach cipher_hex $cipherSuite {
        append cipher_list "${cipher_hex},"
    }

    return $cipher_list

}






when CLIENT_ACCEPTED {
    ## Collect the TCP payload
    TCP::collect
}
when CLIENT_DATA {
    ## Get the TLS packet type and versions
    if { ! [info exists rlen] } {
        binary scan [TCP::payload] cH4ScH6H4 rtype outer_sslver rlen hs_type rlen inner_sslver

        if { ( ${rtype} == 22 ) and ( ${hs_type} == 1 ) } {
            set cipher_list [call getCipherList [TCP::payload] ${rlen} ${outer_sslver} ${inner_sslver} [IP::client_addr] [IP::local_addr]]
        }
    }

    # Collect the rest of the record if necessary
    if { [TCP::payload length] < $rlen } {
        TCP::collect $rlen
    }

    ## Release the payload
    TCP::release
}

when CLIENTSSL_CLIENTHELLO {

    ## Release the GREASE list so we can exclude these from the extensions list
    set greaseList {0a0a 1a1a 2a2a 3a3a 4a4a 5a5a 6a6a 7a7a 8a8a 9a9a aaaa baba caca dada eaea fafa}

    ## Get TCP/QUIC value for JA4
    set ja4c_qt "t"
    if { [SSL::extensions exists -type 39] } {
        set ja4c_qt "q"
    }

    ## Get SNI value for JA4
    set ja4c_sni "i"
    if { [SSL::extensions exists -type 00] } {
        set ja4c_sni "d"
    }

    ## Get count of ciphers and format it
    ## (cipher_cnt is upvar'd from getCipherList proc)
    set ja4c_ccnt $cipher_cnt
    if { $ja4c_ccnt > 99 } {
        set ja4c_ccnt 99
    }
    set ja4c_ccnt [format "%02d" $ja4c_ccnt]   

    ## Hash and truncate cipher list
    set cipher_list [string trimright ${cipher_list} ","]
    binary scan [sha256 ${cipher_list}] H* cipher_hash
    set trunc_cipher_hash [string range $cipher_hash 0 11]

    ## Get count of extensions so we can iterate through ALL of them
    set ext_count [SSL::extensions count]

    ## Parse through extensions list and sort them, excluding GREASE extensions
    set ja4c_ecnt 0
    set ext_types {}
    set ja4c_ver $inner_sslver
    set ja4_alpn "00"
    set sig_algo_list ""
    for {set i 0} {$i<$ext_count} {incr i} {
        binary scan [SSL::extensions -index $i] S1S1H* ext_type ext_len ext_val
        binary scan [SSL::extensions -index $i] H4 ext_type_hex
        if {[lsearch -sorted -inline $greaseList $ext_type_hex] ne "" } {
            #log local0. "Found GREASE extension: $ext_type_hex.  Ignoring.."
        } else {
            set ext_type [expr {$ext_type & 0xffff}]
            set ext_len [expr {$ext_len & 0xffff}]
    
            ## Parse 'Supported Versions' extension, append sver_list excluding GREASE values
            if { ${ext_type} == "43" } {
                set ver_list {}
                scan $ext_val %2x%s sver_len sver_list
                while {[scan $sver_list %4s%n sver_hex length] == 2} {
                    if { [lsearch -sorted -inline $greaseList $sver_hex] eq "" } {
                        lappend ver_list ${sver_hex}
                    } else {
                        #log local0. "Found GREASE version $sver_hex"
                    }
                    set sver_list [string range $sver_list $length end]
                }
                ## Sort the 'Supported Versions' list and only keep the highest version
                set ver_list [lsort $ver_list]
                set ja4c_ver [lindex $ver_list end]
                
                lappend ext_types ${ext_type_hex}
                incr ja4c_ecnt

            ## Parse Signature Algorithms extension to get sig_algo_list; exclude from ext type list
            } elseif { ${ext_type} == "13" } {
                regsub -all {\d{4}} ${ext_val} {&,} sig_algo_list
                set sig_algo_list [string trimright ${sig_algo_list} ","]
                incr ja4c_ecnt
                
            ## Parse 'ALPN' extension, to get first value, exclude from ext type list and count
            } elseif { ${ext_type} == "16" } {
                scan $ext_val %4x%s alpn_list_len alpn_list
                scan $alpn_list %2x%s alpn_len alpn_list
                set alpn_len [expr { ${alpn_len} * 2}]
                scan $alpn_list %${alpn_len}s%s alpn_val alpn_list
                set alpn_val [binary format H* $alpn_val]
                if { [string length ${alpn_val}] > 2 } {
                    set ja4_alpn "[string range ${alpn_val} 0 0][string range ${alpn_val} end end]"
                } else {
                    set ja4_alpn ${alpn_val}
                }
            ## Exclude SNI extension from ext type list and count 
            } elseif { ${ext_type} == "00" } {
                ## do nothing; already captured
            } else {
                lappend ext_types ${ext_type_hex}
                incr ja4c_ecnt
            }
        }

    }

    ## Format extensions count var
    if { $ja4c_ecnt > 99 } {
        set ja4c_ecnt 99
    }
    set ja4c_ecnt [format "%02d" $ja4c_ecnt]

    ## Sort and format extensions type list
    set ext_types [lsort $ext_types]
    set ext_type_list ""
    foreach ext_type_hex $ext_types {
        append ext_type_list "${ext_type_hex},"
    }
    set ext_type_list [string trimright ${ext_type_list} ","]
    ## if it exists, append signature algorithms list to end of ext_type_list string
    if { ${sig_algo_list} ne "" } {
        set ext_type_list "${ext_type_list}_${sig_algo_list}"
    }
    binary scan [sha256 ${ext_type_list}] H* ext_type_hash
    set trunc_ext_type_hash [string range $ext_type_hash 0 11]

    ## Format version
    switch $ja4c_ver {
        0304 { set ja4c_ver "13" }
        0303 { set ja4c_ver "12" }
        0302 { set ja4c_ver "11" }
        0301 { set ja4c_ver "10" }
        0300 { set ja4c_ver "s3" }
        0200 { set ja4c_ver "s2" }
        0100 { set ja4c_ver "s1" }
    }

    set ja4c_fp "${ja4c_qt}${ja4c_ver}${ja4c_sni}${ja4c_ccnt}${ja4c_ecnt}${ja4_alpn}_${trunc_cipher_hash}_${trunc_ext_type_hash}"

    log local0. "JA4TC FINGERPRINT -- $ja4c_fp -- JA4TC FINGERPRINT"

}

when HTTP_REQUEST {
    HTTP::header insert "X-JA4" $ja4c_fp
}

As you may be able to tell from the last few lines of the iRule, the JA4 fingerprint is injected into an HTTP request header.  This allows the web server (or, potentially, any other security component in the path) to "see" and act on the JA4 fingerprint.  Also, because this HTTP header is injected at HTTP_REQUEST time, it allows other modules in the BIGIP to "see" and potentially act on the JA4 fingerprint.

JA4 Fingerprint in HTTP Request Log

In my next article in this series on JA4, I'll detail how I could use F5 Advanced Web Application Firewall (AWAF) to block traffic based on the "reputation" of a JA4 fingerprint.

Requirements and Caveats

Currently, this iRule uses SSL iRule events and commands.  As such, it requires a Client SSL profile to be attached to any virtual server that has this iRule assigned.  This also means that SSL/TLS is terminated at the F5 device.  Depending on demand, I MAY create another version of this iRule that does not have this requirement.

Also, because the iRule is injecting an HTTP header, it requires that an HTTP profile is also assigned to the virtual server.  If you want to use this on a non-HTTP virtual server, you can simply remove the entire HTTP_REQUEST section of the iRule.  However, you will then want to uncomment the log line just above the HTTP_REQUEST event to log the JA4 fingerprint. Or, you could inject the JA4 fingerprint into the underlying protocol in some way; otherwise, you are simply generating a JA4 fingerprint and doing nothing with it.

Updated Jan 19, 2024
Version 2.0

Was this article helpful?

No CommentsBe the first to comment