sched/task: nxtask_exit() frees the wrong TCB on non-SMP when a higher-priority task holds the ready-to-run head
- Dominant language
- C
- Stars
- 4k
- Forks
- 1.7k
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 237
Description
> **Disclaimer:** This report was prepared with AI assistance (Claude Code). I reviewed it, verified the code citations against master myself, and validated the fix on real hardware (deterministic `CONFIG_MM_FILL_ALLOCATIONS` A/B) before filing.
## Summary
On a **non-SMP** build, `nxtask_exit()` (`sched/task/task_exit.c`) identifies the
exiting task with `dtcb = this_task()` — the head of the ready-to-run list. If a
higher-priority task is scheduled to the ready-to-run head while the context
switch is still **deferred** (so the head no longer equals the task that is
actually running), `nxtask_exit()` calls `nxsched_remove_self(dtcb)` and
`nxsched_release_tcb(dtcb)` on that higher-priority task instead of on the task
that is actually exiting. This frees the TCB/stack of a **live** task — a
use-after-free that hard-faults / locks up the board.
The SMP path of the same function already avoids this by using the
actually-running task (`current_task(this_cpu())` = `g_assignedtasks[cpu]`); only
the non-SMP path uses the ready-to-run head.
## Affected code
`sched/task/task_exit.c`, `nxtask_exit()`
(https://github.com/apache/nuttx/blob/master/sched/task/task_exit.c#L75-L88)
```c
#ifdef CONFIG_SMP
dtcb = current_task(this_cpu()); /* running task (correct) */
#else
dtcb = this_task(); /* ready-to-run head (WRONG when head != running) */
#endif
...
nxsched_remove_self(dtcb); /* removes the wrong task */
ret = nxsched_release_tcb(dtcb, ...); /* frees the wrong task's TCB/stack */
```
Note: `this_task()` and `current_task(cpu)` are the *same* expression on non-SMP
(both `g_readytorun.head`), so simply dropping the `#ifdef` is a no-op. The
correct non-SMP accessor for the running task is `g_running_tasks[this_cpu()]`.
## Root cause
`this_task()` returns `g_readytorun.head`. NuttX already distinguishes the
ready-to-run head from the actually-running task — see the `running_task()`
macro and `g_running_tasks[]` (whose comment notes they "may differ during
interrupt-level context switches"). During a task's exit the two can diverge and
stay diverged into `nxtask_exit()`:
1. The task calls `exit()` → `_exit()` enters a critical section and runs
`nxtask_exithook()` (for a `CONFIG_BINFMT_LOADABLE` app this performs the full
module teardown: `binfmt_exit()` → `unload_module()` → `elf_unloadbinary()` →
`libelf_uninit()` → `up_textheap_free()`), then `up_exit()` → `nxtask_exit()`.
2. During that long, preemptible window a higher-priority task becomes ready, is
placed at the ready-to-run head and marked `TSTATE_TASK_RUNNING`, with the
register-level switch deferred. The exiting task is demoted to
`TSTATE_TASK_READYTORUN` but keeps executing; `g_running_tasks[cpu]` still
points at it.
3. `nxtask_exit()` reads `dtcb = this_task()` = the higher-priority task and
removes/frees **it**.
`up_exit()`'s own comment — "Destroy the task at the head of the ready to run
list" — encodes the assumption that the head *is* the exiting task, which step 2
violates.
## Steps to reproduce
1. Non-SMP target with the ELF loader (`CONFIG_ELF`, `CONFIG_BINFMT_LOADABLE`,
`CONFIG_LIBC_ELF`, `CONFIG_NSH_FILE_APPS`) plus at least one high-priority
kernel thread that can wake during the test (e.g. a Wi-Fi/driver worker).
2. Build a minimal relocatable ELF app — an empty `int main(void){return 0;}` is
sufficient; the crash is independent of app content.
3. `nsh> /path/to/app.elf`
4. The board hard-faults / locks up on task exit. Because it is a use-after-free
the crash is timing-dependent; enabling `CONFIG_MM_FILL_ALLOCATIONS` (which
poisons freed memory) makes it deterministic — the freed higher-priority TCB
is later restored with a corrupted/zeroed PC.
Expected: the app runs and exits; nsh returns to the prompt.
Actual: hard fault / lockup (a still-runnable task's TCB was freed).
## Evidence (captured on target with a minimal, no-syslog SRAM breadcrumb)
Recorded at the top of `nxtask_exit()` (raw word stores only, to avoid
perturbing the race): `this_task()`, `g_running_tasks[cpu]`, the chosen `dtcb`,
and their `task_state`s.
- Crashing app exit (app = pid 8, prio 100):
`dtcb = pid4 / prio224 / RUNNING` (a live high-prio Wi-Fi thread),
`g_running_tasks[0] = pid8 / prio100 / READYTORUN` (the exiting app) →
**dtcb != running: the wrong TCB is freed.**
- Fault registers on the deterministic (`MM_FILL`) crash: `CFSR=0x00020000`
(INVSTATE), `HFSR=0x40000000` (FORCED), stacked **PC=0x00000000**.
- Normal exits show `dtcb == running == head`, all `RUNNING`.
## Suggested fix
Use the actually-running task in the non-SMP path (as SMP already does):
```diff
#ifdef CONFIG_SMP
dtcb = current_task(this_cpu());
#else
- dtcb = this_task();
+ dtcb = g_running_tasks[this_cpu()];
#endif
```
`g_running_tasks[this_cpu()]` is updated only at real context switches, so it
stays correct when the ready-to-run head has been taken over by a deferred
higher-priority switch. Removing the correct task is safe: the exiting task is in
`TSTATE_TASK_READYTORUN` and present in the ready-to-run list, so
`nxsched_remove_readytorun()` takes its `dq_rem` path and the CPU then switches
to the pending higher-priority task. `nxtask_exit()` is the only exit-path site
that derives the exiting task from `this_task()` (`nxtask_terminate()` uses an
explicit pid), so this one line is the complete fix.
## Validation (on the affected target)
With `CONFIG_MM_FILL_ALLOCATIONS=y` so the use-after-free faults deterministically
and the only difference between builds is the one line:
1. Crash A/B: the pre-fix build hard-faults on the first ELF run; the fixed build
survives 20+ ELF load/run/exit cycles with the board healthy and no heap growth.
2. Breadcrumb: with the fix, in the exact divergence condition (head = the
high-prio thread) `dtcb` is now the exiting app (`dtcb == running`) and the
wrong-TCB condition never occurs.
## Related
Same failure family as #17418 / #17564 ("group released ahead of schedule"),
which address a different premature-release ordering; this issue is specifically
`nxtask_exit()` choosing the wrong `dtcb` via `this_task()` on non-SMP.
## Environment
- **NuttX version:** 12.13.0 (also present on master, checked 2026-07-02)
- **Architecture:** arm — Cortex-M33, RP2350, non-SMP (`CONFIG_SMP` not set, `CONFIG_BUILD_FLAT=y`)
- **Area:** Kernel / scheduler (`sched/task/task_exit.c`)
- **Relevant config:** `CONFIG_ELF=y`, `CONFIG_BINFMT_LOADABLE=y`, `CONFIG_LIBC_ELF=y`, `CONFIG_NSH_FILE_APPS=y`
- **Toolchain / host:** arm-none-eabi-gcc 13.2.1; Ubuntu 24.04.4 LTS (Linux 6.17.0-35-generic)
- Reproduced on a local raspberrypi-pico-2-w port, but the defect is in generic scheduler code and applies to any non-SMP configuration with a long/preemptible exit path (e.g. `CONFIG_BINFMT_LOADABLE`) and a higher-priority ready task.
Contributor guide
Research direction
Start in sched/task/task_exit.c at nxtask_exit(), then trace this_task(), current_task(), running_task(), and g_running_tasks[] to understand the distinction between the ready-to-run head and the running task. Reproduce with the listed ELF/loadable-app configuration and CONFIG_MM_FILL_ALLOCATIONS enabled. Done means repeated ELF load/run/exit cycles return to the nsh prompt without freeing a live task or faulting.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100