Merge commit from fork

A memory-protected module names the kernel objects it wants operated on by
address, and the Module Manager decided whether a privileged service could
dereference that address by asking only whether it fell outside the module. The
object pool is outside every module, so that test was satisfied by an address
shifted into the interior of one of the module's own privileged allocations,
which denotes no object at all. The bytes such an address presents as a control
block are bytes the module put there through ordinary create and set services,
so the control block ID at the front of them could be made to read as any type
the module chose, and the _txe_ layer's ID test then agreed. A module could
therefore have the kernel read and write fields of an object that does not
exist, at an address it picked; the reported chain reaches a privileged memset
across an attacker-chosen range that way.

Authentication now answers the question the location test could not: is this the
exact address of a live kernel object of the type this service expects. It is
answered from the kernel's created list for the type, which the create and delete
services maintain, so membership establishes at once that the address is an
object start rather than an address inside an object, that the object is of this
type, and that it has not been deleted. That list is also what makes an object
the application created and shared with a module authenticable at all, since the
manager allocated no such object and has no record of its own to consult, so
sharing keeps working and needs no new registration API.

Nothing a module can influence is used to establish a type. An earlier form of
this fix accepted an address that was the exact start of one of the calling
module's own allocations of the right size and then took the type from the
control block ID, which is cheaper -- a module's allocation list is much shorter
than the system's created list for a type. The tests here refused it: a byte
pool, a mutex and a timer are all the same size on a 32-bit target, so an
allocation created as one of them and presented as another passed both the
address and the size test, leaving the ID as the only thing between the module
and a type confusion. The ID is still checked, after the created list has settled
the question, because it is the test the _txe_ services make and it is compiled
away with them under TX_DISABLE_ERROR_CHECKING. An address a module named is not
read at all until a kernel record says there is an object there.

All 58 object-using dispatchers now pass the object type rather than a control
block size, since a size cannot establish a type. Both scans are bounded by the
counts the kernel and the manager maintain beside their lists, so the cost of one
check is bounded and a list whose links have been damaged cannot make a scan run
on, and both run with interrupts disabled, which is the protection those lists
are maintained under and which adds no blocking point or priority inversion to a
kernel request. The cost is a walk of the created list for the type, paid only by
memory-protected modules; the size-based check is kept for dispatchers outside
this repository and hardened to refuse object pool interiors, which is the part
of the attack it can see without a type.

Two further changes close paths the authentication alone would leave open. Thread
reset now refuses a thread that carries no module instance: such a thread is
authentic, can be found by name and satisfies reset's own state test, and reset
reads the shell entry function out of that instance, so a null one was followed
in privileged mode. Object deallocation now clears the control block ID as it
gives memory back, so an object freed without being deleted first stops being
vouched for at the moment the manager stops owning its memory, rather than
returning to the pool still carrying a valid ID for the next allocation placed
there to present.

The 120 expectations in the new test offer every aligned interior offset of a
legitimate object as a foreign type, with that type's own ID planted at the
offset, and assert that none is accepted; they cover all eight object types
against each other, uncreated allocations, deleted objects, freed addresses that
have been handed out again, objects the application owns and memory that merely
carries a plausible ID, objects another module allocated, and the bounds on both
scans. Reverting the object checks to the permissive policy fails 89 of them;
removing the thread reset guard makes the test die with SIGSEGV inside the reset
path; removing the ID clearing in deallocation fails 2. All 99 tests pass in each
of the five configurations the tree builds with GCC 14.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
This commit is contained in:
Frédéric Desbiens
2026-09-28 09:08:55 -04:00
committed by GitHub
parent d55fe1ef2f
commit 7495b02374
7 changed files with 2098 additions and 61 deletions
@@ -9,6 +9,8 @@
* SPDX-License-Identifier: MIT
**************************************************************************/
// Some portions generated by Claude Code (Opus 5).
/**************************************************************************/
/**************************************************************************/
@@ -125,6 +127,27 @@ UINT return_value;
}
}
/* Clear the first word of the object being given back, which for every kernel
object is its control block ID.
Object authentication reads that ID to establish the type of an object and
that it is still created, having first established that the address is the
exact start of an allocation. The kernel clears the ID when it deletes an
object, so the ordinary sequence of delete and then deallocate has already
cleared it. What has not is a deallocation of an object that was never
deleted: the memory returns to the pool still carrying a valid ID, and the
next allocation to be placed there is a raw allocation that presents one.
Clearing it here means the manager stops vouching for a control block at
the moment it stops owning the memory, whatever order the module chose.
An allocation too small to hold an ID cannot be presenting one, and is
left alone rather than written past its end. */
if (module_allocated_object_ptr -> txm_module_object_size >= ((ULONG) sizeof(ULONG)))
{
*((ULONG *) object_ptr) = TX_CLEAR_ID;
}
/* Release the object memory. */
return_value = (ULONG) _txe_byte_release((VOID *) module_allocated_object_ptr);
}
@@ -9,6 +9,8 @@
* SPDX-License-Identifier: MIT
**************************************************************************/
// Some portions generated by Claude Code (Opus 5).
/**************************************************************************/
/**************************************************************************/
@@ -106,6 +108,20 @@ TXM_MODULE_THREAD_ENTRY_INFO *thread_entry_info;
status = TX_NOT_DONE;
}
}
/* Resetting a thread means rebuilding its stack around a module's shell entry
function, so the thread has to be one a module manager created. A thread the
application created carries no module instance, and the fields this function
goes on to read from it -- the shell entry function it builds the new stack
frame around -- would be read through a null pointer in privileged mode. A
module can name such a thread: any thread the system created can be found by
name, and one that has run to completion satisfies the state test above. */
if (thread_ptr -> tx_thread_module_instance_ptr == TX_NULL)
{
/* Not a module thread, so there is nothing here to reset. */
status = TX_NOT_DONE;
}
}
/* Is the request valid? */
File diff suppressed because it is too large Load Diff