| # CWE-825 / CWE-476: Invalid-iterator dereference in CNTK v2 `CompositeFunction::DeserializeBlockComposite` |
|
|
| Unchecked `*std::find_if(... end())` on the root-function lookup crashes on any crafted native `.model` file. |
|
|
| ## Target |
|
|
| - **Project:** Microsoft Cognitive Toolkit (CNTK) |
| - **Component:** `Source/CNTKv2LibraryDll/CompositeFunction.cpp` — native CNTK v2 model (checkpoint) deserializer |
| - **Version:** current `master` (archived project; the vulnerable code is present in the released 2.7 line through HEAD) |
| - **Entry point:** `CNTK::Function::Load(filepath, DeviceDescriptor, ...)` with format `ModelFormat::CNTKv2` |
| - **Class:** CWE-825 (Expired Pointer / invalid iterator dereference), CWE-476 (NULL Pointer Dereference) |
|
|
| ## Root cause |
|
|
| `CompositeFunction::DeserializeBlockComposite` resolves the composite graph's root function by |
| dereferencing the result of `std::find_if` **without ever comparing it to `end()`** |
| (`CompositeFunction.cpp`, ~lines 203-206, current master): |
|
|
| ```cpp |
| FunctionPtr root = *std::find_if(allPrimitiveFunctions.begin(), allPrimitiveFunctions.end(), |
| [&rootUid](const FunctionPtr& func) { |
| return func->Uid() == rootUid; |
| }); |
| ``` |
|
|
| - `rootUid` is taken from the **model-controlled** `rootKey` of the composite `Dictionary`. |
| - `allPrimitiveFunctions` is a `std::unordered_set<FunctionPtr>` (`FunctionPtr == std::shared_ptr<Function>`) |
| built from the **model-controlled** `functionsKey` vector in `CompositeFunction::Deserialize` |
| (`CompositeFunction.cpp` ~lines 251-289). |
|
|
| If `rootUid` matches no deserialized function, `std::find_if` returns `end()`. Dereferencing that |
| past-the-end iterator of the `unordered_set` reads through the container's null terminal hash node and |
| copy-constructs a `std::shared_ptr<Function>` from it — a read of near-null memory (offset `0x8`). |
|
|
| This is trivially reachable: |
|
|
| - an **empty `functions` vector** → `allPrimitiveFunctions` is empty → `find_if` immediately returns `end()`, or |
| - any `rootKey` uid string that does not correspond to some function's `Uid()`. |
|
|
| `ValidateDictionary` (`CompositeFunction.cpp` ~196/233) only checks required-key **presence**, the type |
| tag, and the version. It performs **no** cross-reference that the declared root actually exists among the |
| functions, so nothing guards the dereference. |
|
|
| ## Reachability (source trace) |
|
|
| ``` |
| Function::Load(filepath, CNTKv2) Function.cpp:508-519 |
| -> operator>>(std::istream&, Dictionary&) |
| -> Serializer::Read |
| -> Function::Deserialize Function.cpp:1093-1095 |
| -> CompositeFunction::Deserialize CompositeFunction.cpp:229 |
| -> DeserializeBlockComposite CompositeFunction.cpp:296 -> 190 |
| -> *std::find_if(...) <-- crash CompositeFunction.cpp:203 |
| ``` |
|
|
| A malicious native CNTK v2 `.model` whose top-level composite `Dictionary` has `type=CompositeFunction`, |
| an empty `inputs` vector, an empty `functions` vector, a `uid`, and a `rootKey` set to any string reaches |
| the dereference of `end()` on the empty `allPrimitiveFunctions` set. |
|
|
| ## Proof of concept |
|
|
| A full CNTK build is infeasible in this environment (archived project requiring MKL / Boost / OpenCV / |
| pinned protobuf; only ~7 GB free on `/`), matching the approach used for the three prior verified CNTK |
| findings by this researcher. Instead, a faithful harness reproduces the **exact** vulnerable lines |
| **verbatim**: the same `std::find_if` lambda over the same container type |
| `std::unordered_set<std::shared_ptr<Function>>` (CNTK's `FunctionPtr`), with `Function` exposing the same |
| `Uid()` surface. Only `Function`/`FunctionPtr` are reduced to a minimal stand-in; the crashing statement is |
| copied character-for-character from `CompositeFunction.cpp:203-206`. |
|
|
| - **Harness:** `harness.cpp` (in this repo) |
| - **Build:** `g++ 15.2 -fsanitize=address -O0 -std=c++14 harness.cpp -o harness` |
| - **TRIGGER** (empty set, no matching `rootUid`): AddressSanitizer SEGV. |
| - **NEGATIVE CONTROL** (insert a `Function` whose `Uid() == rootUid`, so `find_if` returns a valid |
| iterator): returns cleanly, exit 0, `use_count=2` — proving the crash is specifically the unchecked |
| `end()` dereference and not an artifact of the harness. |
|
|
| ## Captured evidence (verbatim) |
|
|
| ``` |
| === NEGATIVE CONTROL (matching root uid) === |
| [control] set size=1 |
| returned root use_count=2 ptr=0x7b7823be0020 |
| control exit=0 |
| === TRIGGER (empty set / no matching root uid) === |
| [trigger] set size=0 (empty) -> find_if returns end() |
| AddressSanitizer:DEADLYSIGNAL |
| ==1100204==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000008 (pc 0x5604be983bdd bp 0x7fffe078c2f0 sp 0x7fffe078c2e0 T0) |
| ==1100204==The signal is caused by a READ memory access. |
| ==1100204==Hint: address points to the zero page. |
| #0 ... std::__shared_ptr<Function, ...>::__shared_ptr(... const&) /usr/include/c++/15/bits/shared_ptr_base.h:1529 |
| #1 ... std::shared_ptr<Function>::shared_ptr(std::shared_ptr<Function> const&) /usr/include/c++/15/bits/shared_ptr.h:203 |
| #2 ... DeserializeBlockComposite_vuln harness.cpp:46 (== CompositeFunction.cpp:203 verbatim) |
| #3 ... main harness.cpp:74 |
| SUMMARY: AddressSanitizer: SEGV harness.cpp:46 in DeserializeBlockComposite_vuln |
| ==1100204==ABORTING |
| Registers: rax=0x0000000000000008 rsi=0x0000000000000008 (read of past-the-end null hash-node value at offset 0x8) |
| ``` |
|
|
| The crash was re-reproduced during packaging (fresh build, g++ 15.2, ASan): SEGV on address `0x000000000008`, |
| READ access, same `std::__shared_ptr` copy-constructor frame reached from `DeserializeBlockComposite_vuln`; |
| the control path returns exit 0 with `use_count=2`. |
|
|
| ## Impact |
|
|
| Denial of service (guaranteed crash) on loading an untrusted native CNTK v2 `.model` file via the public |
| `Function::Load` API. The dereference reads attacker-influenceable near-null memory during a |
| `shared_ptr` copy; the reference-count manipulation on a fabricated control block is a memory-safety |
| violation with potential for worse-than-DoS behavior depending on heap layout. |
|
|
| ## Suggested fix |
|
|
| Compare the iterator against `end()` before dereferencing and raise a deserialization error if the declared |
| root uid is not found: |
|
|
| ```cpp |
| auto it = std::find_if(allPrimitiveFunctions.begin(), allPrimitiveFunctions.end(), |
| [&rootUid](const FunctionPtr& func) { return func->Uid() == rootUid; }); |
| if (it == allPrimitiveFunctions.end()) |
| RuntimeError("DeserializeBlockComposite: root function uid '%S' not found among deserialized functions", rootUid.c_str()); |
| FunctionPtr root = *it; |
| ``` |
|
|
| ## Deduplication |
|
|
| Distinct from the three prior CNTK findings by this researcher: |
|
|
| 1. `DictionaryValue::Value<T>()` null-pointer dereference (DictionaryValue type-tag handling), |
| 2. ONNX-import `UnpackDouble` out-of-bounds read (ONNX import path), |
| 3. `NDShape::TotalSize` allocation bomb (shape arithmetic). |
|
|
| This is an **unchecked-iterator (`find_if == end()`) invalid dereference in the composite-function graph |
| deserializer** — unrelated to DictionaryValue tagging, ONNX import, or shape arithmetic. No known CVE |
| covers `CompositeFunction::DeserializeBlockComposite`. |
| |