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)
#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():
- 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().
- 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.
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
- 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).
- Build a minimal relocatable ELF app — an empty
int main(void){return 0;} is
sufficient; the crash is independent of app content.
nsh> /path/to/app.elf
- 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_states.
- 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):
#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:
- 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.
- 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.
Summary
On a non-SMP build,
nxtask_exit()(sched/task/task_exit.c) identifies theexiting task with
dtcb = this_task()— the head of the ready-to-run list. If ahigher-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()callsnxsched_remove_self(dtcb)andnxsched_release_tcb(dtcb)on that higher-priority task instead of on the taskthat 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]); onlythe 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)
Note:
this_task()andcurrent_task(cpu)are the same expression on non-SMP(both
g_readytorun.head), so simply dropping the#ifdefis a no-op. Thecorrect non-SMP accessor for the running task is
g_running_tasks[this_cpu()].Root cause
this_task()returnsg_readytorun.head. NuttX already distinguishes theready-to-run head from the actually-running task — see the
running_task()macro and
g_running_tasks[](whose comment notes they "may differ duringinterrupt-level context switches"). During a task's exit the two can diverge and
stay diverged into
nxtask_exit():exit()→_exit()enters a critical section and runsnxtask_exithook()(for aCONFIG_BINFMT_LOADABLEapp this performs the fullmodule teardown:
binfmt_exit()→unload_module()→elf_unloadbinary()→libelf_uninit()→up_textheap_free()), thenup_exit()→nxtask_exit().placed at the ready-to-run head and marked
TSTATE_TASK_RUNNING, with theregister-level switch deferred. The exiting task is demoted to
TSTATE_TASK_READYTORUNbut keeps executing;g_running_tasks[cpu]stillpoints at it.
nxtask_exit()readsdtcb = this_task()= the higher-priority task andremoves/frees it.
up_exit()'s own comment — "Destroy the task at the head of the ready to runlist" — encodes the assumption that the head is the exiting task, which step 2
violates.
Steps to reproduce
CONFIG_ELF,CONFIG_BINFMT_LOADABLE,CONFIG_LIBC_ELF,CONFIG_NSH_FILE_APPS) plus at least one high-prioritykernel thread that can wake during the test (e.g. a Wi-Fi/driver worker).
int main(void){return 0;}issufficient; the crash is independent of app content.
nsh> /path/to/app.elfthe crash is timing-dependent; enabling
CONFIG_MM_FILL_ALLOCATIONS(whichpoisons 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 avoidperturbing the race):
this_task(),g_running_tasks[cpu], the chosendtcb,and their
task_states.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.
MM_FILL) crash:CFSR=0x00020000(INVSTATE),
HFSR=0x40000000(FORCED), stacked PC=0x00000000.dtcb == running == head, allRUNNING.Suggested fix
Use the actually-running task in the non-SMP path (as SMP already does):
g_running_tasks[this_cpu()]is updated only at real context switches, so itstays 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_READYTORUNand present in the ready-to-run list, sonxsched_remove_readytorun()takes itsdq_rempath and the CPU then switchesto the pending higher-priority task.
nxtask_exit()is the only exit-path sitethat derives the exiting task from
this_task()(nxtask_terminate()uses anexplicit pid), so this one line is the complete fix.
Validation (on the affected target)
With
CONFIG_MM_FILL_ALLOCATIONS=yso the use-after-free faults deterministicallyand the only difference between builds is the one line:
survives 20+ ELF load/run/exit cycles with the board healthy and no heap growth.
high-prio thread)
dtcbis now the exiting app (dtcb == running) and thewrong-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 wrongdtcbviathis_task()on non-SMP.Environment
CONFIG_SMPnot set,CONFIG_BUILD_FLAT=y)sched/task/task_exit.c)CONFIG_ELF=y,CONFIG_BINFMT_LOADABLE=y,CONFIG_LIBC_ELF=y,CONFIG_NSH_FILE_APPS=yCONFIG_BINFMT_LOADABLE) and a higher-priority ready task.