| # CatBoost Duplicate feature_id Silent Feature Shadowing |
| |
| **Status:** Preparing for Huntr submission |
| **Package:** [`catboost`](https://pypi.org/project/catboost/) (PyPI) β Yandex's gradient boosting library |
| **File / function:** `catboost/core.py`, multiple methods using `self.feature_names_.index(feature)` (lines ~3910, 3981, 4168) |
| **Class:** CWE-706 (Use of Incorrectly-Resolved Name) + CWE-20 |
| **Severity:** Medium-High β silent model-integrity issue, not a crash. |
| |
| ## Summary |
| |
| Several public CatBoost methods resolve a feature given by name via: |
| |
| ```python |
| feature_idx = self.feature_names_.index(feature) |
| ``` |
| |
| `self.feature_names_` is populated directly from a loaded model file (tested here via CatBoost's JSON model format β a real, documented, supported export/import format). There is no check anywhere in the loading path that `feature_id` values are unique across the model's declared float features. |
| |
| Python's `list.index()` always returns the **first** matching element. A model file can declare two float features with the same `feature_id` but different real data (different learned split-point borders) β any name-based lookup silently resolves to the first one, and the second feature's real, distinct data becomes permanently unreachable via any name-based API, with zero error or warning. |
| |
| ## Proof of Concept |
| |
| `poc_catboost_duplicate_feature_id.py`: |
| |
| 1. Trains a small, legitimate 2-feature CatBoost model (`age`, `income`) and exports it to JSON. |
| 2. Patches the second feature's declared `feature_id` from `"income"` to `"age"`, keeping its real, distinct border data unchanged. |
| 3. Loads the patched file via the library's real, public, documented API: `CatBoostClassifier().load_model(path, format="json")`. |
| |
| ```bash |
| pip install catboost numpy |
| python poc_catboost_duplicate_feature_id.py |
| ``` |
| |
| ### Result |
| |
| ``` |
| === Loading via the real public API: CatBoostClassifier().load_model(path, format='json') === |
| loaded OK, no error. feature_names_ = ['age', 'age'] |
|
|
| feature_names_.index('age') resolves to position 0 |
| Position 1's real, distinct border data ([0.76..., 0.89...]) is now unreachable via name-based lookup -- 'age' always resolves to position 0. |
|
|
| CONFIRMED: the second feature's real data is silently shadowed. |
| ``` |
| |
| ## Impact |
| |
| A crafted or corrupted CatBoost model file can declare two structurally distinct, independently-trained features under the same name. Any downstream code that asks for statistics, borders, or feature-dependence plots by name silently receives the first feature's data instead β no error, no warning, no structural signal that something is wrong. This can be used to hide or misrepresent a model's actual behavior on a specific real-world input feature. |
| |
| ## Suggested fix |
| |
| Validate that `feature_id` values are unique across `features_info.float_features` (and equivalent categorical/text feature sections) during model loading, and raise a clear error on a duplicate. |
| |
| ## Relationship to other findings |
| |
| Same broad pattern family β duplicate identifier leading to silent "first wins" resolution so real data becomes unreachable β as separately-reported findings this session in `gguf-py` (tensor offset aliasing), `ollama/ollama`'s Go GGUF parser (duplicate tensor names), and `sklearn-pmml-model` (duplicate VectorInstance id) β all first-wins, all missing the same class of uniqueness validation. This is the first confirmed instance of the pattern in a gradient-boosting model format. |
| |
| ## Disclosure |
| |
| Please do not use this PoC against production systems you do not own or have explicit permission to test. |
| |