CNA-BUG-016: The .cnj JSON parser (CNA::Internal::ParseJson) recurses without a nesting limit, so a small crafted document overflows the stack
Evidence basis: source-verified at the pinned commit; executed for this entry (the Evidence section names exactly what was run); tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.
JsonParser recurses once per nesting level with no ceiling; a document of 100,000 nested arrays (200,000 bytes) crashed a probe with SIGSEGV instead of throwing JsonParseException, and with the default 8 MiB stack at -O0 about 13,400 levels (27 KB) are already enough.
- Identifier
CNA-BUG-016- Category
- Bug
- Subsystem
- Content & XNB/CNB/CNJ
- Status
- Open
- Verified against
- CNA
009d40f5(009d40f5dd085c4e674d3479675fac84b12b3e0a) - Severity
- Medium (a triage suggestion, not a project priority)
- Evidence basis
- Reproduced: executed for this entry (the Evidence section names exactly what was run)
- Tests touching this area
- Yes: see Current tests
- Affected contract
- CNA::Internal::ParseJson - documented to throw JsonParseException for malformed input; reached by every .cnj load, the .cnj compilers and the gamer-services local store
Expected behaviour
Parsing untrusted JSON either succeeds or throws JsonParseException. The parser's own class comment in Json.hpp promises "correct nesting/depth tracking". The XNB side, with the same threat model, bounds both type-name nesting and object-graph nesting (XnbReadLimits::maxObjectNestingDepth, 256, enforced by ContentReader::InnerReadObject). LocalGamerServicesStore promises that a corrupt store file "starts empty rather than throwing".
Actual behaviour at TARGET
Detail::JsonParser::ParseValue dispatches { to ParseObject and [ to ParseArray, each of which calls ParseValue for every member or element. Nothing in Json.hpp counts depth, so stack use grows linearly with nesting and a deep document exhausts the thread's stack. That is a process crash, not an exception: no catch in ContentManager, the build tools or TryReadJsonFile can intercept it.
The parser is reached from ParseCnjEnvelope (CnjEnvelope.hpp), which every loose .cnj reader calls, directly from the typed loose readers in ContentManager.cpp (which then hand the parsed tree to the shared readers of CnjCanonicalRead.hpp), the .cnj→.cnb compilers (CnjToCnb.cpp, CnbModelFromCnj.cpp), the content pipeline's project, manifest and model stages, tools/content/content.cpp, and LocalGamerServicesStore.cpp.
Source locations
modules/content/include/CNA/Internal/Json.hpp— Detail::JsonParser::ParseValue / ParseObject / ParseArray - unbounded mutual recursionmodules/content/include/CNA/Internal/CnjEnvelope.hpp— ParseCnjEnvelope - calls ParseJson for every .cnj loadmodules/gamer-services/src/Internal/LocalGamerServicesStore.cpp— TryReadJsonFile - catches only JsonParseExceptionmodules/content/include/Microsoft/Xna/Framework/Content/ContentReader.hpp— InnerReadObject - the XNB-side ObjectDepthGuard for comparison
Evidence
Read at 009d40f5. Executed: a focused probe that includes only TARGET's header-only Json.hpp (it depends on the standard library alone), compiled with g++ 14.2 -std=c++23 at -O0 and -O2 and run on Linux x86-64 with the default 8 MiB main-thread stack. 1,000 and 10,000 nesting levels parse; 50,000 levels (at -O2) and 100,000 levels (both builds) end in SIGSEGV. A thread with a smaller stack fails sooner. The full ContentManager path was not executed; that every .cnj load reaches ParseJson is by reading ParseCnjEnvelope. There is no JSON or .cnj fuzz target: the fuzz suites under modules/content/tests cover XNB containers, LZX, XNB writer input and CNB containers.
Independent re-verification: Reproduced by the audit at 009d40f5. A probe that includes only TARGET's header-only Json.hpp, compiled with g++ -std=c++23 at -O0 and run on Linux x86-64 with the default 8 MiB stack, parsed 10,000 and 13,281 nested-array levels and crashed with SIGSEGV from 13,437 levels (about 27 KB) up, with 20,000, 50,000 and 100,000 levels all crashing; no JsonParseException was thrown. The entry's earlier run also crashed at 100,000 levels at -O2 and parsed 10,000. The full ContentManager path was not executed; that every .cnj load reaches ParseJson is by reading ParseCnjEnvelope. There is no JSON or .cnj fuzz target.
Focused reproduction
Compiled and run as described under Evidence (header-only; needs no other CNA code).
#include "CNA/Internal/Json.hpp"
#include <string>
int main()
{
const std::size_t depth = 100000; // a 200,000-byte document
const std::string doc = std::string(depth, '[') + std::string(depth, ']');
try { (void)CNA::Internal::ParseJson(doc); }
catch (const CNA::Internal::JsonParseException&) { return 0; } // wanted: rejected cleanly
return 1;
}
// Observed at 009d40f5: SIGSEGV before either return.
Current tests
JsonTests.cpp covers grammar, escapes, numbers and shallow nesting (ParsesNestedObjectsAndArrays); no case exceeds a few levels and none asserts a depth limit.
Regression test
Two JsonTests cases around the chosen ceiling: a document nested exactly to the limit parses, one level deeper throws JsonParseException (run it on a deliberately small-stack thread so a missing check fails loudly). Plus one Load<T> test with a deeply nested .cnj expecting ContentLoadException, and ideally a .cnj case in the existing fuzz harness family.
Blast radius
Any process that parses a .cnj or JSON file it did not write: games loading loose .cnj content (models, fonts, effects, animation clips, custom RegisterCnjLoader types), the cna-content build tool and pipeline, and the gamer-services store at start-up (where a corrupted store file should degrade to empty but can crash instead). .xnb and .cnb content does not go through this parser.
Workaround
Load only trusted .cnj files at run time, or ship compiled .cnb content instead of loose .cnj.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- ContentManager: JSON descriptor formats
- Known issues
- Bug index