Skip to content

flexbuffers: cap Reference::ToString recursion depth on cyclic and nested buffers - #9454

Open
itsgaldoron wants to merge 1 commit into
google:masterfrom
itsgaldoron:flexbuffers-tostring-depth-cap
Open

itsgaldoron wants to merge 1 commit into
google:masterfrom
itsgaldoron:flexbuffers-tostring-depth-cap

Conversation

@itsgaldoron

Copy link
Copy Markdown

Reference::ToString() and AppendToString<T>() recurse into each other once per container level with no depth bound. The vector branches of ToString call AppendToString<Vector/TypedVector/FixedTypedVector> (include/flatbuffers/flexbuffers.h:664), which calls back into child.ToString(...) at :387, and the map branch recurses through vals[i].ToString(...) at :652. cur_indent is already threaded through all of these and increments once per level, but nothing ever checks it.

A 6-byte FlexBuffer is enough to blow the stack — a one-element vector whose single element points at the vector itself:

00000000: 0100 2802 2801                           ..(.(.
[0]=0x01  vector size = 1
[1]=0x00  element[0] offset = 0  -> Indirect resolves to the vector itself
[2]=0x28  element[0] packed type (FBT_VECTOR, 1-byte width)
[3]=0x02  root offset = 2        -> resolves to the same vector
[4]=0x28  root packed type
[5]=0x01  root byte width

VerifyBuffer() accepts this whenever a reuse tracker is supplied: the tracker marks the vector's size byte as verified on the first visit, and the self-reference hits the FLEX_CHECK_VERIFIED short-circuit ("already checked"). That is exactly the path flatc takes (src/flatc.cpp:907), so flatc --flexbuffers --json dies on the input:

$ flatc -o out --flexbuffers --json cyclic.flexbin
Segmentation fault (core dumped)

A direct ToString() call under ASan shows the mutual recursion exhausting the stack — 248 alternating Reference::ToString / AppendToString<Vector> frames (flexbuffers.h:664 ↔ :386) before the guard page:

ERROR: AddressSanitizer: stack-overflow on address ...
    #5 ... in void flexbuffers::AppendToString<flexbuffers::Vector>(...) flexbuffers.h:386
    #6 ... in flexbuffers::Reference::ToString(...) flexbuffers.h:664
    #7 ... in void flexbuffers::AppendToString<flexbuffers::Vector>(...) flexbuffers.h:387
    #8 ... in flexbuffers::Reference::ToString(...) flexbuffers.h:664

A deeply nested acyclic buffer (100k levels, built with the regular Builder) crashes the same way. ToString() is a debug printer and does not require the buffer to have been verified at all, so it has to be safe on its own.

Fix

Cap the recursion at kToStringMaxDepth = 64 and truncate with "..." (see diff: the constant at the top of Reference::ToString, the check just before the map branch, guarded on IsAnyVector() which covers map/vector/typed-vector/fixed-typed-vector — the same set #9078 enumerated). The cap lives in ToString() rather than AppendToString() so the map branch is covered by the same check; cur_indent already tracks the depth, so no new parameter is needed. Depth 64 bounds the recursion at ~128 stack frames, safe on any thread stack.

Scope

This fixes a stack-exhaustion crash (DoS) in the FlexBuffers printer. I have deliberately not claimed more than that: there is no memory corruption and no read/write primitive here, and this patch does not change what the verifier accepts.

The verifier's reuse tracker still admits cyclic buffers — that is a separate, verifier-side defect being worked in #9187 and #9440 (with the verifier depth-accounting family #9442 / #9421 / #9197 / #9097 alongside). None of those bound ToString itself, and conversely this patch does not fix verification: an application that verifies with a reuse tracker will still accept a cyclic buffer, it will just be able to print it safely afterwards. Both layers should land; this PR is the printer layer.

Regression test

FlexBuffersToStringRecursionTest() in tests/flexbuffers_test.cpp:

  • control — a shallow nested buffer renders byte-identical to the current output ([ 1, [ 2 ] ]);
  • the 6-byte cyclic buffer and a 100-deep nested vector must both produce bounded, "..."-truncated output. Both probes run in a forked child process (in-process on Windows), so an uncapped build fails the test by crashing the child with the stack overflow instead of taking down the test runner.

Verification

Dual-tree ctest, same suite in both states, only the new test flips:

tree result
master + test, uncapped flattests FAILED (2 assertions: cyclic probe, deep probe)
master + test + this patch flattests Passed, ALL TESTS PASSED

End-to-end, before: flatc --flexbuffers --json SIGSEGV (exit 139) on the cyclic input. After: clean exit, 259-byte truncated JSON (64 nested [ + "..." + 64 closers). ASan, before: stack-overflow with 248 recursion frames. After: clean exit on the cyclic buffer, on the 200k-deep buffer, and on benign buffers, whose rendering is unchanged. Every pre-existing test passes in both trees.

Category: Secure-by-design memory safety improvements.


Supersedes #9078 — the author has been inactive since 2026-05-06 with no engagement on the PR or the issue. The fix is re-derived at master (f58d576) with the same semantics (kToStringMaxDepth = 64, "..." truncation in Reference::ToString) and adds a regression test; I'll leave a note on that PR.

…sted buffers

Reference::ToString() and AppendToString<T>() call each other once per
container level with no depth bound (flexbuffers.h:664 -> AppendToString,
:387 -> child ToString, :652 -> map value ToString), so a cyclic or deeply
nested FlexBuffer exhausts the stack. A 6-byte buffer whose single-element
vector points at itself passes VerifyBuffer whenever a reuse tracker is
supplied (the flatc --flexbuffers --json path) and then crashes the printer:
248 alternating ToString/AppendToString frames until the stack guard.

Cap the recursion at kToStringMaxDepth = 64 and truncate with "...". The
cap lives in ToString() itself (not AppendToString) so the map branch is
covered by the same check; cur_indent already tracks container depth, so
no new parameter is needed.

Add FlexBuffersToStringRecursionTest(): a benign nested buffer renders
byte-identical (control), and the cyclic and 100-deep probes run in a
forked child so an uncapped build fails the test by crashing the child
instead of the test runner.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant