Summary

Coraza: Silent argument drop at ArgumentLimit allows bypass of ARGS-targeted rules via parameter flooding

Advisory details

Root Cause

File: internal/corazawaf/transaction.go, lines 770–808 (since commit 2fd87b89, PR #812, 2023-06-14)

func (tx *Transaction) AddGetRequestArgument(key string, value string) {
    if tx.checkArgumentLimit(tx.variables.argsGet) {
        tx.debugLogger.Warn().Msg("skipping get request argument, over limit")
        return
    }
    tx.variables.argsGet.Add(key, value)
}

func (tx *Transaction) checkArgumentLimit(c *collections.NamedCollection) bool {
    return c.Len() >= tx.WAF.ArgumentLimit
}

AddGetRequestArgument, AddPostRequestArgument, and AddPathRequestArgument silently return once the per-collection argument count reaches WAF.ArgumentLimit (default 1000, see internal/corazawaf/waf.go:346). No error variable is set, no transaction flag is raised, and no rule can observe that a drop occurred.

Worse, ExtractGetArguments (transaction.go:761) iterates the map[string][]string returned by urlutil.ParseQuery:

func (tx *Transaction) ExtractGetArguments(uri string) {
    data := urlutil.ParseQuery(uri, '&')
    for k, vs := range data {        // Go map iteration order is randomized
        for _, v := range vs {
            tx.AddGetRequestArgument(k, v)
        }
    }
}

Because Go randomizes map iteration order, which of the caller-supplied arguments survive the limit is non-deterministic. An attacker can pad the URI with filler arguments; any one of them — including the malicious payload — may be the one silently discarded, and therefore invisible to every SecRule targeting ARGS, ARGS_GET, or ARGS_NAMES.

Secondary finding — POST urlencoded processor bypasses the cap entirely

internal/bodyprocessors/urlencoded.go:29 populates ARGS_POST without invoking checkArgumentLimit:

values := urlutil.ParseQuery(b, '&')
argsCol := v.ArgsPost()
for k, vs := range values {
    argsCol.Set(k, vs)            // direct write, no limit check
}

So AddPostRequestArgument's cap is effectively dead code for real urlencoded bodies. ARGS_POST grows unbounded — both a bypass surface and a memory-DoS surface.

Impact

Any SecRule or CRS rule that inspects ARGS, ARGS_GET, ARGS_NAMES, ARGS_GET_NAMES, or ARGS_PATH can be evaded by inflating the request's argument count past SecArgumentsLimit (default 1000). The bypass probability per request scales with overflow:

Total args in request Observed bypass rate of ARGS rule
1000 (at limit) 0 / 50 (0.0%)
1001 (1 over) 0 / 2000 (< 0.1%)
1100 (100 over) 45 / 500 (9.0%)
2000 (1000 over) 106 / 200 (53.0%)
10000 (10× limit) 47 / 50 (94.0%)

The rate matches the theoretical model (N − limit) / N. An attacker flooding with 10000 arguments lands a bypass on ~94% of requests; one failed attempt costs them nothing, so a handful of retries yields a near-certain evasion against any ARGS-targeted rule, including the OWASP CRS SQLi, XSS, RCE, and LFI detection families.

The issue is silent — operators see no audit-log entry, no error, and no MULTIPART_STRICT_ERROR-style flag variable, because none exists.

Proof of Concept

Start a Coraza-wrapped HTTP server with a trivial ARGS rule:

SecRuleEngine On
SecRule ARGS "@contains ATTACK_HERE_XYZ" "id:9001,phase:2,deny,status:403,msg:'Attack detected'"

Baseline sanity checks pass:

$ curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8090/?evil=ATTACK_HERE_XYZ'
403

Now pad the URI with 9999 filler parameters and one malicious parameter placed at a random offset. Running 50 such trials against a real HTTP listener with real curl:

=== 10000 args (attacker adds 9999 filler parameters) ===
total_args=10000 trials=50 BLOCKED=3 BYPASS=47 (94.0%)

47 of 50 attack requests were served HTTP 200 despite the payload being present in the URI. The defending rule never fired because Coraza discarded the argument before phase:2 evaluation.

Comparison with ModSecurity v3

The engine-level bug is present in ModSecurity v3 as well — src/transaction.cc:282-291 has the equivalent silent-drop:

bool Transaction::addArgument(...) {
    if (m_rules->m_argumentsLimit.m_set
            && m_variableArgs.size() >= m_rules->m_argumentsLimit.m_value) {
        ms_dbg(4, "Skipping request argument, over limit (...)")
        return false;                    // return value is ignored at the GET callsite
    }
    ...
}

ModSecurity is in fact more deterministic than Coraza — its query-string parser (extractArguments, transaction.cc:254) splits with an ordered ssplit, so it is the tail of the query that silently drops. An attacker places the payload first and pads the tail; no retry loop needed.

However, ModSecurity's default configuration papers over the engine bug. modsecurity.conf-recommended ships:

SecArgumentsLimit 1000

# If SecArgumentsLimit has been set, you probably want to reject any
# request body that has only been partly parsed. The value used in this
# rule should match what was used with SecArgumentsLimit
SecRule &ARGS "@ge 1000" \
    "id:'200007',phase:2,t:none,log,deny,status:400,msg:'Failed to fully parse request body due to large argument count',severity:2"

Because addArgument caps the single m_variableArgs collection at exactly the limit, &ARGS == limit iff the limit was hit — rule 200007 converts silent-drop into explicit HTTP 400. ModSecurity also has a complementary REQBODY_ERROR path: its JSON processor cancels parsing on addArgument failure, and rule 200002 denies on REQBODY_ERROR (verified by test/test-cases/regression/secargumentslimit.json, test 2/2).

Coraza's coraza.conf-recommended ships no equivalent rule. That is what makes the bug exploitable out-of-the-box in Coraza and not in ModSecurity.

Silent-drop at engine Compensating default rule Exploitable out-of-the-box
ModSecurity v3 yes yes (id:200007, &ARGS @ge 1000) no — denies at limit
Coraza v3 yes no yes

Mitigation

Recommended fixes, in the order they should be applied. Config-layer (#1) closes the default-install exposure quickly; engine-layer (#2, #3) is the durable fix.

1. Ship compensating rules in coraza.conf-recommended (config-layer, immediate)

Port the ModSecurity guard, but keyed per-collection — Coraza caps ARGS_GET, ARGS_POST, and ARGS_PATH independently, unlike ModSecurity's unified m_variableArgs. A single &ARGS @ge 1000 check on the concatenated collection would false-positive at e.g. GET=500 + POST=500 (no drops occurred but aggregate == 1000):

SecRule &ARGS_GET  "@ge 1000" \
    "id:200007,phase:2,t:none,log,deny,status:400,msg:'ARGS_GET over SecArgumentsLimit; request partially parsed'"
SecRule &ARGS_POST "@ge 1000" \
    "id:200008,phase:2,t:none,log,deny,status:400,msg:'ARGS_POST over SecArgumentsLimit; request partially parsed'"
SecRule &ARGS_PATH "@ge 1000" \
    "id:200009,phase:2,t:none,log,deny,status:400,msg:'ARGS_PATH over SecArgumentsLimit; request partially parsed'"

Both &VAR (variable count, internal/seclang/rule_parser.go:40) and @ge (internal/operators/testdata/ge.json) are supported. Thresholds must track SecArgumentsLimit if the operator overrides it.

2. Expose a transaction-visible flag (engine-layer, durable)

Introduce an ARGUMENTS_LIMIT_REACHED collection variable, analogous to MULTIPART_STRICT_ERROR and URLENCODED_ERROR, set to 1 by AddGetRequestArgument / AddPostRequestArgument / AddPathRequestArgument whenever they drop. Replace the config rules above with a single engine-backed check:

SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" \
    "id:200006,phase:1,t:none,log,deny,status:413,msg:'Argument limit reached; request rejected'"

This protects operators with hand-rolled configurations, not just those who use the recommended file.

3. Close the POST urlencoded body-processor gap

`internal/bo

References