I'm Thiago (@TryZeroDay). I found
this in my first fuzzing campaign against gjson. I'm transparent
about my workflow: I directed the methodology, ran the campaign,
reproduced the bug, and minimised it manually — but I used an LLM
(Claude) to write the fuzzing harnesses and to help me draft this
report. I originally wrote it in Portuguese and translated it to
English.
Summary
I found that gjson.Result.ForEach and gjson.Get resolve a duplicated
object key to different values. ForEach yields every occurrence
in source order; Get returns the first occurrence only. The two APIs
are not internally consistent.
This opens a JSON parser smuggling vector when gjson is mixed with
other JSON parsers — notably the Go standard library, which keeps the
last duplicate instead.
Affected version
I tested against:
github.com/tidwall/gjson v1.19.0
(commit 0fac2c9aa6eb5d5564bfaaaad513ce0d5d2314de)
- Go 1.23 / linux-amd64
Minimal reproducer
package main
import (
"fmt"
"github.com/tidwall/gjson"
)
func main() {
const json = `{"a":1,"a":2}`
gjson.Parse(json).ForEach(func(k, v gjson.Result) bool {
fmt.Printf("ForEach key=%q value=%s\n", k.String(), v.Raw)
return true
})
fmt.Printf("Get(\"a\") value=%s\n", gjson.Get(json, "a").Raw)
}
Output
ForEach key="a" value=1
ForEach key="a" value=2
Get("a") value=1
ForEach exposes both 1 and 2; Get resolves only to 1. The two
APIs cannot both be correct simultaneously for any single
key-resolution policy.
Why I think this matters
RFC 8259 §4 leaves the meaning of duplicate object members undefined
and warns implementations that interoperability is at risk if they
diverge. gjson itself diverges internally, and across the
ecosystem:
| Parser |
Resolution |
gjson.Get |
First occurrence |
gjson.ForEach |
All, in order |
Go encoding/json |
Last |
JavaScript JSON.parse |
Last |
Python json.loads |
Last |
This is the same shape as published vulnerabilities:
- CVE-2017-12635 — Apache CouchDB (admin role injection via
duplicate roles key; CVSS 9.8 critical)
- CVE-2022-23541 — Auth0
jsonwebtoken (JWT verification bypass
via parser disagreement; CVSS 7.6 high)
- CVE-2020-27619 — Python (multiple parser-disagreement issues)
Concrete attack scenario I built to demonstrate it
package main
import (
"encoding/json"
"fmt"
"github.com/tidwall/gjson"
)
// authorize uses gjson on the hot path.
func authorize(payload string) string {
return gjson.Get(payload, "role").String()
}
// auditLog uses encoding/json (already wired into the audit pipeline).
func auditLog(payload string) string {
var m map[string]interface{}
_ = json.Unmarshal([]byte(payload), &m)
if r, ok := m["role"].(string); ok {
return r
}
return ""
}
func main() {
payload := `{"role":"admin","role":"user"}`
fmt.Println("authorize() sees:", authorize(payload)) // admin
fmt.Println("auditLog() sees:", auditLog(payload)) // user
}
For payload {"role":"admin","role":"user"}:
| Function |
Sees |
authorize() |
"admin" (gjson returns the first occurrence) |
auditLog() |
"user" (encoding/json keeps the last) |
The attacker is granted admin access while the audit log records a
user-level action.
How I found it
I ran a libFuzzer + AddressSanitizer campaign with 13 harnesses spread
across 3 Docker containers (~103k execs/s combined), built on top of
the Google OSS-Fuzz tooling. One of the harnesses I wrote checks
ForEach/Get consistency on randomly-generated valid JSON. It
panicked on a 733-byte input after ~750k executions; I minimised the
crashing input manually to the 13-byte case above.
By the time this bug surfaced the campaign had tested roughly 38
million JSON documents.
Suggested resolutions
Any of these would close the issue from my point of view:
- Align
Get with encoding/json — return the last occurrence
of a duplicated key. This brings gjson into alignment with the
wider ecosystem (stdlib encoding/json, browser JSON.parse,
Python json.loads) and removes the cross-library smuggling vector.
- Document the divergence prominently in the README, in a
"Duplicate keys" section, with a clear warning about parser
smuggling when gjson is mixed with other JSON parsers.
- Add a strict mode — for example
gjson.ParseStrict(json) that
returns an error on duplicate keys, plus a gjson.ValidStrict
predicate. This gives security-sensitive callers a way to reject
ambiguous input.
A combination of (1) and (3) would be ideal in my opinion.
Thiago — @TryZeroDay.
Summary
I found that
gjson.Result.ForEachandgjson.Getresolve a duplicatedobject key to different values.
ForEachyields every occurrencein source order;
Getreturns the first occurrence only. The two APIsare not internally consistent.
This opens a JSON parser smuggling vector when
gjsonis mixed withother JSON parsers — notably the Go standard library, which keeps the
last duplicate instead.
Affected version
I tested against:
github.com/tidwall/gjsonv1.19.0(commit
0fac2c9aa6eb5d5564bfaaaad513ce0d5d2314de)Minimal reproducer
Output
ForEachexposes both1and2;Getresolves only to1. The twoAPIs cannot both be correct simultaneously for any single
key-resolution policy.
Why I think this matters
RFC 8259 §4 leaves the meaning of duplicate object members undefined
and warns implementations that interoperability is at risk if they
diverge.
gjsonitself diverges internally, and across theecosystem:
gjson.Getgjson.ForEachencoding/jsonJSON.parsejson.loadsThis is the same shape as published vulnerabilities:
duplicate
roleskey; CVSS 9.8 critical)jsonwebtoken(JWT verification bypassvia parser disagreement; CVSS 7.6 high)
Concrete attack scenario I built to demonstrate it
For payload
{"role":"admin","role":"user"}:authorize()"admin"(gjsonreturns the first occurrence)auditLog()"user"(encoding/jsonkeeps the last)The attacker is granted admin access while the audit log records a
user-level action.
How I found it
I ran a libFuzzer + AddressSanitizer campaign with 13 harnesses spread
across 3 Docker containers (~103k execs/s combined), built on top of
the Google OSS-Fuzz tooling. One of the harnesses I wrote checks
ForEach/Getconsistency on randomly-generated valid JSON. Itpanicked on a 733-byte input after ~750k executions; I minimised the
crashing input manually to the 13-byte case above.
By the time this bug surfaced the campaign had tested roughly 38
million JSON documents.
Suggested resolutions
Any of these would close the issue from my point of view:
Getwithencoding/json— return the last occurrenceof a duplicated key. This brings
gjsoninto alignment with thewider ecosystem (stdlib
encoding/json, browserJSON.parse,Python
json.loads) and removes the cross-library smuggling vector."Duplicate keys" section, with a clear warning about parser
smuggling when
gjsonis mixed with other JSON parsers.gjson.ParseStrict(json)thatreturns an error on duplicate keys, plus a
gjson.ValidStrictpredicate. This gives security-sensitive callers a way to reject
ambiguous input.
A combination of (1) and (3) would be ideal in my opinion.
Thiago — @TryZeroDay.