JSONH-01 ·

Heap overflow in json.h when it writes back what it just read

The parser accepts InfinityNaN as one number. On the way back out, the pass that measures the buffer counts it as 0 bytes and the pass that fills it writes 11.

Target
sheredom/json.h
Class
CWE-787, CWE-125
Severity
High
Status
Fixed upstream
CVE
none assigned
Report
sheredom/json.h/issues/139
Fix
sheredom/json.h#140 (79d85a0)

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:

  1. Parser: only try NaN when Infinity didn’t match, so InfinityNaN is rejected.
  2. 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.
  3. 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.