JA4 Part 2: Detecting and Mitigating Based on Dynamic JA4 Reputation

In my previous article on JA4 I provided a brief introduction to what is JA4 and JA4+, and I shared an iRule that enables you to generate a JA4 client TLS fingerprint.
But having a JA4 fingerprint (or any "identifier") is only valuable if you can take some action on it.  It is even more valuable when you can take immediate action on it.
In this article, I'll explain how I integrated F5 BIG-IP Advanced WAF with a third-party solution that allowed me to identify JA4s that were consistently doing "bad" things, build a list of those JA4s that have a "bad" reputation, pull that list into the F5 BIG-IP, and finally, make F5 Advanced WAF blocking decisions based on that reputation.

Understanding JA4 Fingerprints

It is important to understand that a JA4 TLS fingerprint, or any TLS fingerprint for that matter, is NOT a fingerprint of an individual instance of a device or browser. Rather, it is a fingerprint of a TLS "stack" or application. For example, all Chrome browsers of the same version and the same operating system will generate the same JA4 fingerprint*. Similarly, all Go HTTP clients with the same version and operating system will generate an identical JA4 fingerprint. Because of this, we have to be careful when taking action based on JA4 fingerprints. We cannot simply block in our various security devices based on JA4 fingerprint alone UNLESS we can be certain that ALL (or nearly all) requests with that JA4 are malicious. To make this determination, we need to watch requests over time.


I used CrowdSec Security Engine to build a JA4 real-time reputation database; and 3 irules, an iCall script, and a custom WAF violation to integrate that JA4 reputation into F5 BIG-IP Advanced WAF.

CrowdSec and John Althouse - Serendipity

While at Black Hat each year, I frequently browse the showroom floor (when I'm not working the F5 booth) looking for cool new technology, particularly cool new technology that can potentially be integrated with F5 security solutions. Last year I was browsing the floor and came across CrowdSec. As the name suggests, CrowdSec provides a crowd-sourced IP reputation service. I know, I know.  On the surface this doesn't sound that exciting — there are hundreds of IP reputation services out there AND IP address, as an identifier of a malicious entity, is becoming (has become?) less and less valuable. So what makes CrowdSec any different?

Two things jumped out at me as I looked at their solution.

First, while they do provide a central crowd-sourced IP reputation service like everyone else, they also have "Security Engines".  A security engine is an agent/application that you can install on-premises that can consume logs from your various security devices, process those logs based on "scenarios" that you define, and produce a reputation database based on those scenarios.  This enables you to create an IP reputation feed that is based on your own traffic/logs and based on your own conditions and criteria for what constitutes "malicious" for your organization. I refer to this as "organizationally-significant" reputation.  AND, because this list can be updated very frequently (every few seconds if you wanted) and pushed/pulled into your various security devices very frequently (again, within seconds), you are afforded the ability to block for much shorter periods of time and, possibly, more liberally.

Inherent in such an architecture, as well, is the ability for your various security tools to share intelligence in near real-time.  i.e. If your firewall identifies a bad actor, your WAF can know about that too.  Within seconds!

At this point you're probably wondering, "How does this have anything to do with JA4?"
Second, while the CrowdSec architecture was built to provide IP reputation feeds, I discovered that it can actually create a reputation feed based on ANY "identifier".  In the weeks leading up to Black Hat last year, I had been working with John Althouse on the JA4+ spec and was actually meeting him in person for the first time while there.  So JA4 was at the forefront of my mind.  I wondered if I could use CrowdSec to generate a reputation based on a JA4 fingerprint. Yes! You can!

Deploying CrowdSec

As soon as I got home from Black Hat, I started playing.  I already had my BIG-IP deployed, generating JA4s, and including those in the WAF logs.  Following the very good documentation on their site, I created an account on CrowdSec's site and deployed a CrowdSec Security Engine on an Ubuntu box that I deployed next to my BIG-IP.

It is beyond the scope of this article to detail the complete deployment process but, I will include details relevant to this article.

After getting the CrowdSec Security Engine deployed I needed to configure a parser so that the CrowdSec Security Engine (hereafter referred to simply as "SE") could properly parse the WAF logs from F5.
Following their documentation, I created a YAML file at /etc/crowdsec/parsers/s01-parse/f5-waf-logs.yaml:


onsuccess: next_stage
debug: false
filter: "evt.Parsed.program == 'ASM'"
name: f5/waf-logs
description: "Parse F5 ASM/AWAF logs" pattern_syntax: 

F5WAF: 'unit_hostname="%{DATA:unit_hostname}",management_ip_address="%{DATA:management_ip_address}",management_ip_address_2="%{DATA:management_ip_address_2}",http_class_name="%{DATA:http_class_name}",web_application_name="%{DATA:web_application_name}",policy_name="%{DATA:policy_name}",policy_apply_date="%{DATA:policy_apply_date}",violations="%{DATA:violations}",support_id="%{DATA:support_id}",request_status="%{DATA:request_status}",response_code="%{DATA:response_code}",ip_client="%{IP:ip_client}",route_domain="%{DATA:route_domain}",method="%{DATA:method}",protocol="%{DATA:protocol}",query_string="%{DATA:query_string}",x_forwarded_for_header_value="%{DATA:x_forwarded_for_header_value}",sig_ids="%{DATA:sig_ids}",sig_names="%{DATA:sig_names}",date_time="%{DATA:date_time}",severity="%{DATA:severity}",attack_type="%{DATA:attack_type}",geo_location="%{DATA:geo_location}",ip_address_intelligence="%{DATA:ip_address_intelligence}",username="%{DATA:username}",session_id="%{DATA:session_id}",src_port="%{DATA:src_port}",dest_port="%{DATA:dest_port}",dest_ip="%{DATA:dest_ip}",sub_violations="%{DATA:sub_violations}",virus_name="%{DATA:virus_name}",violation_rating="%{DATA:violation_rating}",websocket_direction="%{DATA:websocket_direction}",websocket_message_type="%{DATA:websocket_message_type}",device_id="%{DATA:device_id}",staged_sig_ids="%{DATA:staged_sig_ids}",staged_sig_names="%{DATA:staged_sig_names}",threat_campaign_names="%{DATA:threat_campaign_names}",staged_threat_campaign_names="%{DATA:staged_threat_campaign_names}",blocking_exception_reason="%{DATA:blocking_exception_reason}",captcha_result="%{DATA:captcha_result}",microservice="%{DATA:microservice}",tap_event_id="%{DATA:tap_event_id}",tap_vid="%{DATA:tap_vid}",vs_name="%{DATA:vs_name}",sig_cves="%{DATA:sig_cves}",staged_sig_cves="%{DATA:staged_sig_cves}",uri="%{DATA:uri}",fragment="%{DATA:fragment}",request="%{DATA:request}",response="%{DATA:response}"'
- grok: 
  name: "F5WAF"
  apply_on: message
  - meta: log_type
    value: f5waf
  - meta: user
    expression: "evt.Parsed.username"
  - meta: source_ip
    expression: "evt.Parsed.ip_client"
  - meta:violation_rating
  - meta:request_status
  - meta:attack_type
  - meta:support_id
  - meta:violations
  - meta:sub_violations
  - meta:session_id
  - meta:sig_ids
  - meta:sig_names
  - meta:method
  - meta:device_id
  - meta:uri
    expression:"evt.Parsed.uri" nodes: 
- grok: 
  pattern: '%{GREEDYDATA}X-JA4: %{DATA:ja4_fp}\\r\\n%{GREEDYDATA}'
  apply_on: request
  - meta: ja4_fp


Sending WAF Logs


On the F5 BIG-IP, I created a logging profile to send the WAF logs to the CrowdSec Security Engine IP address and port.

Defining "Scenarios"

At this point, I had the WAF logs being sent to the SE and properly being parsed.  Now I needed to define the "scenarios" or the conditions under which I wanted to trigger and alert for an IP address or, in this case, a JA4 fingerprint.

For testing purposes, I initially created a very simple scenario that flagged a JA4 as malicious as soon as I saw 5 violations in a sliding 30 second window but only if the violation rating was 3 or higher.
That worked great!  But that would never be practical in the real world (see the Understanding JA4 Fingerprints section above).  I created a more practical "scenario" that only flags a JA4 as malicious if we have seen at least X number of requests AND more than 90% of requests from that JA4 have triggered some WAF violation.  The premise with this scenario is that there should be enough legitimate traffic from popular browsers and other client types to keep the percentage of malicious traffic from any of those JA4s below 90%.  

Again, following the CrowdSec documentation, I created a YAML file at /etc/crowdsec/scenarios/f5-waf-ja4-viol-percent.yaml:


type: conditional
name: f5/waf-ja4-viol-percent
description: "Raise an alert if the percentage of requests from a ja4 finerprint is above X percent"
filter: "evt.Meta.violations != 'JA4 Fingerprint Reputation'"
blackhole: 300s
leakspeed: 5m
capacity: -1
condition: | len(queue.Queue) > 10 and (count(queue.Queue, Atof(#.Meta.violation_rating) > 1) / len(queue.Queue)) > 0.9
groupby: "evt.Meta.ja4_fp"
scope: type: ja4_fp
expression: evt.Meta.ja4_fp
  service: f5_waf
  type: waf_ja4
  remediation: true
debug: false


There are a few key lines to call out from this configuration file.
leakspeed: This is the "sliding window" within which we are looking for our "scenarios".  i.e. events "leak" out of the bucket after 5 minutes.
condition: The conditions under which I want to trigger this bucket.  For my scenario, I have defined a condition of at least 10 events (with in that 5 minute window) AND where the total number of events, divided by the number of events where the violation rating is above 1, is greater than 0.9.  in other words, if more than 90% of the requests have triggered a WAF violation with a rating higher than 1.
filter:  used to filter out events that you don't want to include in this scenario.  In my case, I do not want to include requests where the only violation is the "JA4 Fingerprint Reputation" violation.
groupby: this defines how I want to group requests.  Typiiccally, in most CrowdSec scenarios this wil be some IP address field from the logs.  In my scenario, I wanted to group by the JA4 fingerprint parsed out of the WAF logs.
blackhole: this defines how long I want to "silence" alerts per JA4 fingerprint after this scenario has triggered.  This prevents the same scenario from triggering repeatedly every time a new request comes into the bucket.
scope: the scope is used by the reputation service to "categorize" alerts triggered by scenarios.  the type field is used to define the type of data that is being reported.  In most CrowdSec scenarios the type is "ip".  In my case, I defined a custom type of "ja4_fp" with an "expression" (or value) of the JA4 fingerprint extracted from the WAF logs.

Defining "Profiles"

In the CrowdSec configuration "profiles" are used to define the remediation that should be taken when a scenario is triggered.  I edited the /etc/crowdsec/profiles.yaml file to include the new profile for my JA4 scenario.


name: ban_ja4_fp
- Alert.Remediation == true && Alert.GetScope() == "ja4_fp"
- type: ban
  scope: "ja4_fp"
duration: 5m
debug: true
on_success: continue
##### Everything below this point was already in the profiles.yaml file. Truncated here for brevity.
name: default_ip_remediation
#debug: true
- Alert.Remediation == true && Alert.GetScope() == "Ip"
on_success: break


Again, there are a few key lines from this configuration file.  First, I only added a new profile named "ban_ja4_fp" with lines 1 through 9 in the file above.
filters: Used to define which triggered scenarios should be included in this profile.  In my case, all scenarios with the "remediation" label AND the "ja4_fp" scope.
decisions: Used to define what type of remediation should be taken, for which "scope", and for how long. In my case, I chose the default of "ban", for the "ja4_fp" scope, and for 5 minutes.

With this configuration in place I sent several malicious requests from my browser to my test application protected by the F5 Advanced WAF.  I then checked the CrowdSec decisions list and voila! I had my browser's JA4 fingerprint listed!

This was great but I wanted to be able to take action based on this intelligence in the F5 WAF.  CrowdSec has the concept of "bouncers".  Bouncers are devices the can take action on the remediation decisions generated by the SEs.  Technically, anything that can call the local CrowdSec API and take some remediating action can be a bouncer.  So, using the CLI on the CrowdSec SE, I defined a new "bouncer" for the F5 BIG-IP.  


ubuntu@xxxxxxxx:~$ sudo cscli bouncer add f5-bigip
Api key for 'f5-bigip': xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Please keep this key since you will not be able to retrieve it!


I knew that I could write an iRule that could call the SE API. However, the latency introduced by a sideband API call on EVERY HTTP request would just be completely untenable.  I wanted a way to download the entire reputation list at a regular interval and store it on the F5 BIG-IP in a way that would be easily and efficiently accessible from the data plane.  This sounded like a perfect job for an iCall script.

Customizing the F5 BIG-IP Configuration

If you are not familiar with iCall scripts, they are a programmatic way of checking or altering the F5 configuration based on some trigger; they are to the F5 BIG-IP management plane what iRules are to the data plane.  The trigger can be some event, condition, log message, time interval, etc.  
I needed my iCall script to do two things. First, pull the reputation list from the CrowdSec SE.  Second, store that list somewhere accessible to the F5 data plane.  Like many of you, my first thought was either an iFile or a data group.  Both of these are easily configurable components accessible via iCall scripts that are also accessible via iRules.  For several reasons that I will not bother to detail here, I did not want to use either of these solutions, primarily for performance reasons (this reputation lookup needs to be very performant).  And the most performant place to store information like this is the session table.  The session table is accessible to iRules via "table" commands.  However, the session table is not accessible via iCall scripts.  At least not directly.  I realized that I could send an HTTP request using the iCall script, AND that HTTP request could be to a local virtual server on the same BIG-IP where I could use an iRule to populate the session table with the JA4 reputation list pulled from the CrowdSec SE.

The iCall Script

From the F5 BIG-IP CLI I created the following iCall script using the tmsh command 'tmsh create sys icall script crowdsec_ja4_rep_update':


sys icall script crowdsec_ja4_rep_update {
  app-service none
  definition {
    package require http
    set csapi_resp [http::geturl -headers "X-api-Key 1a234xxxxxxxxxxxxxxe56fab7"]
    #tmsh::log "[http::data ${csapi_resp}]"
    set payload [http::data ${csapi_resp}]
    http::cleanup ${csapi_resp}
    set tupdate_resp [http::geturl -type "application/json" -query ${payload}]
    tmsh::log "[http::data ${tupdate_resp}]"
    http::cleanup ${tupdate_resp} 
  description none
  events none


Let's dig through this iCall script line by line:

4. Used to "require" or "include" the TCL http library.
5. HTTP request to the CrowdSec API to get the JA4 reputation list. is the IP:port of the CrowdSec SE API
/v1/decisions/stream is the API endpoint used to grab an entire reputation list (rather than just query for the status of an individual IP/JA4)
startup=true tells the API to send the entire list, not just additions/deletions since the last API call
scopes=ja4_fp limits the returned results to just JA4 fingerprint-type decisions
-headers "X-api-Key xxxxxxxxxxxxxxxxxxxxxxxxxx" includes the API key generated previously to authenticate the F5 BIG-IP as a "bouncer"

7. Store just the body of the API response in a variable called "payload"
8. free up memory used by the HTTP request tot eh CrowdSec API

9. HTTP Request to a local virtual server (on the same F5 BIG-IP) including the contents of the "payload" variable as the POST body.

The IP address needs to be the IP address of the virtual server defined in the next step.  An iRule will be created and placed on this virtual server that parses the "payload" and inserts the JA4 reputation list into the session table.

An iCall script will not run unless an iCall handler is created that defines when that iCall script should run.  I call handlers can be "triggered", "perpetual", or "periodic".   I created the following periodic iCall handler to run this iCall script at regular intervals.


sys icall handler periodic crowdsec-api-ja4 {
  interval 30
  script crowdsec_ja4_list


This iCall handler is very simple; it has an "interval" for how often you want to run the script and the script that you want to run.  I chose to run the iCall script every 30 seconds so that the BIGIP session table would be updated with any new malicious JA4 fingerprints very quickly.  But you could choose to run the iCall script every 1 minute, 5 minutes, etc.

The Table Updater Virtual Server and iRule

I then created a HTTP virtual server with no pool associated to it.  This virtual server exists solely to accept and process the HTTP requests from the iCall script.
I then created the following iRule to process the requests and payload from the iCall script:


proc duration2seconds {durstr} {
    set h 0
    set m 0
    set s 0
    regexp {(\d+)h} ${durstr} junk h
    regexp {(\d+)m} ${durstr} junk m
    regexp {(\d+)\.} ${durstr} junk s
    set seconds [expr "(${h}*3600) + (${m}*60) + ${s}"]
    return $seconds

    if { ([HTTP::uri] eq "/updatetables" || [HTTP::uri] eq "/lookuptables") && [HTTP::method] eq "POST"} {
        HTTP::collect [HTTP::header value "content-length"]
    } else {
        HTTP::respond 404
    #log local0. "PAYLOAD: '[HTTP::payload]'"
    regexp {"deleted":\[([^\]]+)\]} [HTTP::payload] junk cs_deletes
    regexp {"new":\[([^\]]+)\]} [HTTP::payload] junk cs_adds
    if { ![info exists cs_adds] } {
        HTTP::respond 200 content "NO NEW ENTRIES"
    log local0. "CS Additions: '${cs_adds}'"
    set records [regexp -all -inline -- {\{([^\}]+)\},?} ${cs_adds}]

    set update_list [list]
    foreach {junk record} $records {
        set urec ""
        foreach k {scope value type scenario duration} {
            set v ""
            regexp -- "\"${k}\":\"?(\[^\",\]+)\"?,?" ${record} junk v
            log local0. "'${k}': '${v}'"
            if { ${k} eq "duration" } {
                set v [call duration2seconds ${v}]
            append urec "${v}:"
        set urec [string trimright ${urec} ":"]
        #log local0. "$urec"
        lappend update_list ${urec}

    set response ""
    foreach entry $update_list {
        scan $entry {%[^:]:%[^:]:%[^:]:%[^:]:%s} scope entity type scenario duration
        if { [HTTP::uri] eq "/updatetables" } {
            table set "${scope}:${entity}" "${type}:${scenario}" indefinite $duration
            append response "ADDED ${scope}:${entity} FOR ${duration} -- "
        } elseif { [HTTP::uri] eq "/lookuptables" } {
            set remaining ""
            set action ""
            if { [set action [table lookup ${scope}:${entity}]] ne "" } {
                set remaining [table lifetime -remaining ${scope}:${entity}]
                append response "${scope}:${entity} - ${action} - ${remaining}s remaining\r\n"
            } else {
                append response "${scope}:${entity} - NOT IN TABLE\r\n"

    HTTP::respond 200 content "${response}"


I have attempted to include sufficient inline comments so that the iRule is self-explanatory. If you have any questions or comments on this iRule please feel free to DM me.

It is important to note here that the iRule is storing not only each JA4 fingerprint in the session table as a key but also the metadata passed back from the CrowdSec API about each JA4 reputation as the value for each key.  This metadata includes the scenario name, the "type" or action, and the duration.

So at this point I had a JA4 reputation list, updated continuously based on the WAF violation logs and CrowdSec scenarios. I also had an iCall script on the F5 BIG-IP that was pulling that reputation list via the local CrowdSec API every 30 seconds and pushing that reputation list into the local session table on the BIG-IP.

Now I just needed to take some action based on that reputation list.

Integrating JA4 Reputation into F5 WAF

To integrate the JA4 reputation into the F5 Advanced WAF we only need two things:

  1. a custom violation defined in the WAF
  2. an iRule to lookup the JA4 in the local session table and raise that violation

Creating a Custom Violation

Creating a custom violation in F5 Advanced WAF (or ASM) will vary slightly depending on which version of the TMOS software you are running.  In version 17.1 it is at Security  ››  Options : Application Security : Advanced Configuration : Violations List.  Select the User-Defined Violations tab and click Create.

Give the Violation a Title and define the Type, Severity, and Attack Type.  

Finally, I modified the Learning and Blocking Settings of my policy to ensure that the new custom violation was set to Alarm and Block.

F5 iRule for Custom Violation

I then created the following iRule to raise this new custom WAF violation if the JA4 fingerprint is found in the reputation list in the local session table.

  # Grab JA4 fingerprint from x-ja4 header
  # This header is inserted by the JA4 irule
  set ja4_fp [HTTP::header value "x-ja4"]
  # Lookup JA4 fingerprint in session table
  if { [set result [table lookup "ja4_fp:${ja4_fp}"]] ne "" } {
    # JA4 was found in session table, scan the value to get "category" and "action"
    scan ${result} {%[^:]:%s} action category
    # Initialize all the nested list of lists format required for the
    # violation details of the ASM::raise command
    set viol []
    set viol_det1 []
    set viol_det2 []
    set viol_det3 []
    # Populate the variables with values parsed from the session table for this JA4
    lappend viol_det1 "JA4 FP" "${ja4_fp}"
    lappend viol_det2 "CrowdSec Category" "${category}"
    lappend viol_det3 "CrowdSec Action" "${action}"
    lappend viol ${viol_det1} ${viol_det2} ${viol_det3}
    # Raise custom ASM violation with violation details
    ASM::raise VIOL_JA4_REPUTATION ${viol}

Again, I tried to include enough inline documentation for the iRule to be self-explanatory.


Ensure that your WAF policy has Trigger iRule Events enabled. If it is not enabled the ASM_REQUEST_DONE event referenced in this iRule will not fire.

Seeing It All In Action

With everything in place, I sent several requests, most malicious and some benign, to the application protected by the F5 Advanced WAF.  Initially, only the malicious requests were blocked.  After about 60 seconds, ALL of the requests were being blocked due to the new custom violation based on JA4 reputation.

Below is a screenshot from one of my honeypot WAF instances blocking real "in-the-wild" traffic based on JA4 reputation.  Note that the WAF violation includes (1) the JA4 fingerprint, (2) the "category" (or scenario), and (3) the "action" (or type).

Things to Note

The API communication between the F5 BIG-IP and the CrowdSec SE is over HTTP.  This is obviously insecure; for this proof-of-concept deployment I was just too lazy to spend the extra time to get signed certs on all the devices involved and alter the iCall script to use the TCL SSL library.

Updated Jul 02, 2024
Version 2.0

Was this article helpful?

No CommentsBe the first to comment