Vulnerability Report — klib kson JSON parser: heap out-of-bounds read/write in kson_parse_core()
Responsible-disclosure report. Vulnerabilities discovered by SEFF (Semantic-Enriched
Fuzzing Framework — AFL++ + AddressSanitizer, autonomous harness generation + triage) and
manually verified. Shared privately for a fix before any public discussion.
1. Summary
|
|
| Project |
klib — a generic C library (attractivechaos/klib) |
| Component |
kson — the bundled single-file JSON parser (kson.c / kson.h) |
| Vulnerable commit |
97a0fcb790b43b9e5da8994f4671021fec036f19 ("added block arena") |
| Vulnerable function |
kson_parse_core() — reached from the public API kson_parse() (kson.c:121) |
| Bug class |
CWE-125 Out-of-Bounds Read (3 sites) + CWE-787 Out-of-Bounds Write (site 1, latent) |
| Detector |
AddressSanitizer — heap-buffer-overflow (READ of size 8 / size 1) |
| Attack surface |
Any application that passes attacker-controlled text to kson_parse() |
| Impact |
Denial of Service (crash) guaranteed; potential heap-metadata corruption (site 1 write) and limited information disclosure |
| Severity |
Medium — CVSS 3.1 6.5 (site 1); Sites 2 & 3 Medium 5.3. Site 1 rises to High if the latent OOB write is reachable in a hardened allocator. |
| Reporter |
Anh, Nguyen Le Quoc aka h4niz |
kson_parse() copies the caller's text into a heap buffer and parses it in place,
relying on NUL-termination for bounds. Three distinct code paths in kson_parse_core()
violate the buffer bounds:
| # |
Site |
Trigger (hex / printable) |
Read size |
Primitive |
| 1 |
kson.c:80 |
5b3a30 → [:0 |
8 |
OOB read of a[-1].v.str + latent OOB write to a[-1].key |
| 2 |
kson.c:40 |
27 → ' |
1 |
OOB read (*p) — cursor advanced past NUL |
| 3 |
kson.c:90 |
5c → \ |
1 |
OOB read (*q) — \-escape jumps past NUL |
All three are reachable from a single call to kson_parse() on untrusted input, and each
reproduces deterministically on inputs of 1–3 bytes.
2. CVSS 3.1
Site 1 (kson.c:80) — headline score
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L → Base 6.5 (Medium)
- AV:N — JSON is very commonly parsed straight from network/RPC/file inputs; the
library exposes no trust boundary of its own.
- C:L — the OOB read loads an 8-byte pointer from before the heap allocation and stores
it as a node key; a caller that later prints/serializes keys can leak adjacent heap data.
- A:L — reliable crash (DoS) under ASan/hardened builds.
- Note (write amplifier): the same statement also performs an OOB write to
a[-1].key. It is masked here only because ASan aborts on the earlier read. With the read
fixed (or on an unsanitized hardened allocator) this becomes an 8-byte heap write 8 bytes
before a live allocation → I:H/A:H, CVSS ≈ 8.1 (High). Treat site 1 as High for
triage.
Sites 2 & 3 (kson.c:40, kson.c:90) — read-only, size 1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L → Base 5.3 (Medium) — single-byte OOB
read, demonstrated impact is a crash (DoS).
3. Affected code & root cause
The parser mixes two kinds of value on one stack[]: node indices (≥ 0) and
container/key type markers (-1 bracket, -2 brace, -3 key). Several sites treat a
marker as if it were a node index, or advance the read cursor without a length bound.
Site 1 — invalid negative index used as array subscript (kson.c:74–80)
} else { // a bare value or the value of key:value
int c = *p;
// get the node to modify
if (top >= 2 && stack[top-1] == -3) { // we have a key:value pair here
--top;
u = &a[stack[top-1]]; // kson.c:79 <-- stack[top-1] may be a MARKER (-1/-2), not an index
u->key = u->v.str; // kson.c:80 <-- OOB READ of a[-1].v.str (+ OOB WRITE a[-1].key)
} else {
__push_back(n_a);
__new_node(&u);
}
For the input [:0:
| Step |
Token |
stack[] after |
Note |
| 1 |
[ |
[0, -1] |
pushes node index 0, then bracket marker -1 |
| 2 |
: |
[0, -1, -3] |
pushes key marker -3 |
| 3 |
0 |
— |
stack[top-1] == -3 → --top → stack[top-1] == -1 → u = &a[-1] |
a is the heap node array (kson_node_t, 24 bytes each). &a[-1] points 8 bytes before
the allocation start (see shadow map in the evidence). u->key = u->v.str then reads
a[-1].v.str (union at offset +16 → the address 8 bytes before the region) and writes
a[-1].key (offset +8). The stack[] never guarantees that stack[top-1] (after popping
the -3) is a non-negative node index.
Sites 2 & 3 — cursor advanced past the NUL terminator (kson.c:40, 86–95)
for (p = json; *p; ++p) { // kson.c:40 <-- Site 2 dereferences *p after over-advance
...
// parse string / bare value
if (c == '\'' || c == '"') {
for (q = ++p; *q && *q != c; ++q) // kson.c:87
if (*q == '\\') ++q; // kson.c:88 escape: unconditional ++q
} else {
for (q = p; *q && *q != ']' && *q != '}' && *q != ',' && *q != ':' && *q != '\n'; ++q) // kson.c:90 <-- Site 3
if (*q == '\\') ++q; // kson.c:91 same unbounded skip
}
...
p = c == '\'' || c == '"'? q : q - 1; // kson.c:95 p may now point at/after the buffer end
}
- Site 3 (
\): the bare-value scanner sees *q == '\\', does ++q (now at the NUL),
and the for step ++q moves past the NUL; the loop condition then reads *q one
byte beyond the allocation.
- Site 2 (
'): an unterminated quote makes q = ++p land on the NUL, the inner loop
exits immediately, p = q, and the outer for (...; *p; ++p)'s ++p steps past the NUL;
the next *p at kson.c:40 reads out of bounds.
The unifying defect: every scan uses the NUL byte as the only end sentinel, but the escape
handling and quote handling can each skip that sentinel, so the cursor escapes the buffer.
4. Proof of Concept
Three minimal PoC files (klib.zip):
printf '[:0' > poc_site1_bracket.bin # 5b 3a 30 -> kson.c:80 (READ size 8 + latent WRITE)
printf "'" > poc_site2_squote.bin # 27 -> kson.c:40 (READ size 1)
printf '\\' > poc_site3_backslash.bin # 5c -> kson.c:90 (READ size 1)
5. Harness — build & run
5.1 The harness (seff_harness.c)
A 12-line driver that reproduces exactly what a real caller does — read bytes, NUL-terminate,
hand them to the public kson_parse():
/* seff_harness.c — reproduces a real kson_parse() caller */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "kson.h"
int main(int argc, char **argv) {
FILE *f = (argc > 1) ? fopen(argv[1], "rb") : stdin;
if (!f) return 0;
static char buf[1 << 20];
size_t n = fread(buf, 1, sizeof buf, f); // read the whole input file
if (argc > 1) fclose(f);
char *in = (char *)malloc(n + 1); // EXACT-size heap buffer (n + 1 for the NUL)
memcpy(in, buf, n); in[n] = 0; // NUL-terminate, mirroring typical usage
kson_t *k = kson_parse(in); // <-- the only API call under test
if (k) kson_destroy(k);
free(in);
return 0;
}
Why this harness is faithful and why it detects the bug:
kson_parse() is the documented public entry point; feeding it file contents is the exact
pattern used by kson.c's own main() (KSON_MAIN).
- The buffer is allocated with
malloc(n + 1) — the tightest legal allocation. This is
what makes ASan's redzone sit immediately after the JSON text, so any 1-byte over-read
(sites 2/3) is caught. A large static buffer would hide these bugs (the classic reason
such over-reads go unnoticed in the field: they read into unrelated valid heap/globals).
- No fuzzer hooks are needed — a normal
argv[1] file path drives one parse per run.
5.2 Build (AddressSanitizer + UBSan)
# LLVM/clang (afl-clang-fast used originally; plain clang works identically for repro)
clang -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all -g \
-I /path/to/klib \
/path/to/klib/seff_harness.c /path/to/klib/kson.c \
-o harness_asan
5.3 Run / trigger
ASAN_OPTIONS=detect_leaks=0:symbolize=1 ./harness_asan poc_site1_bracket.bin
# ==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 8 ...
# #0 kson_parse_core kson.c:80 #1 kson_parse kson.c:126 #2 main seff_harness.c:14
Trigger conditions (per site):
- Site 1: any input where a container-open marker (
[ or {) sits directly beneath a
key marker on the stack when a value token arrives — minimal form [:0 (also {:0, etc.).
- Site 2: a value/string whose closing quote is missing so the scan cursor reaches the
final NUL and the outer loop steps past it — minimal form a lone ' (or ").
- Site 3: a
\ escape as (or near) the last byte, so the unconditional ++q skips the
NUL — minimal form a lone \.
6. Evidence
klib_asan.zip
Site 1 — [:0 (READ size 8, located 8 bytes before the node array)
==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7119acde0018 ...
READ of size 8 at 0x7119acde0018 thread T0
#0 kson_parse_core /tmp/seff_targets/klib/kson.c:80:19
#1 kson_parse /tmp/seff_targets/klib/kson.c:126:15
#2 main /tmp/seff_targets/klib/seff_harness.c:14:17
0x7119acde0018 is located 8 bytes before 96-byte region [0x7119acde0020,0x7119acde0080)
allocated by thread T0 here:
#0 realloc
#1 kson_parse_core /tmp/seff_targets/klib/kson.c:48:5 <-- the node array `a`
SUMMARY: AddressSanitizer: heap-buffer-overflow kson.c:80:19 in kson_parse_core
Shadow: =>0x...0000: fa fa fa[fa]00 00 ... (read lands in the heap LEFT redzone `fa`)
The [fa]-marked shadow byte confirms the access is in the left redzone (i.e. a[-1]),
and the 96-byte region is the 4-slot node array (4 × sizeof(kson_node_t)=24 = 96).
Site 2 — ' (READ size 1 at kson.c:40)
READ of size 1 at 0x6dd06c7e0012 thread T0
#0 kson_parse_core kson.c:40:17
#1 kson_parse kson.c:126:15
#2 main seff_harness.c:14:17
allocated by thread T0 here:
#1 main seff_harness.c:12:24 <-- the malloc(n+1) input buffer
SUMMARY: heap-buffer-overflow kson.c:40:17 in kson_parse_core
Site 3 — \ (READ size 1 at kson.c:90)
READ of size 1 at 0x75fe9d9e0012 thread T0
#0 kson_parse_core kson.c:90:17
#1 kson_parse kson.c:126:15
#2 main seff_harness.c:14:17
SUMMARY: heap-buffer-overflow kson.c:90:17 in kson_parse_core
7. Suggested fix
The clean fix is to stop trusting NUL as the sole bound and to validate stack contents
before using them as indices.
1. Track an explicit end pointer and bound every scan (fixes sites 2 & 3):
const char *end = json + strlen(json); // or take an explicit length parameter
...
if (c == '\'' || c == '"') {
for (q = ++p; q < end && *q != c; ++q)
if (*q == '\\' && q + 1 < end) ++q; // never skip past `end`
} else {
for (q = p; q < end && *q != ']' && *q != '}' && *q != ',' && *q != ':' && *q != '\n'; ++q)
if (*q == '\\' && q + 1 < end) ++q;
}
and guard the outer loop / re-entry: p = (c=='\'' || c=='"') ? q : q - 1; must satisfy
p < end before the next ++p/*p.
2. Validate the node index before dereferencing (fixes site 1 read and the latent write):
if (top >= 2 && stack[top-1] == -3) {
--top;
if (stack[top-1] < 0) { // marker, not a node index
*error = KSON_ERR_NO_KEY; // or a new KSON_ERR_INVALID
break;
}
u = &a[stack[top-1]];
u->key = u->v.str;
}
3. Preferred API hardening: add a length-taking entry point
kson_parse_n(const char *json, size_t len) so callers with non-NUL-terminated buffers are
safe by construction, and treat stack[] markers vs. indices with a tagged type instead of
sign overloading.
8. Disclosure notes
kson is a compact, widely-copied single-file JSON parser; these are memory-safety defects
reachable from any untrusted JSON input, on inputs as small as one byte.
- Verified impact: reliable crash (DoS). Site 1 additionally exhibits a latent OOB write
(a[-1].key = …) that ASan masks behind the earlier read — this warrants High-severity
handling and a check on whether the written pointer is later free()d by kson_destroy().
- Discovered autonomously by SEFF (AFL++ + ASan), then minimized and root-caused manually.
ASan logs, PoCs, and the exact build command are included; happy to provide additional
reproducers or coordinate a fix timeline.
Vulnerability Report — klib
ksonJSON parser: heap out-of-bounds read/write inkson_parse_core()1. Summary
kson— the bundled single-file JSON parser (kson.c/kson.h)97a0fcb790b43b9e5da8994f4671021fec036f19("added block arena")kson_parse_core()— reached from the public APIkson_parse()(kson.c:121)heap-buffer-overflow(READ of size 8 / size 1)kson_parse()kson_parse()copies the caller's text into a heap buffer and parses it in place,relying on NUL-termination for bounds. Three distinct code paths in
kson_parse_core()violate the buffer bounds:
kson.c:805b3a30→[:0a[-1].v.str+ latent OOB write toa[-1].keykson.c:4027→'*p) — cursor advanced past NULkson.c:905c→\*q) —\-escape jumps past NULAll three are reachable from a single call to
kson_parse()on untrusted input, and eachreproduces deterministically on inputs of 1–3 bytes.
2. CVSS 3.1
Site 1 (
kson.c:80) — headline scoreCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L→ Base 6.5 (Medium)library exposes no trust boundary of its own.
it as a node
key; a caller that later prints/serializes keys can leak adjacent heap data.a[-1].key. It is masked here only because ASan aborts on the earlier read. With the readfixed (or on an unsanitized hardened allocator) this becomes an 8-byte heap write 8 bytes
before a live allocation →
I:H/A:H, CVSS ≈ 8.1 (High). Treat site 1 as High fortriage.
Sites 2 & 3 (
kson.c:40,kson.c:90) — read-only, size 1CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L→ Base 5.3 (Medium) — single-byte OOBread, demonstrated impact is a crash (DoS).
3. Affected code & root cause
The parser mixes two kinds of value on one
stack[]: node indices (≥ 0) andcontainer/key type markers (
-1bracket,-2brace,-3key). Several sites treat amarker as if it were a node index, or advance the read cursor without a length bound.
Site 1 — invalid negative index used as array subscript (
kson.c:74–80)For the input
[:0:stack[]after[[0, -1]0, then bracket marker-1:[0, -1, -3]-30stack[top-1] == -3→--top→stack[top-1] == -1→u = &a[-1]ais the heap node array (kson_node_t, 24 bytes each).&a[-1]points 8 bytes beforethe allocation start (see shadow map in the evidence).
u->key = u->v.strthen readsa[-1].v.str(union at offset +16 → the address 8 bytes before the region) and writesa[-1].key(offset +8). Thestack[]never guarantees thatstack[top-1](after poppingthe
-3) is a non-negative node index.Sites 2 & 3 — cursor advanced past the NUL terminator (
kson.c:40, 86–95)\): the bare-value scanner sees*q == '\\', does++q(now at the NUL),and the
forstep++qmoves past the NUL; the loop condition then reads*qonebyte beyond the allocation.
'): an unterminated quote makesq = ++pland on the NUL, the inner loopexits immediately,
p = q, and the outerfor (...; *p; ++p)'s++psteps past the NUL;the next
*patkson.c:40reads out of bounds.The unifying defect: every scan uses the NUL byte as the only end sentinel, but the escape
handling and quote handling can each skip that sentinel, so the cursor escapes the buffer.
4. Proof of Concept
Three minimal PoC files (klib.zip):
poc/klib_line80_bracket.bin5b 3a 30(3 B)poc/klib_line40_squote.bin27(1 B)poc/klib_line90_backslash.bin5c(1 B)5. Harness — build & run
5.1 The harness (
seff_harness.c)A 12-line driver that reproduces exactly what a real caller does — read bytes, NUL-terminate,
hand them to the public
kson_parse():Why this harness is faithful and why it detects the bug:
kson_parse()is the documented public entry point; feeding it file contents is the exactpattern used by
kson.c's ownmain()(KSON_MAIN).malloc(n + 1)— the tightest legal allocation. This iswhat makes ASan's redzone sit immediately after the JSON text, so any 1-byte over-read
(sites 2/3) is caught. A large static buffer would hide these bugs (the classic reason
such over-reads go unnoticed in the field: they read into unrelated valid heap/globals).
argv[1]file path drives one parse per run.5.2 Build (AddressSanitizer + UBSan)
# LLVM/clang (afl-clang-fast used originally; plain clang works identically for repro) clang -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all -g \ -I /path/to/klib \ /path/to/klib/seff_harness.c /path/to/klib/kson.c \ -o harness_asan5.3 Run / trigger
Trigger conditions (per site):
[or{) sits directly beneath akey marker on the stack when a value token arrives — minimal form
[:0(also{:0, etc.).final NUL and the outer loop steps past it — minimal form a lone
'(or").\escape as (or near) the last byte, so the unconditional++qskips theNUL — minimal form a lone
\.6. Evidence
klib_asan.zip
Site 1 —
[:0(READ size 8, located 8 bytes before the node array)The
[fa]-marked shadow byte confirms the access is in the left redzone (i.e.a[-1]),and the 96-byte region is the 4-slot node array (
4 × sizeof(kson_node_t)=24 = 96).Site 2 —
'(READ size 1 atkson.c:40)Site 3 —
\(READ size 1 atkson.c:90)7. Suggested fix
The clean fix is to stop trusting NUL as the sole bound and to validate stack contents
before using them as indices.
1. Track an explicit end pointer and bound every scan (fixes sites 2 & 3):
and guard the outer loop / re-entry:
p = (c=='\'' || c=='"') ? q : q - 1;must satisfyp < endbefore the next++p/*p.2. Validate the node index before dereferencing (fixes site 1 read and the latent write):
3. Preferred API hardening: add a length-taking entry point
kson_parse_n(const char *json, size_t len)so callers with non-NUL-terminated buffers aresafe by construction, and treat
stack[]markers vs. indices with a tagged type instead ofsign overloading.
8. Disclosure notes
ksonis a compact, widely-copied single-file JSON parser; these are memory-safety defectsreachable from any untrusted JSON input, on inputs as small as one byte.
(
a[-1].key = …) that ASan masks behind the earlier read — this warrants High-severityhandling and a check on whether the written pointer is later
free()d bykson_destroy().ASan logs, PoCs, and the exact build command are included; happy to provide additional
reproducers or coordinate a fix timeline.