Fix stack overflow in binary format parsers (CBOR/MessagePack/UBJSON/BSON)#5293
Fix stack overflow in binary format parsers (CBOR/MessagePack/UBJSON/BSON)#5293Gooh456 wants to merge 9 commits into
Conversation
🔴 Amalgamation check failed! 🔴The source code has not been amalgamated and/or formatted correctly. 📎 A ready-to-apply patch is attached to the failed workflow run as the git apply amalgamation.patchThis does not require installing astyle yourself. @Gooh456 Please read and follow the Contribution Guidelines. |
|
Pushed a fix for the amalgamation check: it's exactly the patch the "Check amalgamation" workflow generated as its artifact (pure astyle formatting in a couple of pre-existing regions unrelated to the actual change), applied as-is. The Windows/Ubuntu CI failures are a more substantive, real finding I want to flag rather than paper over. Root cause, from reading the recursion structure: This is exactly why local verification can miss this: I verified the fix against an optimized MinGW g++ build on Windows, where frames are small enough that even ~100k logical levels didn't overflow — a materially different stack-cost-per-frame than what upstream's Debug-mode Windows CI matrix hits. Good catch by your CI, basically. I don't currently have an MSVC/clang-cl Debug toolchain available to compile-and-verify a corrected |
|
Follow-up on the CI failure flagged above: confirmed it's a real stack overflow ( Pushed |
|
My last fix for the Debug crash was too aggressive - I picked 128 without checking whether it was actually high enough for real data, and it wasn't. It broke the library's own roundtrip fixture (sample.json, 458 levels deep), which is why CI just lit up across basically every job instead of just the Debug ones. Pushed a fix: 600 instead of 128, which covers that fixture with real margin and stays well clear of the 1024 that caused the original overflow. Downloaded the actual fixture this time and measured its real depth instead of guessing (458), so this number's grounded in something concrete rather than a second guess. Sorry for the noise on this one - should've checked what depth real input needs before picking a number the first time around. |
…BSON) The binary format parsers in binary_reader.hpp parse nested arrays/objects using unbounded recursive descent, unlike the text JSON parser which is iterative. A deeply nested (but otherwise tiny) CBOR, MessagePack, UBJSON, or BSON document therefore exhausts the native call stack and crashes the process, rather than failing gracefully. Add a depth_guard RAII helper that tracks container-nesting depth on the binary_reader and rejects input nested deeper than a fixed max_depth (1024) with a clean parse_error (new id 116) instead of recursing further. The guard is placed at each recursive entry point: parse_cbor_internal, parse_msgpack_internal, parse_bson_internal (which also covers nested BSON sub-documents via parse_bson_element_internal), and get_ubjson_value (the actual recursive entry point for UBJSON/BJData, including the "optimized homogeneous array" code path that recurses without going through parse_ubjson_internal). Closes nlohmann#5104. Testing: - Reproduced the crash for real against unpatched code on Windows: a 100k-deep CBOR/MessagePack/UBJSON/BSON payload each terminate the process with STATUS_STACK_OVERFLOW (exit code 0xC00000FD), matching the reported Segmentation fault. - Rebuilt against the patched header: all four formats now throw a clean [json.exception.parse_error.116] instead of crashing; process exits 0. - Added regression tests (unit-cbor.cpp, unit-msgpack.cpp, unit-ubjson.cpp, unit-bson.cpp) reproducing deep nesting for each format and asserting the clean parse_error; ran them against the fix (pass) and confirmed they return the crash on unpatched code first. - Compiled and ran the full unit-cbor/unit-msgpack/unit-ubjson/unit-bson test binaries (g++ 16, C++17) against both the patched and pristine baseline: identical pre-existing failure set (8 test cases that require the external json_test_data corpus, not present in this checkout) in both builds, and the patched build additionally passes the new tests with zero regressions elsewhere (3,549,340 assertions passed on the patched build vs 3,549,332 on baseline - exactly the added assertions). - Confirmed moderate/legitimate nesting (well under the new limit) and a normal round-trip still parse correctly, so the fix does not introduce false positives for realistic input. - Regenerated single_include/nlohmann/json.hpp via tools/amalgamate, and documented the new parse_error.116 in docs/mkdocs/docs/home/exceptions.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
single_include/nlohmann/json.hpp had drifted out of astyle-formatting sync in a few pre-existing regions unrelated to this PR's actual change (json_fwd.hpp block indentation, a couple of wrapped call sites); this is the exact patch the "Check amalgamation" GitHub Action generated and offered as a downloadable artifact. Applying it as-is, no manual edits. Signed-off-by: Kyue <sendocat456@gmail.com>
CI on this branch crashed with a real SIGSEGV (stack overflow) in test-bson_cpp11 under msvc (Debug, x64), msvc-arm64 (Debug), and msvc-vs2026 (Debug, x64) - the depth_guard added to fix nlohmann#5104 correctly caps recursion at max_depth=1024, but reaching depth 1024 itself already overflows a 1 MiB default thread stack in unoptimized/Debug builds, where each nesting level costs several real (non-inlined) call frames instead of one. No MSVC toolchain is available in this environment, so instead of guessing a safe constant, I only lowered it (1024 -> 128, a 4x margin under the depth that crashed) and am relying on CI's real Windows/MSVC runners - which reproduced the original bug - to confirm this closes it. Verified locally with g++ 16/C++17 (not MSVC): recompiled and ran the affected unit-bson/cbor/msgpack/ubjson test binaries after updating the four depth-limit regression tests' expected byte offsets (which shift with max_depth: CBOR/MessagePack 1024->128, UBJSON 1025->129, BSON 7168->896, cross-checked against actual thrown exception messages, not guessed). All four pass; the only failures are the same 9 pre-existing "external json_test_data corpus not present" cases already documented as a known baseline gap in this checkout - no new regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
My previous commit dropped max_depth to 128 to fix the MSVC Debug stack overflow from nlohmann#5104, but that number was picked without checking whether it was actually high enough for real input. It wasn't: the library's own roundtrip fixture (tests/data/json_testsuite/sample.json, used by all four binary format roundtrip tests) legitimately nests 458 levels deep, so 128 started rejecting valid, previously-working input - visible in CI as widespread new failures across nearly every job, not just the Debug builds this was meant to fix. 600 covers that fixture with real margin (about 30% above the 458 it actually needs) while staying well clear of the 1024 that overflowed the stack in the first place (about 40% below it). Verified locally (g++, no MSVC available): downloaded the actual sample.json fixture and measured its real max nesting depth directly (458) rather than guessing, recomputed the four depth-limit regression tests' expected byte offsets for the new limit the same way as before (cross-checked against actual thrown exception text), and reran the full binary-format test files - all four depth-guard tests pass, and the only other failures are the same pre-existing "external test corpus not present in this checkout" gaps as before, nothing newly broken. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
…flow CI (clang-cl-12 x64, Windows Debug) still crashed with a real SIGSEGV in test-ubjson_cpp11 on the nlohmann#5104 regression test even with max_depth=600 in place, while the equivalent CBOR/MessagePack/BSON depth-limit tests passed fine at the same max_depth. The depth_guard itself was applied correctly on every UBJSON recursive call site (verified by reading every recursive path in get_ubjson_array/get_ubjson_object and the optimized $type#count path) - the bug wasn't a missing or bypassed check, it was that reaching the *same* depth of 600 costs UBJSON more real stack than it costs the other formats. Root cause: CBOR/MessagePack fold "read the next value's dispatch" and "the depth_guard" into one function (parse_cbor_internal / parse_msgpack_internal), so their recursive chain is two stack frames per nesting level (that function + get_cbor_array/get_msgpack_array). UBJSON splits this into two functions instead of one: parse_ubjson_internal() (fetch the next byte) calling get_ubjson_value() (the depth_guard + the switch), because get_ubjson_value() also has to be callable directly with an already-known type marker from the optimized `$type#count` container path - a feature the other formats don't have. That leaves parse_ubjson_internal() as a genuine extra frame on top of get_ubjson_value() and get_ubjson_array()/get_ubjson_object(): three frames per nesting level instead of two. Measured with `g++ -O0 -fstack-usage` (proxy for MSVC Debug's similarly unoptimized frames): parse_ubjson_internal/get_ubjson_value/get_ubjson_array together cost ~1488 bytes per nesting level pre-fix vs CBOR's parse_cbor_internal/get_cbor_array at ~1088 bytes/level - about 37% more stack per level for the same max_depth, comfortably enough to explain overflowing the 4 MB stack (tests/CMakeLists.txt already sets /STACK:4000000 for these test binaries, see nlohmann#2955) at depth 600 in MSVC Debug specifically. Fix: mark parse_ubjson_internal() JSON_HEDLEY_ALWAYS_INLINE so it collapses into its caller in every build configuration, including unoptimized Debug builds where the compiler wouldn't otherwise bother inlining a function this trivial. Confirmed with objdump that this eliminates every `call` to parse_ubjson_internal in the compiled object (previously 2 real calls, now 0) - i.e. it's not just a hint, the frame is actually gone - without touching get_ubjson_value's depth_guard/max_depth logic at all, so the 128->600 max_depth tuning from the previous two commits (needed for the 458-level-deep legitimate roundtrip fixture) is untouched. Regenerated single_include/nlohmann/json.hpp for this one function only (re-running the full amalgamate script produced unrelated whitespace churn in this environment, so I hand-applied the equivalent change to keep the diff minimal and matching). Verified locally with g++ 16 (MinGW, Debug, no MSVC available in this environment): rebuilt test-ubjson_cpp11/test-cbor_cpp11/test-msgpack_cpp11/ test-bson_cpp11 against both the multi-header and the amalgamated single_include build, and all non-data-file-dependent test cases pass, including the nlohmann#5104 stack-overflow regression test (691k+ assertions) and the pre-existing legitimate-deep-input roundtrip test. The only failures are the already-documented external-test-corpus-not-downloaded gap (network access unavailable here) and one pre-existing, unrelated vector::reserve failure in a CBOR RFC 7049 test that reproduces identically before and after this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
9a12b01 to
2372e68
Compare
|
Pushed a fix for the On the stack overflow: I want to be clear this wasn't a missing or bypassed depth check. I went through every recursive call site in the UBJSON/BJData path ( The actual problem is that reaching the same depth of 600 costs UBJSON meaningfully more real stack than it costs the other three formats. CBOR and MessagePack fold "fetch+dispatch the next value" and the depth guard into a single function ( I measured this with Fix is just marking No MSVC available in my environment, so I can't reproduce the exact CI crash, but I rebuilt and ran test-ubjson/test-cbor/test-msgpack/test-bson locally (g++ 16, Debug, both the multi-header and the regenerated single_include build) and everything passes, including the #5104 regression test and the legitimate deep-nesting roundtrip test. Only failures are the pre-existing external-test-corpus-not-downloaded gap and one unrelated |
|
The more I think about it - maybe a non-recursive parser would be a better fix. WDYT? |
nlohmann suggested a non-recursive parser would be a better fix than continuing to tune max_depth against compiler/platform-specific stack frame sizes (the UBJSON force-inline fix still wasn't enough on clang-cl Debug). This does that: the container-parsing loops for CBOR, MessagePack, UBJSON/BJData, and BSON no longer recurse on the native call stack for nested arrays/objects. Each format's parse_*_internal() now runs a single iterative loop with an explicit, heap-allocated stack of small per-format "resume this container" frames (cbor_container_frame, msgpack_container_frame, ubjson_container_frame, bson_container_frame); descending into a nested array/object pushes a frame and loops back to parse its first child instead of recursing, and finishing a container pops its frame and resumes the parent. Scalar values (numbers, strings, binaries) are unaffected - they never recursed into container parsing in the first place. This makes native stack depth O(1) regardless of input nesting depth, so it eliminates the whole bug class (nlohmann#5104) rather than tuning a magic number against a specific compiler's frame sizes: CBOR's separate tag-unwrapping recursion is now a plain loop too (it was also uncapped in all but name, since it recursed through the same depth-guarded function). depth_guard and the per-call recursion-depth counter are gone entirely - they'd have nothing left to guard - and max_depth (now 100000, up from 600) is kept purely as a sanity/DoS cap against absurd/malicious inputs, not a stack-safety mechanism; it's checked against the explicit stack's size at container-entry time instead. While rewriting BSON's container handling I noticed nested *arrays* (record type 0x04) never went through the old depth_guard at all - only nested objects (type 0x03) did, via parse_bson_internal. The unified iterative loop applies the depth cap to both uniformly now, closing that gap; a regression test for it is included alongside the updated nlohmann#5104 tests. Testing: - Updated the four existing nlohmann#5104 regression tests (CBOR/MessagePack/ UBJSON/BSON) for the new max_depth=100000 and the resulting byte offsets in the parse_error messages; added a BSON array-nesting variant of the same test for the gap above. - Ran the full existing test suite (g++ 16, Debug, MinGW) - CBOR, MessagePack (cpp11+cpp17), UBJSON, BSON, BJData, and every other suite (94 binaries total) - and diffed assertion-by-assertion against a pristine baseline build of the pre-rewrite tree: every binary matches the baseline's pass/fail counts exactly except the two new BSON assertions, which pass. The one pre-existing failure per binary format suite (missing external json_test_data fuzz corpus - no network access in this environment - plus one unrelated vector::reserve failure in a CBOR RFC 7049 test) reproduces identically in both trees. - Added a throwaway smoke test parsing 500,000 levels of nesting (~830x the old max_depth of 600, far beyond anything the old recursive parser could have survived at any max_depth) for all four formats via a minimal non-DOM-building SAX consumer: all four reject it cleanly via parse_error.116 with no crash, confirming this isn't luck at one specific depth. Separately confirmed 50,000 levels of *legitimate* nesting (under the new cap, well past the old one) parses to completion correctly, and that the BJData "optimized homogeneous container" and ND-array-annotation paths - the trickiest part of the UBJSON rewrite - are exercised by the existing test suite and match baseline exactly. - Regenerated single_include/nlohmann/json.hpp via tools/amalgamate and reformatted with the pinned astyle (3.4.13) matching the CI "Check amalgamation" workflow's exact sequence (amalgamate.py for both json.hpp/json_fwd.hpp configs, then astyle on the amalgamated output and on include/tests/docs sources); diffed clean against a fresh regeneration. No real clang-cl/MSVC available in this environment, so the exact CI toolchain isn't locally reproducible - but the whole point of this rewrite is that stack safety no longer depends on compiler/platform frame sizes at all, so a clean run under a different compiler (optimized or not) is meaningful structural evidence rather than a coincidence of this one toolchain's frame sizes, the way the previous max_depth tuning attempts were. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
|
Agreed, and I've pushed that now. I went back through the last few CI failures with that lens and yeah - every one of them was the same underlying problem wearing a different hat: max_depth was never actually a stack-safety mechanism, it was a proxy for one, and the proxy kept breaking because the real cost (native stack frames per nesting level) depends on the compiler, the optimization level, and incidental things like whether a helper function happens to get inlined. Tuning the number just moves where it breaks. So: the container-parsing loops for CBOR, MessagePack, UBJSON/BJData, and BSON are no longer recursive. Each format's parser now runs one iterative loop with an explicit, heap-allocated stack of small "resume this container" frames - descending into a nested array/object pushes a frame and loops back around to parse its first child instead of calling back into itself, and finishing a container pops the frame and resumes the parent. Scalars (numbers/strings/binary) don't recurse into container parsing at all, so they're untouched. One CBOR-specific thing worth calling out: the tag-unwrapping path ( While I was in there I also noticed BSON's depth guard only ever applied to nested objects (record type 0x03) - nested arrays (0x04) went through a separate path that never checked depth at all. The unified loop applies the cap to both now; added a regression test for that specifically since it wasn't caught by anything upstream. What I verified this against:
Honest caveat: I don't have real clang-cl or MSVC available in this environment, so I can't reproduce the exact CI toolchain locally - that's exactly the failure mode this rewrite is meant to stop mattering, but I haven't watched it pass on the actual Debug clang-cl/MSVC jobs with my own eyes yet. CI on this PR needs a maintainer approval to run (standard for external PRs, it looks like), so I'd treat this as "strong local evidence, not yet independently confirmed on the real matrix" until those jobs actually go green. |
…y_reader Signed-off-by: Kyue <sendocat456@gmail.com>
Signed-off-by: Kyue <sendocat456@gmail.com>
nlohmann
left a comment
There was a problem hiding this comment.
👋 Heads up: I'm on vacation, so this review was produced with the help of an AI assistant on my behalf and I haven't hand-verified every detail yet. Apologies for the impersonal reply — I wanted to get you actionable feedback rather than leave the PR sitting. I'll go over it properly when I'm back.
Thanks a lot for the iterative rewrite and for the very transparent write-ups on each CI round — this is the right direction.
One substantive concern: the DOM API can still overflow on tiny nested input
The parser itself is now genuinely non-recursive (nicely done — a 500k-deep payload is rejected cleanly for all four formats). But the crash appears to just move one frame later, into the return path of the from_* entry points:
return res ? result : basic_json(value_t::discarded);That ternary produces a prvalue, so result is copy-constructed, and basic_json's copy constructor is recursive. A validly-nested document below max_depth parses fine and then overflows the native stack on return.
Measured locally (Linux, 8 MB stack, -O2):
| Input | Result |
|---|---|
from_cbor, 40 000-deep arrays |
OK |
from_cbor, 60 000-deep arrays |
SIGSEGV — recursive basic_json copy ctor, on return |
json::parse, 200 000-deep text |
OK (returns result directly, no copy) |
So a ~60 KB CBOR file still crashes the process through from_cbor. On the Windows test binaries (linked /STACK:4000000) the threshold is roughly half that, and max_depth = 100000 sits comfortably above it — so the new guard never gets a chance to protect the DOM path.
Why current CI stays green: the regression tests here all exceed max_depth, so they throw/discard during parsing and the partial tree unwinds through the (iterative) destructor. The case that's still exposed is exactly the one #5104 is about: a valid, accepted, deeply-nested document that parses successfully and is returned.
Possible fixes:
- Return by move —
return res ? std::move(result) : basic_json(value_t::discarded);infrom_cbor/from_msgpack/from_ubjson/from_bson/from_bjdata. I tested this and it raised the safefrom_cbordepth from ~60k up to the fullmax_depthcap, matchingjson::parse. - And/or choose a
max_depththat stays below the library's safe recursion depth on the smallest supported (4 MB) stack, treating it as a genuine sanity cap rather than 100000.
Caveat worth noting: even with the move, a caller copying / comparing / dump-ing a 100000-deep returned value hits the same recursive copy constructor. That's a pre-existing, library-wide limitation (also reachable via json::parse), so not this PR's job to solve — but it's another reason to lean toward a more conservative cap.
What looks good
- Iterative parser verified: native stack is O(1); 500k-deep input rejected cleanly, no crash, all four formats.
- Correctness spot-checks against this branch's header all passed: indefinite CBOR arrays/maps, empty indefinite containers, mixed definite/indefinite nesting, chained tags in
ignoremode, half-float, UBJSON optimized$type#countarrays/objects, BJData ND-array (JData wrapping) and$Bbinary — plus ~20k random-structure round-trips across all six format variants (0 mismatches) and ~200k random-byte inputs (0 crashes / no non-jsonexceptions). - Closing the BSON nested-array (0x04) depth gap, with a dedicated test, is a real bonus.
Minor
- The clang 20.1.8 CI red is a MinGW
relocation truncated to fitlinker error inunit-regression2— a known large-object-file toolchain issue, unrelated to this diff.
Bottom line: great approach and the parser change is solid, but I'd hold off on merging it as "closes #5104" until the return-path copy overflow is handled — otherwise from_cbor and friends still crash on a small nested file.
Generated by Claude Code
|
Appreciate the thorough pass, and no worries about the AI-assisted disclaimer — the finding's real either way. The move fix is right, and it's an obvious miss on my end — On "the clang 20.1.8 red is unrelated to this diff" — I don't think that's quite right, and wanted to flag it before it gets waved off. Checked Pushed a shot at a CI-only mitigation: |
7026d20 to
7c92b95
Compare
Copying a deeply-nested basic_json on return is exactly as recursive as the stack overflow this PR is meant to fix, since basic_json's copy constructor recurses over the tree. Use std::move instead across all from_cbor/from_msgpack/from_ubjson/from_bjdata/from_bson overloads. Also add a CI-only workaround for the MinGW clang linker failing with "relocation truncated to fit" on test-regression2, which appears to be tipped over by this PR's added instantiated code (-fuse-ld=lld). Signed-off-by: Kyue <164024549+Gooh456@users.noreply.github.com>
7c92b95 to
a47902e
Compare
What
Closes #5104.
The binary format parsers in
binary_reader.hppparse nested arrays/objectswith unbounded recursive descent (unlike the text JSON parser, which is
iterative). A deeply nested but otherwise tiny CBOR/MessagePack/UBJSON/BSON
document therefore exhausts the native call stack and crashes the process
instead of failing gracefully — a DoS vector for anything that parses
untrusted binary input.
This adds a small
depth_guardRAII helper that tracks container-nestingdepth on
binary_readerand rejects input nested deeper than a fixedmax_depth(1024) with a cleanparse_error(new id116) instead ofrecursing further, mirroring the fix suggested in the issue. The guard sits
at each actual recursive entry point:
parse_cbor_internalparse_msgpack_internalparse_bson_internal(this also covers nested BSON sub-documents, sincethose recurse back into
parse_bson_internalviaparse_bson_element_internal)get_ubjson_value— this is the real recursive entry point forUBJSON/BJData, not
parse_ubjson_internal(which is a thin wrapper); the"optimized homogeneous array" code path in
get_ubjson_array/get_ubjson_objectcallsget_ubjson_valuedirectly without goingthrough
parse_ubjson_internal, so guarding only the wrapper would havemissed that path.
single_include/nlohmann/json.hppwas regenerated viatools/amalgamate,and the new
parse_error.116is documented indocs/mkdocs/docs/home/exceptions.md.Testing
develop: a 100k-deepCBOR/MessagePack/UBJSON/BSON payload each terminate the process with
STATUS_STACK_OVERFLOW(Windows exit code0xC00000FD), matching thereported segfault.
[json.exception.parse_error.116]instead of crashing; process exits 0.unit-cbor.cpp,unit-msgpack.cpp,unit-ubjson.cpp, andunit-bson.cpp, one per format, each building adeeply-nested payload and asserting the clean
parse_error(plus theis_discarded()non-throwing-mode path). Verified each test reproducesthe crash against unpatched code before confirming it passes against the
fix.
unit-cbor/unit-msgpack/unit-ubjson/unit-bsontest binaries (g++ 16,-std=c++17) against both the patchedtree and a pristine baseline (
git stash): the patched build shows theexact same pre-existing 8 failing test cases as baseline (all due to the
external
json_test_datafuzz corpus not being present in thischeckout, unrelated to this change) and zero new failures, with exactly
the expected 8 additional passing assertions from the new tests
(3,549,340 passed on patched vs 3,549,332 on baseline).
normal CBOR round-trip (
to_cbor/from_cbor) still parse correctly, sothe fix doesn't introduce false positives on realistic input.
I'm not a maintainer/regular contributor here — happy to adjust the chosen
max_depthvalue, error wording, or guard placement if you'd prefer adifferent approach (e.g. a configurable limit via a new parameter instead
of a fixed constant).