Skip to content

Stock OWASP CRS does not evaluate correctly: skipAfter, TX key case, block, and unimplemented variables #29

Description

@raffaelschneider

Problem Statement

Four engine defects make stock OWASP CRS evaluate incorrectly. All four were confirmed by probe against main @ 3905ee1 with CRS main (4.30.0-dev), not by reading the code.

They mask each other, so they should be fixed and tested together. Fixing any one alone will look like a regression:

  • (3) makes every matching rule block, so traffic is stopped — which hides (1) and (2).
  • (2) stops CRS's paranoia gates from ever firing, which hides (1).
  • Fix (3) alone and the WAF stops blocking anything at all, because (1) and (2) mean the anomaly score never reaches 949110.

Ordered by impact.


1. skipAfter in phase ≥ 2 discards the rest of the phase

SecMarker is registered only under Phase::RequestHeaders, regardless of the phase of the rules around it:

// src/engine/ruleset.rs:290
Directive::SecMarker(marker) => {
    // Add marker at current position in default phase
    let phase = Phase::RequestHeaders;

but the runtime only resumes when the marker's phase matches the phase being evaluated:

// src/engine/transaction.rs:427
if marker_phase == phase && marker_idx > idx {
    idx = marker_idx;
    skip_after = None;
    continue;
}
// Marker not found or in different phase, continue
idx += 1;
continue;

For a phase-2 skipAfter the marker is filed under phase 1, the branch is never taken, and the loop walks to the end of the phase skipping every remaining rule.

This is the highest-impact of the four: CRS gates every rule file with a phase-2 paranoia check (id:942012,phase:2,...,skipAfter:END-REQUEST-942-APPLICATION-ATTACK-SQLI), and 949110 — the rule that actually blocks on anomaly score — sits after all of them. So anomaly-score blocking can never run.

// phase 1: correct -> ["1", "3"]
SecAction "id:1,phase:1,pass,nolog,skipAfter:M1"
SecRule REQUEST_METHOD "@streq POST" "id:2,phase:1,pass,nolog"
SecMarker M1
SecRule REQUEST_METHOD "@streq POST" "id:3,phase:1,pass,nolog"

// phase 2: wrong -> ["11"], expected ["11", "13"]
SecAction "id:11,phase:2,pass,nolog,skipAfter:M2"
SecRule ARGS "@rx hello" "id:12,phase:2,pass,nolog"
SecMarker M2
SecRule ARGS "@rx hello" "id:13,phase:2,pass,nolog"

A marker needs to be recorded per phase — at the current index of each phase it separates — rather than at a single index in phase 1.


2. TX collection keys are case-sensitive

ModSecurity treats collection keys case-insensitively. CRS relies on this: it writes lowercase and reads uppercase.

SecAction "id:1,phase:2,pass,nolog,setvar:'tx.blocking_paranoia_level=1'"

SecRule TX:blocking_paranoia_level "@ge 1" "id:2,phase:2,deny"  // fires
SecRule TX:BLOCKING_PARANOIA_LEVEL "@ge 1" "id:3,phase:2,deny"  // does NOT fire

Macro expansion has the same problem — %{TX.CRITICAL_ANOMALY_SCORE} does not resolve a value set as tx.critical_anomaly_score.

Consequence: 949060 (SecRule TX:BLOCKING_PARANOIA_LEVEL "@ge 1" ... setvar:'tx.blocking_inbound_anomaly_score=+%{tx.inbound_anomaly_score_pl1}') never runs, so per-rule anomaly scores are never summed and 949110 has nothing to compare. Verified: after 15 CRS attack rules match a SQLi request, tx.blocking_inbound_anomaly_score is still unset.

setvar increment semantics themselves are correct — =+, macro increments, and increment-from-unset all behave — this is purely key lookup.


3. block is an immediate deny instead of inheriting SecDefaultAction

In ModSecurity, block means "use the disruptive action from SecDefaultAction". CRS sets:

SecDefaultAction "phase:1,log,auditlog,pass"
SecDefaultAction "phase:2,log,auditlog,pass"

so in anomaly-scoring mode block must resolve to pass, leaving the decision to 949110. Here it is its own disruptive action:

// src/actions/mod.rs:171
DisruptiveAction::Block => DisruptiveOutcome::Block,

and merge_default_actions (src/parser/mod.rs:468) drops the default pass because both are Action::Disruptive, so the rule's block wins.

SecDefaultAction "phase:2,log,auditlog,pass"
SecRule REQUEST_METHOD "@streq POST" "id:3,phase:2,block"   // intervenes; should not

Effect: CRS runs as block-on-first-match rather than anomaly scoring — including on protocol-enforcement false positives, and with no threshold, no paranoia levels and no scoring.

Note SecDefaultAction is also stored as a single flat Vec<Action> (src/parser/mod.rs:46) with no phase dimension, though CRS sets one per phase. Secondary, but the same fix area.


4. Unimplemented variables resolve to empty, and negation inverts that into "always match"

resolver.rs:249 ends the match with _ => vec![], and an empty variable list under a negated operator is reported as a match:

// src/engine/transaction.rs:707
// Variables were specified but resolved to nothing (e.g. absent header).
return Ok(RuleOutcome {
    matched: rule.operator_negated,

REQUEST_LINE is one of the unimplemented ones, and CRS 920100 is a negated regex over it — so it matches every request:

SecRuleEngine On
SecRule REQUEST_LINE "!@rx (?i)^(?:connect ...|[a-z]{3,10}[\s\x0b]+...)$" "id:920100,phase:2,deny"
// benign POST /api HTTP/1.1 -> BLOCKED

With stock CRS loaded, 920100 denies 100% of traffic. REQUEST_LINE "@rx ." matches nothing, confirming the variable is simply absent.

Variables CRS references that are accepted by the parser but unimplemented in the resolver:

Variable CRS rules Notes
REQUEST_LINE 7 incl. 920100, negated → always fires
REQUEST_BASENAME 5 incl. one negated !@endsWith .pdf → always fires
XML 176 see zentinelproxy/zentinel#441
ARGS_COMBINED_SIZE 1
FILES_COMBINED_SIZE 1
UNIQUE_ID 1

Two things worth separating here: implementing the missing variables, and deciding what an unimplemented-but-parsed variable should do. The precedent from #25 (report unusable @rx patterns rather than match nothing) argues for a load-time diagnostic rather than a silent empty resolve — and note #25's own trap applies again, that preserving negation over a never-matching variable turns the rule into one that matches everything.


Notes for whoever verifies this

Three things cost me wrong readings before I isolated them:

  • matched_rules() records a chained rule's ID when only its first leg matched, even though the rule did not fire. It is not a list of rules that fired.
  • has_intervention() is transaction-wide, so appending a probe rule and reading it tells you nothing if an earlier rule already intervened — under stock CRS, 920100 intervenes on everything.
  • ModSecurity::from_string does not resolve relative Include paths; a CRS config loaded that way silently has no rules in it. Use from_file with absolute includes.

Chaining, & count, and setvar increments were all checked and are correct — they are not part of this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions