json.h is a single-header JSON parser and writer for C and C++. It gets copied into projects rather than installed, so there are no releases, no version numbers, and nothing to update: whatever copy a project vendored is the copy it runs.
The bug needs two things from the application: it has to parse with
json_parse_flags_allow_inf_and_nan (which json_parse_flags_allow_json5 turns on for you),
and it has to serialize the result afterwards. A service that reads JSON5 and writes it back
out does both.
The two halves disagree
Writing is two passes. The first adds up how many bytes the output will need and allocates that; the second copies the document in. They have to agree. Here they don’t:
const char *in = "[InfinityNaN,\"ATTACKER_CONTROLLED_BYTES\"]";
struct json_value_s *v = json_parse_ex(in, strlen(in), json_parse_flags_allow_json5, 0, 0, 0);
size_t size = 0;
char *out = json_write_minified(v, &size);
printf("allocated=%zu written=%zu\n", size, strlen(out) + 1);
allocated=31 written=42
Eleven bytes past the end of the allocation, and what lands there is the tail of the document
being written, so some of it is input. Here the bytes past the end were ED_BYTES"] and a NUL.
Three things have to line up for that.
The parser takes InfinityNaN as a single number. In json_get_number_size, the NaN
match runs after the Infinity match instead of as an alternative to it, so both are consumed
and the offset advances past all eleven characters. Whatever follows is then checked as a
terminator, and ,, ], } and whitespace are all fine, so [InfinityNaN] parses. Bare
InfinityNaN as the whole document became valid too in PR #130.
The measuring pass returns 0 for it. json_write_get_number_size compares the number
against the literal "Infinity" with a loop that steps one past the literal’s NUL and reads
whatever byte comes next in .rodata:
if ('\0' == *inf) { /* after the loop consumed the NUL */
When that byte isn’t NUL the match fails, and the branch then returns 0 without ever counting
the number’s own length. So an InfinityNaN contributes nothing to the size:
$ gcc -O1 -I. show_size_is_zero.c -o s && ./s
rc=0 computed size for "InfinityNaN" = 0 (actual chars to write = 11)
The writing pass has no such early return. json_write_number fails the same comparison,
falls through to the generic copy loop, and writes all eleven bytes into the buffer that was
sized without them.
Why the sanitiser hides it
The thing that decides whether this fires is the byte sitting after "Infinity" in the
binary’s string data. In the gcc 15.2 -O2 build I tested, that byte is '1', the start of
"1.7976931348623158e308" packed in right behind it, so the match fails and the buffer is
under-allocated.
Under AddressSanitizer it doesn’t happen at all. ASan puts a zero-filled redzone after each
global, so the byte past the literal is \0, the comparison succeeds, both passes agree on 22
bytes, and there is no heap overflow. ASan reports only the one-byte read of the global
(CWE-125) and nothing else. Running with -fsanitize-recover=address halt_on_error=0 to let
it continue gives two READ reports and no WRITE.
To see the write under ASan you have to put the production layout back by hand:
sed 's/const char \*inf = "Infinity";/const char *inf = "Infinity\\0Z";/' json.h > p/json.h
gcc -g -O1 -fsanitize=address -Ip poc_write_overflow.c -o w && ASAN_OPTIONS=detect_leaks=0 ./w
# heap-buffer-overflow WRITE in json_write_number (json.h:3031)
That is worth sitting with. The usual tool for this class of bug is the reason the bug is invisible, and a clean ASan run would have been easy to read as “no overflow here”. The one-byte read it does report is the thread to pull.
Crashing it
Repeat the token enough times and the overflow walks off the end of the mapping:
/* poc_segfault.c, abridged: build "[InfinityNaN,InfinityNaN,...]" with N tokens */
struct json_value_s *v = json_parse_ex(in, p, json_parse_flags_allow_inf_and_nan, 0, 0, 0);
free(in); /* drop the big input mapping before writing */
size_t osz = 0;
void *o = json_write_minified(v, &osz);
$ gcc -O2 -I. poc_segfault.c -o seg && ./seg # N = 300000
serializing N=300000 ...
Segmentation fault (core dumped)
The faulting store moves around: at -O0 gdb stopped in json_write_minified_array writing a
comma, not in json_write_number. Any store after the end can be the one that hits unmapped
memory first.
Overflow length is 11 bytes per token (12 with a leading -), and the number of tokens comes
from the input, so the total is attacker-controlled. The bytes written past the end are
document content. I didn’t attempt anything beyond denial of service, and I’m not claiming
anything beyond it.
How far back
I built each revision to check rather than guessing:
| Form | First affected | Notes |
|---|---|---|
[InfinityNaN] |
22dead4 (2020-01-02) |
the single-header refactor; every json.h commit from there to ce09669 overflows |
bare InfinityNaN |
8d14da9 (2026-09-28) |
rejected before that |
Older split json.c/json.h trees have a different API and weren’t tested. Infinity/NaN
support goes back to 2017, so the parser side may be older than 2020. This isn’t an incomplete
fix of the earlier inf/nan issues (#125, #126) — it predates them.
Before reporting I checked it was actually new: json.h issues and PRs open and closed, the
repo’s advisories, the GitHub advisory database, and NVD for sheredom. The five 2022 CVEs
there are all parser bugs. The only prior writer overflow is #113, a different trigger with the
same shape: size pass and write pass disagreeing.
The fix
Three changes, all small:
- Parser: only try
NaNwhenInfinitydidn’t match, soInfinityNaNis rejected. - Both writer passes: stop the comparison at the literal’s NUL instead of one past it, and require a full-length match. That removes the out-of-bounds read as well.
- Measuring pass: only return 0 on an actual match, so a mismatch falls through and gets counted the same way the writer writes it.
I checked it against hand-built json_number_s values (InfinityNaN, Infinityx, NaNx)
that skip the parser entirely, the way #113’s reproducer did, so the writer is consistent even
when the parser isn’t the one producing the value. Infinity, -Infinity and NaN output is
unchanged, and the upstream suite passes 1206/1206 under ASan and UBSan, same as before the
patch.
Timeline
| Date | |
|---|---|
| 2026-10-08 | ce09669, upstream master at the time |
| 2026-10-10 | Reported as issue #139 |
| 2026-10-15 | Fixed upstream: PR #140, commit 79d85a0 |
| 2026-10-15 | Fix verified here: the crashing PoC is rejected on the merged tree |
The repo has private vulnerability reporting switched off and no SECURITY.md, and the
maintainer has pointed security reporters to public issues before, so this went in as a public
issue. No CVE is assigned. The honest status is: found, reported, fixed, nothing more than
that yet.
Verification was gcc 15.2.0 on Linux x86-64 throughout; clang wasn’t available on that machine. Everything above was tested against local builds only.