CNA-BUG-016: The .cnj JSON parser (CNA::Internal::ParseJson) recurses without a nesting limit, so a small crafted document overflows the stack

CNA snapshot 009d40f5  ·  Known Issues › Current bugs  ·  source links pinned to 009d40f5

✓

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

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.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Known issues
Bug index