In distributed API architectures, input validation is often decoupled from business logic. Gateways, WAFs, and microservices each perform their own parsing of inbound data.
However, subtle inconsistencies between JSON parsers can create dangerous parser differentials where the same payload means different things to different components.
This post shows a reproducible example of such a differential, dubbed Split-Brain JSON, where duplicate-key handling inconsistencies lead to authorization bypasses and logic confusion between an API gateway and a backend service.
Attack Scenario:
The Core Problem: Divergent JSON Semantics
According to the JSON specification (RFC 8259, Section 4), “The names within an object SHOULD be unique.”
Note: That language is advisory not enforced.
Most JSON parsers therefore tolerate duplicate keys and resolve them using one of three common policies.
When two layers disagree e.g., a gateway uses first-key semantics and a backend uses last-key semantics the same byte sequence represents two different logical realities.
Demonstration: A Split-Brain API Architecture
We model a simple two-tier setup:
- Gateway (Port 5000): Validates incoming JSON with a first-key-wins parser.
from flask import Flask, request, jsonify
import json, requests
app = Flask(__name__)
def first_wins_object_pairs(pairs):
out = {}
for k, v in pairs:
if k not in out:
out[k] = v
return out
@app.route("/submit", methods=["POST"])
def submit():
raw = request.data
try:
doc_first = json.loads(raw, object_pairs_hook=first_wins_object_pairs)
except Exception as e:
return jsonify({"gateway":"reject", "reason": f"parse error: {e}"}), 400
role_seen = doc_first.get("role")
if role_seen != "user":
return jsonify({"gateway":"reject", "reason": f"invalid role (saw: {role_seen!r})"}), 403
resp = requests.post("http://127.0.0.1:5001/submit", data=raw, headers={"Content-Type": "application/json"})
return jsonify({"gateway":"accept","validation_view":{"role": role_seen},"backend_response": resp.json()})
if __name__ == "__main__":
app.run(port=5000, debug=False)
- Backend (Port 5001): Consumes the same raw body using the default Python
jsonparser (last-key-wins).
from flask import Flask, request, jsonify
import json
app = Flask(__name__)
@app.route("/submit", methods=["POST"])
def submit():
raw = request.data
try:
doc = json.loads(raw)
except Exception as e:
return jsonify({"backend":"reject", "reason": f"parse error: {e}"}), 400
role = doc.get("role", "user")
decision = "GRANT_ADMIN" if role == "admin" else "GRANT_USER"
return jsonify({"backend":"processed","role_interpreted": role,"decision": decision,"doc": doc})
if __name__ == "__main__":
app.run(port=5001, debug=False)
The gateway forwards the raw body to the backend, as many API gateways do for performance or signature verification reasons.
The Payload
{
"user": "bob",
"role": "user",
"role": "admin"
}
Expected Behavior
- The gateway validates that
"role" == "user". - It approves the request and forwards the raw JSON body to the backend.
- The backend parses the same JSON and overwrites the
"role"field with the last occurrence ("admin"). - The backend grants admin privileges.
Result
The same request simultaneously:
- Passes validation at the gateway.
- Triggers privilege escalation at the backend.
Proof of Concept
Gateway running on port 5000 (First-key-wins parser) and Backend on 5001 (Last key wins parser)
send payload using curl
curl -s -X POST http://127.0.0.1:5000/submit \
-H 'Content-Type: application/json' \
-d '{"user":"bob","role":"user","role":"admin"}' | jq .
Normal Output:
Payload Embedded output:
Variants and Amplifications
1. Nested Duplicate Keys
{"attrs": {"role": "user", "role": "admin"}}
Nested duplicates bypass validators that only check the top-level structure.
2. Escaped Keys (Unicode Confusion)
{"role": "user", "ro\u006ce": "admin"}
Visually distinct, logically identical after decoding bypasses regex-based filters.
Why This Matters
- Cloud API Gateways: AWS API Gateway, Kong, and Apigee all have differing parser behaviors.A misalignment between validation policies and backend logic can result in bypasses that survive every WAF layer.
- Web Application Firewalls: ModSecurity historically used
json-c, which preserves first occurrence; backends in Node or Python often take the last occurrence. - Auth Proxies: OAuth or RBAC enforcement at gateways may validate role, scopes, or permissions before forwarding, unaware that the backend interprets later duplicates differently.
Defensive Design Patterns
- Fail on Duplicate Keys
Modify your parser or request validator to reject objects containing duplicates outright.
def reject_dupes(pairs):
seen = set()
for k, _ in pairs:
if k in seen:
raise ValueError("duplicate key")
seen.add(k)
return dict(pairs)
2. Parse Once, Validate Once
The component that validates the request should also generate the canonical JSON body to be consumed by all downstream services.
3. Use Canonical Serialization for Signing and Forwarding
Always re-encode JSON (json.dumps() with strict separators) before signing, caching, or forwarding.
Closing Thought
Modern systems rely on consistent data interpretation more than perfect input filtering.
The next generation of security design must ensure semantic alignment not just sanitization.
The next time you validate a request, ask yourself this 😉.