Add OpenVINO IR <user_data> rt_info unbounded-recursion stack-overflow PoC (private, gated)
6d0621d verified | # OpenVINO IR `rt_info` `<user_data>` deserialization: unbounded recursion β native stack overflow (SIGSEGV DoS) in `read_model` | |
| ## Summary | |
| `ov::Core().read_model()` can be crashed (SIGSEGV, process-fatal denial of service) by loading a | |
| crafted, otherwise-valid OpenVINO IR (`.xml`) model. The IR deserializer recurses once per nesting | |
| level of `<user_data name="...">` elements inside any layer's `<rt_info>`, with **no recursion-depth | |
| limit**. A deeply nested `<user_data>` chain exhausts the calling thread's native C++ stack and | |
| crashes the process before any graph/shape validation runs. | |
| - **Target:** OpenVINO β IR (v11) XML front-end / model deserializer | |
| - **Affected component:** `src/core/xml_util/src/xml_deserialize_util.cpp`, function `set_custom_rt_info()` | |
| - **Verified on:** `openvino 2026.2.1` (pip wheel, CPU), version string | |
| `2026.2.1-21919-ede283a88e3-releases/2026/2`, Linux x86-64, default 8 MB thread stack | |
| - **Impact:** Denial of service (native stack-overflow SIGSEGV) triggered purely by loading an | |
| attacker-supplied model file. No ASan build required to observe the crash. | |
| - **Attack surface:** Any application that calls `read_model` / `compile_model` on an untrusted IR file | |
| (model zoos, model-conversion services, CI that loads third-party models, etc.). | |
| ## Root cause | |
| `set_custom_rt_info()` iterates a node's `<rt_info>` children. For each child named `user_data` that | |
| has a `name` attribute but **no** `value` attribute, it recurses into that element's own children to | |
| build a nested `AnyMap`: | |
| ```cpp | |
| // src/core/xml_util/src/xml_deserialize_util.cpp | |
| void set_custom_rt_info(const pugi::xml_node& rt_attrs, ov::AnyMap& rt_info, bool prefix_needed = true) { | |
| constexpr std::string_view rt_info_user_data_tag{"user_data"}; | |
| std::string custom_name, custom_value; | |
| for (const auto& item : rt_attrs) { | |
| if (std::strcmp(item.name(), rt_info_user_data_tag.data()) == 0) { | |
| if (getStrAttribute(item, "name", custom_name)) { | |
| const auto name = std::string{prefix_needed ? rt_info_user_data_tag : ""} + custom_name; | |
| if (getStrAttribute(item, "value", custom_value)) { | |
| rt_info.emplace(name, custom_value); // non-recursive branch (has value=) | |
| } else { | |
| rt_info.erase(name); | |
| if (auto map_elem = rt_info.emplace(name, ov::AnyMap{}); map_elem.second) { | |
| auto& nested_map = map_elem.first->second.as<ov::AnyMap>(); | |
| set_custom_rt_info(item, nested_map, false); // <-- UNBOUNDED RECURSION (line ~147) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| ``` | |
| Every nesting level of `<user_data name="a"> ... </user_data>` (with no `value` attribute) drives one | |
| additional native C++ stack frame during deserialization, called from the layer-`rt_info` parse path | |
| (`set_custom_rt_info(rt_attrs, rt_info);`). There is no depth cap, so a sufficiently deep chain | |
| exhausts the thread stack and the process dies with SIGSEGV. | |
| This path is distinct from the previously reported If/Loop nested-subgraph `parse_function` recursion: | |
| - **Different function** (`set_custom_rt_info`, not `parse_function`). | |
| - **Different subsystem** (metadata/`rt_info` parsing, not control-flow subgraph parsing). | |
| - **No control-flow ops required** β any ordinary node's `<rt_info>` suffices (here a plain `ReLU`). | |
| ## Proof of concept | |
| Generator: `gen_rtinfo_recursion.py` emits a minimal valid IR (`Parameter β ReLU β Result`) whose | |
| `ReLU` layer carries an `<rt_info>` containing `N` nested `<user_data name="a">` elements (each with no | |
| `value` attribute, so the recursive branch is taken every level). | |
| ```bash | |
| python3 gen_rtinfo_recursion.py 20000 poc.xml # writes poc.xml + empty poc.bin | |
| python -c "import openvino as ov; ov.Core().read_model('poc.xml')" # SIGSEGV | |
| ``` | |
| Self-contained repro files included: `selfpoc.xml` / `selfpoc.bin` (depth 20000, crashes), | |
| `selfctrl.xml` / `selfctrl.bin` (depth 1000, loads OK). Also `ud_20000.xml`, `ud_1000.xml`, | |
| `ud_50000.xml`, and the two negative controls `ctrl_zzz.xml`, `ctrl_udval.xml`. | |
| ## Captured evidence (verbatim) | |
| Primary repro, `faulthandler` shows the crash inside `read_model`: | |
| ``` | |
| $ ./venv/bin/python -X faulthandler -c "import openvino as ov; print('OV',ov.__version__); ov.Core().read_model('ud_20000.xml'); print('NOCRASH')" | |
| OV 2026.2.1-21919-ede283a88e3-releases/2026/2 | |
| Fatal Python error: Segmentation fault | |
| Current thread 0x00007f885a7d2200 (most recent call first): | |
| File ".../openvino/_ov_api.py", line 601 in read_model | |
| File "<string>", line 1 in <module> | |
| Extension modules: numpy._core._multiarray_umath, numpy.linalg._umath_linalg (total: 2) | |
| RC=139 | |
| ``` | |
| ### Depth threshold (default 8 MB stack) | |
| | depth | result | | |
| |-------|--------| | |
| | 1000 / 5000 / 10000 | LOADED OK (rc=0) | | |
| | 20000 / 50000 / 200000 / 500000 | SIGSEGV (rc=139) | | |
| ### Reproducibility | |
| `ud_20000.xml` crashes 3/3 fresh processes: `run1 rc=139 / run2 rc=139 / run3 rc=139`. | |
| ### Negative control 1 β isolates pugixml (not deep-XML parsing) | |
| `ctrl_zzz.xml` = same depth (50000) but nested **non-`user_data`** tags (`<zzz>...`): | |
| β `LOADED_OK` (rc=0). Proves the crash is not generic deep-XML/pugixml parsing but the `user_data` | |
| recursion specifically. | |
| ### Negative control 2 β isolates the recursive branch | |
| `ctrl_udval.xml` = 50000 nested `<user_data name="a" value="v">` (**with** `value` attribute, which | |
| takes the non-recursive `emplace` branch): β `LOADED_OK` (rc=0). Proves it is the value-less recursive | |
| branch, not `user_data` nesting per se. | |
| ### Stack-size scaling β definitive stack-overflow signature | |
| `ud_50000.xml`: | |
| - default 8 MB stack β **SIGSEGV (rc=139)** | |
| - `ulimit -s 65536` (64 MB stack) β **LOADED_OK (rc=0)** | |
| The crash threshold moves with available stack β call-stack exhaustion, not heap/data corruption. | |
| ## Dedup / prior-art note | |
| - Distinct from OpenVINO's If/Loop nested-subgraph `parse_function` unbounded-recursion issue | |
| (different function, different subsystem, and this one needs no control-flow ops). | |
| - No public CVE was found for `set_custom_rt_info` / `rt_info` `user_data` recursion at time of writing. | |
| - The generic class (missing recursion-depth limit in a recursive-descent model parser) is a known | |
| memory-safety pattern; this report identifies a specific, independently reachable instance in the | |
| `rt_info` metadata path. | |
| ## Suggested fix | |
| Add an explicit recursion-depth limit (or convert to an explicit work-stack with a cap) in | |
| `set_custom_rt_info()`, rejecting the model with a clear error once a reasonable nesting depth is | |
| exceeded. | |