Introducing crs-trigger.py


This is a blog post about a new script, that will execute a request in order trigger an arbitrary Core Rule Set anomaly score.

The OWASP ModSecurity Core Rule Set (short CRS) is a scoring rule set with individual rules working together to assess an incoming request and assigning it an anomaly score. An administrator can then assign an anomaly threshold which will lead to a blockade of the request.

By default, CRS comes with anomaly threshold of 5, which equals one critical rule violation. But it is a good practice to start with a higher anomaly threshold and work your way down as you tune the configuration and weed out false positives.

I have always liked the idea to score a discrete anomaly score by triggering different rules via the same request. Most rules score 5 (critical), but there are also rules scoring 2 (info), 3 (warning) and 4 (error) points. Technically, we are thus able to score all integer numbers with the exception of 1 when you combine these rules: 7 could be a combination of a critical rule plus a rule with severity info (5 + 2) . Or two info rules plus a warning (2 + 2 + 3).

If there was a rule that could be triggered multiple time with the same request, then we could actually trigger arbitrary high values. There are practical limits to this, as we will come to see, but we can score quite high. Surprisingly high.

But there is something that makes me feel uneasy: Many of the rules take a real attacking payload to trigger. I am a blue team player, I defend web servers for a living, so I do not enjoy firing SQLinjections at servers that I do not own. No, I want use payloads that are benign and do not put me in legal jeopardy (I live in Switzerland and jurisdiction about hacking is very strict here).

So I am a quite picky with the selection of the rules, but in a body of over 150 rules, there should be some choice. Here is what I found.

920310 : Request Has an Empty Accept Header (2 points)

This rule tests for an empty accept header. If the header exists, but does not contain any value, then this triggers the rule.

RFC 2616 defines HTTP request headers in a way that allows a client to send empty request headers, so there is nothing wrong with doing this. It’s just that there is a Core Rule Set rule that monitors this behavior and triggers an alert.

920330 : Empty User Agent Header (2 points)

This is very close to 920310 with the only difference being this rule inspecting the User Agent header.

920190 : Range: Invalid Last Byte Value (3 points)

This rule can be triggered with a Range header of the following form : Range: 2-1,

This is a syntactically incorrect range header and servers must ignore it according to the RFC. However, CRS will take notice and score 3 points (warning).

920210 : Multiple/Conflicting Connection Header Data Found (3 points)

This rule looks for conflicting Connections headers like the following : Connection: keep-alive,close

The RFC does not say what the server is supposed to do in this situation, but it is obvious that sending this header does not make much sense. It should not do any harm though. Closing the connection would probably be the best course of action and this is also what Apache does, when it receives this header.

920350 : Host header is a numeric IP address (3 points)

Host header values must be the domain name of a server according to the RFC. But using an IP address instead is a widespread practice, namely among load balancers. CRS does not like this, though, and assigns 3 points to this malpractice.

913110 : Found request header associated with security scanner (5 points)

This is an interesting rule as it watches for request headers that it associates with security scanners. The list of clues reside in a file names scanners-headers.data. This file is very old and I did not see much change in recent years. It also comes without comments which makes it hard to understand where the values come from. One value caught my attention, though: x-scanner.

If you are setting this header, you trigger a critical alert. The value of the header does not matter. And when inspecting the rule a bit closer, it becomes obvious the rule does not look for this very header name, but for header names containing this string. This allows to add a suffix to the header name. We can thus enumerate the suffixes and submit the header several times with each separate header field adding a score of 5.

Now what do we do with all these rules?

Web servers come with a maximum number of headers that they accept in a request with the limit being several dozen header fields. Testing reveals, that you can use an enumerating set of this header to trigger scores of several hundred points. When combined with the other rules above, we can trigger anomaly scores in the range from 2 to 490 on a default Apache server.

When playing around with this, I found out that the rule 920350 (Numeric Host header) leads to practical problems as many people are disabling it since so many clients do this and the logs end up with too many false positives. So 920350 is not really useful in our situation. But the other ones can be freely combined.

And this is what I did: Please checkout my script crs-trigger.py.

The central part of the script is this routine:

while score > 0:
    # We iterate through the assembly and add headers to trigger alerts / anomaly scores
    # until we hit 0.
    if score > 11:
        headers = trigger_score_5_913120(headers)
        score = score - 5
    elif score == 11:
        headers = trigger_score_5_913120(headers)
        headers = trigger_score_3_920190(headers)
        headers = trigger_score_3_920210(headers)
        score = score - 11
    elif score == 10:
        headers = trigger_score_5_913120(headers)
        headers = trigger_score_3_920190(headers)
        headers = trigger_score_2_920310(headers)
        score = score - 10
    elif score == 9:
        headers = trigger_score_5_913120(headers)
        headers = trigger_score_2_920310(headers)
        headers = trigger_score_2_920330(headers)
        score = score - 9
    elif score == 8:
        headers = trigger_score_5_913120(headers)
        headers = trigger_score_3_920190(headers)
        score = score - 8
    elif score == 7:
        headers = trigger_score_3_920190(headers)
        headers = trigger_score_2_920310(headers)
        headers = trigger_score_2_920330(headers)
        score = score - 7
    elif score == 6:
        headers = trigger_score_3_920190(headers)
        headers = trigger_score_3_920210(headers)
        score = score - 6
    elif score == 5:
        headers = trigger_score_3_920190(headers)
        headers = trigger_score_2_920310(headers)
        score = score - 5
    elif score == 4:
        headers = trigger_score_2_920310(headers)
        headers = trigger_score_2_920330(headers)
        score = score - 4
    elif score == 3:
        headers = trigger_score_3_920190(headers)
        score = score - 3
    elif score == 2:
        headers = trigger_score_2_920310(headers)
        score = score - 2
    elif score == 1:
        # We should not end up here, this ought to be caught earlier or
        # the assembly of the alerts to trigger did something wrong.
        print "Error with the assembly of alerts; hitting score 1. This is fatal. Aborting."
        sys.exit(1)

So we are building a request with a combination of characteristics that will trigger an exact score on a webserver running CRS 3; 3.2 in my case.

How does the script run in practice?

$> python /tmp/crs-trigger.py --score 19 http://localhost
[*] Checking http://localhost …
Redirect points to http://localhost/index.html. Following redirect.
Request successful with status code 200.

Let’s look at the alerts that this request triggered:

913110 Found request header associated with security scanner
913110 Found request header associated with security scanner
913110 Found request header associated with security scanner
920310 Request Has an Empty Accept Header
920330 Empty User Agent Header

Crosschecking with the description of the rules above confirms: This request scored 19 points. Actually my extended Access Log supports this:

127.0.0.1 - - [2020-02-05 04:22:18.105844] "GET / HTTP/1.1" 200 45 "-" "" "-" 58182 localhost 127.0.0.1 80 - - + "-" Xjo06olSTDGUKs3Hv5GZPgAAAAs - - 192 256 -% 2123 1179 19 172 19-0-0-0 0-0-0-0 19 0

The incoming anomaly score is the 2nd number from the end of the line in this format; the number 19.

I’m very pleased with this outcome.

Maybe you are not yet understanding why this is kind of a big deal or where I am taking this. The point is that this allows me to probe a server to see if it has CRS configured and if yes at which anomaly threshold. I do not know any WAF outside of ModSecurity / CRS that triggers on a header name containing the string x-scanner. So if we trigger a score of 30 and the request is blocked, then it’s a pretty sure sign the server runs CRS.

The next step is now to automate the probing and return a definitive report about the use of CRS on the server. I’ll write another blog, when I am done.



Christian Folini / @ChrFolini on Twitter