# Kernel-owned synchronization objects **Status:** shipped design. Every semaphore, mutex, and condition variable in the tree is kernel-owned by construction — there is no struct-based sync API to migrate away from; this document describes what ships, not a plan. Depends on the `TK_PRIVILEGED_DATA` placement established in [`kernel-ram-isolation.md`](kernel-ram-isolation.md); the confined caller reaching it is the syscall boundary described there and in [`per-thread-stack-isolation.md`](per-thread-stack-isolation.md). **Goal:** an unprivileged thread must not be able to corrupt a sync object it shares with another thread, nor use the sync API to make the kernel touch arbitrary memory. --- ## 1. Two holes a struct-based, app-owned design would have If `struct tk_semaphore`/`tk_mutex` lived in application RAM (the app declares `struct tk_semaphore s; tk_sem_init(&s, 0);` and passes `&s` to every call), two holes follow directly from confinement (`kernel-ram-isolation.md` §1): 1. **Field corruption.** `s` sits in the shared unprivileged-RW grant, so *any* thread — not just the ones the object is meant to coordinate — can write `s.count = 999` or scribble a wait-queue pointer, breaking synchronization for every legitimate user. 2. **Forged-pointer confused deputy.** A syscall dispatcher that resolves the object from an **app-supplied pointer** (`(struct tk_semaphore*)frame[1]`) and then operates on it privileged is a confused deputy: a hostile thread passes a pointer into `.privileged_bss` (or anywhere), and the kernel dereferences it with privilege — an arbitrary-kernel-write primitive that defeats the whole lockdown from `kernel-ram-isolation.md`, regardless of how tight that lockdown otherwise is. The root cause of both is the same: trusting an app-owned address. The shipped fix is that the kernel **owns** every sync object and hands the app only a **validated handle** — never a pointer. --- ## 2. The shape: kernel-owned pools + opaque integer handles `kernel/tk_sync.c:20-25` holds three fixed pools, each `TK_PRIVILEGED_DATA` (placed in `.privileged_bss` under full lockdown, ordinary `.bss` otherwise — same macro, same file, as `kernel-ram-isolation.md` §2): ```c static struct tk_semaphore g_sem_pool[TK_CONFIG_MAX_SEMAPHORES] TK_PRIVILEGED_DATA; static unsigned char g_sem_used[TK_CONFIG_MAX_SEMAPHORES] TK_PRIVILEGED_DATA; static struct tk_mutex g_mutex_pool[TK_CONFIG_MAX_MUTEXES] TK_PRIVILEGED_DATA; static unsigned char g_mutex_used[TK_CONFIG_MAX_MUTEXES] TK_PRIVILEGED_DATA; static struct tk_cond g_cond_pool[TK_CONFIG_MAX_CONDS] TK_PRIVILEGED_DATA; static unsigned char g_cond_used[TK_CONFIG_MAX_CONDS] TK_PRIVILEGED_DATA; ``` Three object kinds ship today — **semaphore, mutex, and condition variable** (`kernel/tk_sem.h`, `kernel/tk_mutex.h`, `kernel/tk_cond.h`). Condition variables did not exist when this mechanism was first designed; they were added on top of the identical pool/handle/resolve shape with no changes to the model — the strongest evidence that the shape generalizes. `tk_sem_create()` / `tk_mutex_create()` / `tk_cond_create()` allocate a free slot and return an **opaque handle**: `typedef int16_t tk_sem_t;` etc. (`kernel/tk_sem.h:11`, `kernel/tk_mutex.h:10`, `kernel/tk_cond.h:16`) — a small integer, not a pointer. `[setup]`-only (create before `tk_kernel_start`); there is no destroy, so pool exhaustion is a permanent-until-reset condition, reported once via `TK_DET` (§6) and `TK_SEM_INVALID`/`TK_MUTEX_INVALID`/`TK_COND_INVALID` (`0`). ### Why an integer index, not an opaque pointer A pointer-shaped handle (FreeRTOS-MPU style) still needs bounds-checking against the pool address range before use — a forged pointer that happens to land inside the pool would otherwise pass. An **index** is bounded by construction: `sem_resolve` rejects anything outside `[0, POOL_SIZE)` without any address arithmetic, and an index cannot alias anything outside its own pool no matter what value a hostile caller picks. ### Handle encoding: 1-based, 0 = invalid ```c /* kernel/tk_sync.c:28-34 */ struct tk_semaphore* tk_sem_resolve(tk_sem_t h) { if ((h > 0) && ((uint16_t)(h - 1) < TK_CONFIG_MAX_SEMAPHORES) && (g_sem_used[h - 1] != 0u)) { return &g_sem_pool[h - 1]; } TK_DET(TK_MOD_SYNC, 0u, TK_ERR_PARAM); return NULL; } ``` A handle is `index + 1`; `0` is invalid. This is what lets a **zero-initialized** handle (a global `tk_sem_t s;` never assigned, or a struct field left at its default) fail `h > 0` and resolve to `NULL` rather than silently aliasing slot 0 — no separate sentinel value needed. `tk_mutex_resolve` and `tk_cond_resolve` (`kernel/tk_sync.c:35-48`) are the same three-part check: positive, in-range, marked used. --- ## 3. The syscall boundary: the kernel resolves, never dereferences an app pointer Every blocking op has a direct-call body used when confinement is off, and — under `TK_FEAT_UNPRIV` — a syscall-routed variant that carries the **handle**, never a pointer, across the trap: ```c /* kernel/tk_sync.c:132-140 */ int tk_sem_pend_timeout(tk_sem_t s, uint32_t timeout_ms) { #if TK_FEAT_UNPRIV return (int)tk_syscall(TK_SYS_SEM_PEND, (uint16_t)s, timeout_ms); #else struct tk_semaphore* obj = tk_sem_resolve(s); return (obj != NULL) ? tk_sem_pend_timeout_impl(obj, timeout_ms) : 1; #endif } ``` `tk_sync_svc_dispatch()` (`kernel/tk_sync.c:308-330`, reached from `tk_svc_dispatch` — `kernel-ram-isolation.md` §3) resolves the handle **on the privileged side**, from the stacked syscall frame, exactly as the direct path does: ```c case TK_SYS_SEM_PEND: { struct tk_semaphore* o = tk_sem_resolve((tk_sem_t)frame[1]); frame[0] = (o) ? (uint32_t)tk_sem_pend_timeout_impl(o, frame[2]) : 1u; break; } ``` The kernel never receives, stores, or dereferences an app-supplied address for a sync object — only a small integer it bounds-checks itself, on both the syscall path and the direct path. That symmetry (`kernel/tk_kernel_internal.h:107-111` — `TK_SYS_SEM_PEND, TK_SYS_SEM_POST, TK_SYS_MUTEX_LOCK, TK_SYS_MUTEX_UNLOCK, ..., TK_SYS_COND_WAIT, TK_SYS_COND_SIGNAL, TK_SYS_COND_BROADCAST`) is what closes hole #2 from §1 without a special case for the unprivileged build — the resolver runs identically either way; only the call site (direct vs. `tk_syscall`) differs. `tk_cond_wait_timeout` packs **two** handles (condition variable + mutex) into one syscall argument word (`kernel/tk_sync.c:276`, `((uint32_t)(uint16_t)c << 16) | (uint16_t)m`) since a syscall carries only two register-width args — both still resolved server-side before use, same guarantee. --- ## 4. What this closes, concretely - **Field corruption (§1.1):** closed by placement, not validation — the pools live in `.privileged_bss`, which is exactly the region `kernel-ram-isolation.md` denies to unprivileged code. An unprivileged write anywhere in the pool's address range faults; this reuses that mechanism's proof, it doesn't need its own. - **Confused deputy (§1.2):** closed by `sem_resolve`/`mutex_resolve`/`cond_resolve` rejecting any handle that isn't a currently-used slot index. A forged handle — `0x7FFF`, or a small in-range but never-created index — is just an out-of-range or unused integer: `TK_DET` + `NULL`, never a wild pointer dereferenced with privilege. --- ## 5. Semantics unchanged by the ownership model FIFO wait queues; `post`/`unlock`/`signal` wake the longest waiter and yield to it immediately if it outranks the caller (`kernel/tk_sync.c:150-152,233-234,265-269` — the post-wake priority check); timeout variants (`*_timeout`, `0` = forever) return `0` acquired / `1` timed out; mutex priority inheritance is **single-level, non-transitive**, default protocol (`kernel/tk_sync.c:180-184`); recursive and error-check mutex types exist alongside the default normal type (`kernel/tk_mutex.h:18-22`); pools are fixed-size with **no destroy** — allocation is a one-shot setup-time operation in practice. None of this is new; the handle indirection sits underneath it, invisible to a caller who already used the pre-handle API shape. `tk_cond_wait`/`_timeout` (`kernel/tk_sync.c:247-286`) atomically release the held mutex and block — the release happens under the same kernel lock that resolves the wait, so there is no window where the mutex looks unlocked to another thread before the waiter is actually queued. --- ## 6. `TK_DET` — currently a placeholder at root Every rejection path in this document (`sem_resolve` failing, pool exhaustion on `create`) reports through `TK_DET(TK_MOD_SYNC, 0u, TK_ERR_PARAM)`. At root today, `TK_DET` is a **no-op placeholder**: ```c /* kernel/tk_det.h */ #define TK_DET(mod, id, err) ((void)0) ``` The module/error-id constants (`TK_MOD_SYNC`, `TK_ERR_PARAM`, ...) exist so call sites compile and read correctly, but nothing observes a rejected handle yet — a forged or exhausted handle is silently rejected (the caller still gets `NULL`/`TK_SEM_INVALID`/a timeout-shaped `1`), not reported. The richer `hal/tk_det.h` + `hal/tk_error.h` backend (hook + per-module/status counters) referenced by earlier design notes has not been ported into the reshaped `hal/` yet — the design described in this document (resolve, reject, report) is otherwise complete; only the "report" leg's backend is a stub. --- ## 7. Related - [`kernel-ram-isolation.md`](kernel-ram-isolation.md) — the `TK_PRIVILEGED_DATA` placement these pools rely on, and the syscall boundary (`tk_syscall`/`tk_svc_dispatch`) they route through. - [`per-thread-stack-isolation.md`](per-thread-stack-isolation.md) — the sibling mechanism that isolates stacks the way this document isolates sync state; together the two are "full lockdown." - [`../../safety-and-security.md`](../../safety-and-security.md) §2.3 (pending migration) — cites this design as part of what an unprivileged thread is denied under L3 confinement.