Skip to content

Stack overflow in diff(), merge_patch(), and flatten() due to unbounded recursion #5393

Description

@nlohmann

Summary

Three value algorithms recurse once per nesting level with no depth limit, so each crashes the process on a deeply nested value: json::diff(), basic_json::merge_patch() and json_pointer::flatten() (reached through basic_json::flatten()).

The text parser is iterative, so json::parse accepts input of any depth. Any of these three called on such a value is a crash, which matters wherever the value came from somewhere untrusted — JSON Patch and JSON Merge Patch are typically applied to exactly that.

unflatten() is not affected: it iterates the object and lets get_and_create walk each pointer, and it handles a 500,000-level pointer without trouble. I checked, so that nobody has to.

Reproduction

Against develop (734fd30), -O2, macOS/arm64 with the default 8 MB stack. All three crash at depth 100,000:

#include <nlohmann/json.hpp>
#include <string>

int main()
{
    const std::size_t depth = 100000;
    const auto deep = [&](const char* leaf)
    {
        return nlohmann::json::parse(std::string(depth, '[') + leaf + std::string(depth, ']'));
    };

    const nlohmann::json a = deep("0");
    const nlohmann::json b = deep("1");

    const auto patch = nlohmann::json::diff(a, b);   // SIGSEGV
    return static_cast<int>(patch.size());
}
call result
json::diff(a, b) SIGSEGV
target.merge_patch(patch) SIGSEGV
j.flatten() SIGSEGV
j.unflatten() fine — iterative

merge_patch is measured with nested objects, since it only descends into objects.

Root cause

  • diff — basic_json::diff() (json.hpp:5089) calls itself per element: json.hpp:5120 (arrays), :5168 (objects)
  • merge_patch — basic_json::merge_patch() (json.hpp:5231) calls itself per member: json.hpp:5247
  • flatten — json_pointer::flatten() (json_pointer.hpp:861) calls itself per element: json_pointer.hpp:879 (arrays), :898 (objects)

Suggested fix

The same shape used for the destructor in #1436 and for copying, serializing and comparing in #5389 / #5285 / #5390: descend a bounded number of levels, then finish the rest on an explicit stack.

flatten looks the most straightforward of the three, since it only accumulates into result and carries a prefix string down. diff returns a value built from each level's result, and merge_patch mutates in place, so both need a little more care about what the explicit stack has to carry.

Related


Written by Claude Code.

Activity

  1. dexhunter commented on Aug 22, 2026

    @dexhunter

    Would you welcome a focused PR that makes only flatten() iterative, with a 100,000-level regression test and a current-head benchmark? I would leave diff() and merge_patch() out of scope so the change stays small and does not overlap the open copy/comparison work. I have not started an implementation.

  2. gregmarr commented on Sep 28, 2026

    @gregmarr
    Contributor

    @nlohmann Even with this fixed in some of the PRs, we still ended up with two limits. Can they be combined?

    /*!
    @brief the number of nesting levels an operation recurses into
    
    Operations that walk a value (serializing, hashing, merging, ...) recurse once
    per nesting level, which is fastest, but a value nested deeply enough would
    exhaust the call stack. So they recurse only this many levels deep and finish
    whatever lies below with an explicit stack. All of them share this limit.
    
    @sa https://github.com/nlohmann/json/issues/5387
    */
    constexpr std::size_t recursion_depth_limit() noexcept
    {
        return 128;
    }
    
    #ifndef JSON_NO_THREAD_LOCAL
        /// the number of levels an operation descends into before it finishes the
        /// value below it without the call stack
        static constexpr std::uint8_t nesting_depth_limit()
        {
            return 128;
        }
    
  3. nlohmann commented on Sep 29, 2026

    @nlohmann
    OwnerAuthor

    @gregmarr Good catch, thanks. The plan was to switch whichever of #5389 and #5390 merged second over to the shared limit, and that step got lost.

    #5637 fixes it: basic_json::nesting_depth_limit() is gone, and the thread-local counter used by the copy constructor and the comparison operators now checks against detail::recursion_depth_limit() as well.

    Only the limit is shared, not the mechanism. Copying and comparing have fixed signatures and can't take a depth argument, so they still count depth in a thread-local byte, while everything else passes the depth explicitly. A static_assert makes sure the shared limit stays below 255 so the byte can't overflow.


    Written by Claude Code.

  4. nlohmann commented on Oct 9, 2026

    @nlohmann
    OwnerAuthor

    Closing this with #5548 left flatten() out: json_pointer::flatten() still recursed once per level, and the 100,000-level repro still crashed on develop. #5792 makes it iterative.


    Written by Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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