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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions