Skip to content

[c++] Out-of-bounds write (and read) when loading and predicting with an untrusted text model #7357

Description

@geo-chen
LightGBM is no longer owned or maintained by Microsoft. Ownership of the project has transitioned to an external maintainer that operates its own security reporting process. Because the affected component falls outside Microsoft ownership, the report does not meet the bar for action.

Summary

The LightGBM text model parser validates array lengths against the number of leaves but never validates the parsed values. As a result, an untrusted text model can drive two memory-safety faults during normal use:

  1. An out-of-bounds write with an attacker-controlled offset. A left_child or right_child entry can be any 32-bit integer. A negative value is treated as a leaf reference and bit-inverted into an array index, which is then used as a write subscript into the leaf_depth_ buffer. Calling predict(pred_contrib=True) (SHAP feature contributions, a documented feature) stores the recursion depth at leaf_depth_ + (attacker_index) * 4, an out-of-bounds write whose target address the attacker controls.

  2. An out-of-bounds read. A split_feature entry is used directly as an index into the caller's feature buffer during ordinary predict, faulting on a large value.

Both stem from the same defect: parsed node values (left_child, right_child, split_feature, categorical indices) are used as array subscripts without any bounds validation. Models are shared, downloaded, and stored, so loading and predicting with one is a common, deliberate action.

Details

Out-of-bounds write (primary). The parser reads left_child and right_child with only a length check (src/io/tree.cpp:756-765):

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

The values are never checked to be valid node or leaf references. When SHAP contributions are requested, GBDT::PredictContrib initializes each tree via models_[i]->RecomputeMaxDepth() (src/boosting/gbdt.h:445, guarded by is_pred_contrib && !models_initialized_). RecomputeMaxDepth calls RecomputeLeafDepths, which recurses over the child arrays and writes using a bit-inverted child value as the index (include/LightGBM/tree.h):

inline void Tree::RecomputeLeafDepths(int node, int depth) {
  if (node == 0) leaf_depth_.resize(num_leaves());
  if (node < 0) {
    leaf_depth_[~node] = depth;                 // ~node is attacker-controlled; no bounds check
  } else {
    RecomputeLeafDepths(left_child_[node], depth + 1);
    RecomputeLeafDepths(right_child_[node], depth + 1);
  }
}

A child value of -1000000000 yields ~(-1000000000) = 999999999, so leaf_depth_[999999999] = depth writes about 4 GB past a buffer sized to the number of leaves. The write offset is fully attacker-controlled through the child value (any negative int maps to an index across the positive int range), and the written value is the recursion depth, which the attacker influences by nesting depth. This is a constrained write-what-where primitive (controlled address, small controlled value), not merely a crash.

Confirmed under a debugger on lightgbm 4.6.0. The faulting instruction is a store, not a load:

mov  %r15d,(%rdx,%rax,4)         in LightGBM::Tree::RecomputeLeafDepths(int, int)
rax = 0x3b9ac9ff = 999999999     (= ~(-1000000000), the attacker index)
rdx = leaf_depth_ base ;  %r15d = depth (value stored)
fault address = 0xef6d392c       (base + index*4, about 4 GB past the buffer)

Out-of-bounds read (sibling). split_feature is read with only a length check (src/io/tree.cpp:769) and used as an index in the hot prediction path (include/LightGBM/tree.h:710, numerical; :706 categorical):

node = Decision(feature_values[split_feature_[node]], node);

split_feature_[node] indexes the caller's feature_values buffer; a value such as 2000000000 reads far out of bounds and faults during ordinary predict.

The same unchecked-value pattern affects related sinks: tree.h:386-387 uses cat_idx = static_cast<int>(threshold_[node]) then indexes cat_boundaries_[cat_idx] unchecked; tree.cpp:865 sizes cat_threshold_ from the attacker-controlled cat_boundaries_.back(); and out-of-range left_child_/right_child_ values also enable out-of-range node indices and cycles. No published GHSA covers the LightGBM C++ text parser; CVE-2024-37056 is an unrelated MLflow pickle issue.

PoC

shared in the GHSA - available here upon request

Impact

A malicious LightGBM text model, when loaded and used, corrupts memory in the loading process. The contribution path (predict(pred_contrib=True)) yields an out-of-bounds write whose target address is attacker-controlled and whose value is a small attacker-influenced integer, which is a memory-corruption primitive: depending on heap layout it overwrites adjacent allocations and is a plausible building block toward control-flow hijack, beyond the reliable crash. The ordinary prediction path yields an out-of-bounds read and crash. Both are reachable through the standard workflow of loading a shared or downloaded model and predicting with it. Code execution was not demonstrated; the out-of-bounds write with a controlled offset is.

Remediation

In the text model parser (Tree::Tree from string), validate every parsed node value before use: require split_feature entries to be within the model's feature count, require left_child/right_child to reference valid in-range nodes or leaves and to form an acyclic tree, and validate categorical indices (threshold_ as cat_idx, cat_boundaries_ entries) against their array bounds. Reject the model at load time when any value is out of range, rather than indexing with it during recompute or prediction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions