GHSA-9hhf-6357-hpgq: Deleting a user-mode module thread leaks its privileged kernel stack
GHSA ID |
|
|---|---|
CVE ID |
CVE‑2026‑95108 |
Severity |
Medium |
CWE |
|
Published |
2026‑10‑02 |
Last updated |
2026‑10‑02 |
Affected packages
| Package | Vulnerable versions | Patched versions |
|---|---|---|
ThreadX |
>= 6.0.1, ⇐ 6.5.1.202602a |
6.5.2.202603 |
Description
Summary
One logical thread of a user-mode ThreadX module costs the Module Manager’s shared object pool two allocations, not one. The module asks for a TX_THREAD control block through txm_module_object_allocate; _txm_module_manager_thread_create then takes a second allocation of TXM_MODULE_KERNEL_STACK_SIZE bytes for the privileged kernel stack that thread’s system calls run on, and records it in the control block. Both come from _txm_module_manager_object_pool and both are linked onto the module’s allocation list.
The Module Manager’s thread-delete dispatcher released only the first. The kernel stack stayed allocated until the whole module was stopped, so a module that creates, terminates and deletes threads in a loop — every state transition valid, every service returning TX_SUCCESS — removed a fixed block from a finite pool shared by the manager and by every other loaded module, on every cycle, for as long as it stayed loaded. Once the pool is exhausted, no memory-protected module can create any kernel object and no user-mode module thread can be created anywhere in the system.
Vulnerable function: _txm_module_manager_tx_thread_delete_dispatch, in common_modules/module_manager/inc/txm_module_manager_dispatch.h.
Details
A module loaded with TXM_MODULE_USER_MODE runs its own code unprivileged, and the privileged side of each of its system calls needs a stack of its own. _txm_module_manager_thread_create allocates that stack per thread, out of the object pool, and stores the address in the new thread’s tx_thread_module_kernel_stack_start. The module never requests it and cannot see it; it is the manager’s allocation, made on the module’s behalf.
On a successful tx_thread_delete the dispatcher called _txe_thread_delete and then released param_0, the control block, and nothing else. Nothing else in the system reclaims the kernel stack:
-
_tx_thread_deleteinvalidates the thread ID and unlinks the thread from the created list, and does nothing about module memory. -
TX_THREAD_DELETE_PORT_COMPLETION, the one hook that could, defaults to empty and is overridden only by the Linux, Win32 and Win64 host ports. No module port overrides it. -
The only sweep is in
_txm_module_manager_stop, which releases every allocation still on the module’s list — but only when the entire module is stopped.
So the block remained linked on txm_module_instance_object_list_head and counted in txm_module_instance_object_list_count, unavailable to any caller, until the module was torn down. This is not allocator caching: the byte pool considers the block in use, and a replacement thread gets a fresh allocation.
The per-cycle cost is a compile-time constant of the port — 512, 768 or 1024 bytes — plus the manager’s allocation header and the byte pool’s own per-block overhead. On a small pool that is enough to matter quickly: on the 12,280-byte pool the regression test uses, the measured cost is 792 bytes per cycle and fifteen cycles exhaust it.
The creation path is not at fault. _txm_module_manager_thread_create has no failure return after the kernel-stack allocation succeeds, so no error path leaks the stack at creation time. The defect is entirely in the deletion path.
Impact
A module with no privileges beyond the ability to manage its own threads can exhaust a finite privileged resource that the whole system shares, while its own accounting looks correct throughout — every service returns success, and it holds no live thread and no live allocation of its own.
Once the pool is exhausted the loss is total for that resource rather than gradual. All eight object-create dispatchers refuse a memory-protected module’s request, and any application logic that needs to create a kernel object in order to respond to the condition cannot itself run. Depending on the integrator’s error handling this disables services, prevents recovery tasks from starting, wedges module restart logic, or fails the system’s availability outright.
Because every cycle uses valid ThreadX state transitions, neither the error-checking layer nor MPU or MMU isolation prevents it. An accidental dynamic-thread workload exhausts the pool by the same mechanism over a longer period.
Recovery requires stopping the offending module, which does reclaim the leaked blocks and does not itself need the exhausted pool. The condition therefore persists after the module stops attacking, until an administrative act by another component clears it.
Affected configurations
ThreadX Modules with a Module Manager object pool, and a module loaded with TXM_MODULE_USER_MODE.
Memory protection requires user mode: both load paths refuse a module that asks for TXM_MODULE_MEMORY_PROTECTION without TXM_MODULE_USER_MODE. Every memory-protected module is therefore a user-mode module, and every one of its threads holds a kernel stack. A user-mode module without memory protection is affected in the same way.
All 31 memory-protected module port combinations are affected. Every one defines TXM_MODULE_KERNEL_STACK_SIZE — 768 bytes on 25 of them, 512 on five and 1024 on one — so the leaked block exists everywhere and only its size varies.
Present since v6.0.1_rel, the release that introduced user mode, the per-thread kernel stack and the dispatch table together. The thread-delete dispatcher was byte-identical in that release and in every release since, up to and including v6.5.1.202602a_rel. v6.0_rel predates user mode and is unaffected.
Severity
CVSS v4.0 AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N — 6.8, medium.
The actor is a module running on the device, using the ordinary thread-management system calls it is entitled to make: correctly ordered create, terminate and delete calls in a loop, with no race, no allocator shaping beyond repetition, no malformed argument and no configuration beyond a normal supported user-mode Modules setup.
Nothing is disclosed and nothing is corrupted. The only observable is the iteration at which allocation starts failing, which a module can already measure through txm_module_object_allocate. The leaked allocation stays correctly linked with an intact header and an accurate count, and the module-stop sweep releases it properly.
VA:H because the pool is finite and shared, every cycle consumes a fixed block, the module can run arbitrarily many cycles, and the loss is a total refusal of the resource rather than a degradation that persists after the attack completes.
Subsequent-system metrics are N: the impact stays inside one ThreadX instance — one kernel, one address space, one object pool, one Module Manager. A second module is a component of the vulnerable system rather than a subsequent system, because the memory-protection boundary between modules partitions memory, not security authority, and a denial reaching another module reaches it through a resource VA already records. Consequences beyond the RTOS instance depend on what a particular product puts in a second module and belong in the Environmental metrics.
Patches
Fixed in ThreadX 6.5.2.202603.
On a successful delete the dispatcher releases the kernel stack through _txm_module_manager_object_deallocate before releasing the control block. Two conditions gate the release: the calling module carries TXM_MODULE_USER_MODE, and tx_thread_module_kernel_stack_start is non-null. The property flag mirrors the allocation side, so the two ends of the thread’s life agree about when there is a kernel stack. The null test makes the release exact rather than an inference from how the module was loaded: a user-mode module can be given the address of a thread whose kernel stack the manager never allocated, and deleting such a thread would otherwise ask for memory back that was never handed out. Both thread create paths clear the entire control block before filling it in, so a thread with no manager-allocated kernel stack holds TX_NULL in that field.
Reading the field after the delete is sound: _tx_thread_delete leaves the control block intact and no module port defines a completion hook that would clear it. Releasing the control block last means the pointer is read while the control block is still the module’s. Both releases are attempted whatever the other returns, and the first failure is what the module is told — the kernel thread is already deleted when either is attempted, so neither allocation can still be in use, and skipping one because the other was refused would strand an allocation. Releasing through _txm_module_manager_object_deallocate keeps the existing ownership check, so a module cannot release another module’s kernel stack this way, and that function unlinks the allocation and decrements the count before releasing the memory, so the module-stop sweep cannot then double free it.
No other Module Manager delete dispatcher has an equivalent defect. _txm_module_manager_object_allocate is called from exactly two places in the manager — the txm_module_object_allocate dispatcher, which is the module’s own request, and this kernel-stack site — so no other object type has a manager-owned satellite allocation. The module’s own code and data areas come from a different pool, _txm_module_manager_byte_pool, and are released at unload.
A host test asserts the invariant this defect broke: deleting the thread must return the pool’s available bytes and the module’s allocation-list count to exactly what they were. The assertion is an equality rather than a bound, because a bound would pass while one of the two allocations was left behind on every cycle. It then runs 200 create-and-delete cycles against a pool that a leak of one kernel stack per cycle exhausts well inside that count. Against the unfixed dispatcher the test fails in eight places and exhausts the pool at cycle 22. It needed a host harness for the Module Manager, which the test tree did not have — the manager is not in the ThreadX library the suite links, and its control blocks come from a module port rather than a base port. That harness ships with the fix and is reusable by any later test of the manager.
The txm_module_manager_object_pool_create reference gains the sizing fact it was missing. The manual described the object pool as memory the modules allocate their objects from, and said nowhere an integrator sizing a pool would look that the manager also draws on it for a per-thread kernel stack, so a pool sized for control blocks alone is undersized by TXM_MODULE_KERNEL_STACK_SIZE per module thread.
Workarounds
None that preserve the feature. Every path here is reachable only from a module the Module Manager has loaded, so a system that loads only modules it builds itself is not exposed — but that is precisely the assumption module memory protection exists so that you do not have to make.
Credits
Found and fixed by Frédéric Desbiens (@fdesbiens) during a review of the Module Manager’s object lifetime paths. The analysis, fix, regression test and documentation change were produced with AI assistance under human review, in accordance with the Eclipse Foundation’s Generative AI Usage Guidelines.