Skip to content

sched/task: nxtask_exit() frees the wrong TCB on non-SMP when a higher-priority task holds the ready-to-run head #19308

Description

@ricardgb

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():

  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_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:

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions