CNA-BUG-125: DecompressXnbPayload grows the LZX output without bound and checks it against the declared size only after the last block
Evidence basis: source-verified at the pinned commit; 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.
The LZX loop appends up to 64 KiB per block into an unbounded MemoryStream, so XnbReadLimits::maxDecompressedSize bounds only the declared size, not the memory the decoder actually allocates.
- Identifier
CNA-BUG-125- Category
- Bug
- Subsystem
- Content & XNB/CNB/CNJ
- Status
- Open
- Verified against
- CNA
009d40f5(009d40f5dd085c4e674d3479675fac84b12b3e0a) - Severity
- Low (a triage suggestion, not a project priority)
- Evidence basis
- Source-verified: read at TARGET, not executed
- Tests touching this area
- Yes: see Current tests
- Affected contract
- CNA::Internal::Xnb::DecompressXnbPayload and XnbReadLimits::maxDecompressedSize
Expected behaviour
XnbReadLimits.hpp documents maxDecompressedSize as the “largest decompressed XNB payload this reader will allocate”. The LZ4 decoder in the same file honours that: it allocates exactly the declared size and fails as soon as a literal or match would exceed it.
Actual behaviour at TARGET
DecompressXnbPayload in XnbDecompression.cpp validates the declared size, then decodes block after block into a growable MemoryStream; each block writes frame_size bytes (up to 0xFFFF) and the loop ends only when the compressed input is consumed. The produced length is compared with the declared size once, after the loop. A crafted file within the 64 MiB maxFileSize can therefore make the decoder allocate far more than 256 MiB before that comparison rejects it.
Source locations
modules/content/src/Xnb/XnbDecompression.cpp— DecompressXnbPayload: unbounded decompressedStream, size check after the loop; DecompressXnbLz4Payload for the capped contrastmodules/content/include/CNA/Internal/Xnb/XnbReadLimits.hpp— XnbReadLimits::maxDecompressedSize documentationmodules/content/src/Xnb/LzxDecoder.cpp— LzxDecoder::Decompress writes outLen bytes per call
Evidence
Checked by reading at 009d40f5; not executed and no bomb file was built. The achievable ratio depends on how many output frames a crafted LZX stream can encode per input byte; the bound on memory is not measured.
Focused reproduction
No focused reproduction is known. Nothing has been invented here; the evidence above is what exists.
Current tests
LzxDecoderFuzzTests.cpp mutates real LZX payloads and the declared size and asserts that every call completes or fails with a clean exception (also under ASan/UBSan); it does not bound peak allocation, and random mutation rarely yields a valid high-ratio stream.
Regression test
A synthetic LZX stream whose blocks decode to more than the declared size, expecting ContentLoadException before the output exceeds the declared size (the fix is to stop, or fail, when decompressedStream would pass decompressedSize).
Blast radius
Every LZX-compressed .xnb, typed and untyped. Only malformed or hostile files are affected; valid files decode to their declared size.
Workaround
Load content only from trusted sources.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Internals
- Content runtime: the XNB tier
- Deep dives
- Content robustness: limits · XNB container: compression
- Known issues
- Bug index