GHSA-57mf-w87f-rq7m: Module object deallocation can free a live ThreadX kernel object

GHSA ID

GHSA‑57mf‑w87f‑rq7m

CVE ID

CVE‑2026‑94192

Severity

High

CWE

CWE-416, CWE-664

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

A ThreadX module allocates the control blocks of its kernel objects from the Module Manager’s object pool, and can ask for that memory back by address with txm_module_object_deallocate. The manager released it whatever was in it, including a control block the kernel was still using. Deallocation is not deletion: the object stays on the created list for its type, a thread stays wherever it was on the ready or suspension lists and stays schedulable, and an active timer stays on the timer list. The byte pool is meanwhile free to hand those same bytes to the next allocation.

Details

_txm_module_manager_object_deallocate checked that the address named one of the calling module’s allocations and then unlinked it and called _txe_byte_release. Nothing established that the allocation was not a live kernel object. The private allocation header records the owning module, the list links and the byte size, and nothing else — there is no state in it distinguishing raw storage from a control block the kernel has been told about.

None of the structures that go on referring to the object consult a control block ID, so nothing about the memory having been released stops the kernel from using it:

  • the object remains on the created list for its type, so a later create or delete of another object of that type writes through the created_next and created_previous fields inside the released block;

  • txm_module_manager_object_pointer_get_extended walks those same lists bounded by the kernel’s created counts, reads the tx*_name pointer straight out of the released block and hands it to _txm_module_manager_object_name_compare. It is reachable by any module through txm_module_object_pointer_get and by the application directly;

  • a released TX_THREAD remains in the ready or suspension list it was on and remains schedulable. On the Cortex-A7 module port tx_thread_schedule.S does LDR sp, [r0, #8] — tx_thread_stack_ptr — and then pops a saved CPSR and register state from there;

  • a released active TX_TIMER remains on the timer list, and _tx_timer_expiration_process reads tx_timer_internal_timeout_function out of the block and calls it.

The pool then reuses the block. A module can arrange that: it asks for an allocation of the same size and the pool hands the block back. Because distinct object types share a size on a 32-bit target — TX_BYTE_POOL, TX_MUTEX and TX_TIMER are all the same size — and because a smaller control block placed on a larger one replaces its head while its tail survives, ordinary create services are enough to put a module-chosen value at the two offsets the kernel dereferences with nothing checked. A timer created on a released thread’s control block sets offset 8 from its own initial_ticks, a module-chosen ULONG, while the released thread’s tx_thread_module_instance_ptr survives beyond the timer’s size and keeps the port’s address-space switch satisfied.

The memory protection boundary does not help. The privileged Module Manager performs the release on the module’s behalf, and for a module without memory protection the dispatcher’s parameter checks are skipped entirely.

GHSA-36vf-7cx6-9f9j establishes that a module may destroy only the kernel objects it owns. It does not reduce this advisory: deallocation is its own service and reaches the release without any delete having happened, so the ownership question never arises on this path. The two compose in the deallocator — that advisory establishes that the address names one of the calling module’s allocations, and this one refuses to release it while a live object is in it.

Impact

A low-privilege module induces a privileged use-after-free on memory shared by every module and by the Module Manager itself. Integrity and availability are lost unconditionally and without any reuse of the memory: the kernel writes through stale created-list links inside a released block, follows a name pointer read out of it, restores a thread context from it, and calls a function pointer out of it.

With the block reused — which the module can arrange — those same two dereferences become privileged control-flow capture with an attacker-supplied processor state, from which total loss of confidentiality follows.

There is no recovery short of reboot once the created lists or the pool have been corrupted.

Affected configurations

Any ThreadX Modules build with a Module Manager object pool. The defect is portable rather than port-specific: the service lives in common_modules and every module port reaches it through the same dispatcher. It requires TXM_MODULE_OBJECT_DEALLOCATE_CALL, which is present unless the integrator defines TXM_MODULE_OBJECT_DEALLOCATE_CALL_NOT_USED.

Every release from 6.0.1 through 6.5.1.202602a, verified by reading each tag rather than by inference. The service arrived with the Modules component itself in commit 6f61053f (2020-08-07); v6.0_rel predates it and is not affected.

txm_module_object_deallocate was marked deprecated after the most recent release, so no shipped version carries even that warning, and a compile-time message is not a runtime control in any case: a prebuilt module never sees it and the kernel honoured the request regardless.

No race, ISR context or disabled error checking is required.

Severity

CVSS v4.0 base score 8.5 (High) CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

VI:H and VA:H are unconditional and independent of memory reuse. VC:H rests on privileged control-flow capture rather than on a read primitive, and what is deliberately not claimed is a single-step read of memory at an address the module picks: the paths that reach the released block without authenticating it return a name-match result and a pool address rather than chosen memory, and a module cannot aim a queue’s message area at privileged memory because the create dispatcher requires that area to be writable by the module. If a reviewer rejects the reuse step, the vector becomes VC:L and the score 7.0, still high.

Patches

Fixed in ThreadX 6.5.2.202603. _txm_module_manager_object_deallocate now refuses to release memory that holds an object which is still created, and returns TX_DELETE_ERROR without touching the allocation, the object or the memory. Deleting the object first is what makes its memory releasable, which is the sequence the delete dispatchers already follow and the one module authors are told to follow. Storage that was never made into an object, and storage whose object has been deleted, are released exactly as before, so cleanup after a create that failed or was abandoned still works. The request number and the exported symbol are unchanged, so an already-built module still links and still runs, and now gets a deterministic error instead of a use-after-free.

The check sits in the manager function rather than in the dispatcher, because the dispatcher’s parameter block runs only for memory-protected modules and the internal post-delete release does not pass through it at all.

Liveness is answered from the kernel’s created lists, not from the control block. A control block ID is not evidence in either direction: an allocation that was never created can be carrying the value of an ID, so refusing on one would strand memory a module is entitled to have back, and objects of distinct types share a size, so an ID at a known object start is not even a type. Deallocation is given an address and nothing else, so each of the eight lists is searched in turn, each in its own interrupts-disabled window bounded by the count the kernel keeps beside it, and a list whose links have been damaged cannot make the search run on. The search is deliberately not narrowed by the size of the allocation, because that would be sound only if every object had been created through a size-checked path. A manager-owned lifecycle field in the header was considered and rejected for the same reason: it would record only what the manager was told.

Regression coverage models the object pool behind the manager and builds the two records the fix consults the way the system builds them. All eight object types are driven through allocate, create, a refused deallocation, delete, and a deallocation that succeeds, alongside storage never created, an object at the start of an oversized allocation, interior addresses, an application-owned object outside the pool, another module’s allocations, double release, and the bounds on the search. A substantial minority of the expectations fail against the unfixed implementation. The new functions and the changed deallocation reach 100% line and branch coverage, all 99 host tests pass in each of the five build configurations under GCC 14, and all changed files compile at -Werror for every GNU 32-bit module port.

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). The finding came from a local AI-agent source review configured and run by the maintainer, then validated against the source. The remediation, its regression test and the documentation change were produced with AI assistance under human review, in accordance with the Eclipse Foundation’s Generative AI Usage Guidelines.