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 c1c316b9  ·  Known Issues › Current bugs  ·  source links pinned to c1c316b9

✓

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 c1c316b9; 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 c1c316b9 (c1c316b9c7a846ce8002809c151fcd1af14942c9)
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 offline achievement catalog

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).

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 the gamer-services catalog reader 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 ParseOfflineCatalog in LocalGamerServicesStore.cpp (the title's offline achievement catalog). The store's own persisted files are parsed with nlohmann::json since GS-AUDIT-005 (38287637d) and no longer reach this parser.

Source locations

Evidence

Read at c1c316b9. Executed at 009d40f5: a focused probe that includes only the 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 the 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 offline achievement catalog (a title-shipped file). .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