Skip to content

Fix CMSIS timer callback use-after-free on delete - #198

Open
Zepp-Hanzj wants to merge 2 commits into
ARM-software:mainfrom
Zepp-Hanzj:fix/cmsis-timer-delete-uaf
Open

Fix CMSIS timer callback use-after-free on delete#198
Zepp-Hanzj wants to merge 2 commits into
ARM-software:mainfrom
Zepp-Hanzj:fix/cmsis-timer-delete-uaf

Conversation

@Zepp-Hanzj

Copy link
Copy Markdown

Summary

Defer freeing the dynamically allocated CMSIS TimerCallback_t until the
FreeRTOS timer daemon actually processes tmrCOMMAND_DELETE.

Problem observed in the field

This fixes a real failure that we encountered on an embedded product; it is
not only a theoretical race. We observed intermittent HardFaults/system
crashes in code using CMSIS-RTOS2 timers. The failures were most reproducible
in these situations:

  • stopping and deleting a timer close to its expiry time;
  • creating a timer and deleting it immediately; and
  • deleting timers while the timer daemon command queue had a backlog.

Instrumentation of the timer daemon and heap showed the following sequence:

  1. osTimerDelete() calls xTimerDelete().
  2. xTimerDelete() returns pdPASS after placing tmrCOMMAND_DELETE in the
    timer command queue. The timer has not necessarily been deleted yet.
  3. The current osTimerDelete() immediately frees the dynamically allocated
    TimerCallback_t referenced by pvTimerID.
  4. Before the daemon handles the delete command, the timer can expire and
    TimerCallback() dereferences the already freed callback context.

Depending on whether the freed block had already been reused, this produced a
use-after-free, an invalid function/argument access, and eventually a
HardFault or system hang.

Root cause

FreeRTOS software timer commands are asynchronous. A successful return from
xTimerDelete() means that the delete command was queued; it does not mean
that the timer daemon has processed it. The callback context therefore has to
remain alive until tmrCOMMAND_DELETE is handled.

Fix

  • Add a timer-delete completion callback invoked by the timer daemon while the
    timer control block, callback function, and pvTimerID are still valid.
  • Register a CMSIS cleanup callback during osKernelInitialize().
  • Release TimerCallback_t only when the deleted timer uses the CMSIS
    TimerCallback trampoline and its pvTimerID carries the dynamic-allocation
    flag.
  • Preserve the existing heap_1 behavior, and do not free callback contexts
    belonging to native FreeRTOS timers or statically allocated CMSIS timers.

This avoids using a second queued cleanup command. Such an approach is not
atomic with xTimerDelete() and can fail when the timer queue has only one
free slot.

Validation

  • Built the repository's FreeRTOS Kernel CMake example successfully with GCC,
    including the modified timers.c.
  • On the affected product, daemon instrumentation confirmed that callback
    context is retained until delete processing completes.
  • Verified normal system startup and a timer-heavy weather application after
    applying the fix.

@Zepp-Hanzj

Copy link
Copy Markdown
Author

Hardware A/B validation on STM32F407ZG (Cortex-M4F)

We built a dedicated on-target validation firmware to prove both (a) the bug is real and deterministic under the old behavior, and (b) this fix removes it. All numbers below are from a real board over UART.

Phase A - old osTimerDelete() behavior (immediate free of callback context)

The firmware reproduces the unsafe free exactly as the old wrapper does: start the timer, let the daemon arm it, then free and immediately reuse (poison) the TimerCallback_t while the timer is still armed and can still expire.

PASS phase=baseline-done baseline=32 uaf=32 poison_cb=32 reuse_fail=0 ... errors=0
  • 32/32 iterations triggered the use-after-free (poisoned callback executed) -> 100% deterministic reproduction of the field failure mode.

Phase B - fixed behavior (daemon delete callback owns the free)

Same board, same timer churn, with the daemon delete callback registered:

PASS phase=fixed-stress ... fixed=8300 live_cb=8300 delete_cb=8300 native_skip=32 ... errors=0
  • uaf stays frozen at the 32 baseline count: no new use-after-free in 8300+ stress loops.
  • live_cb == delete_cb (both 8300): the callback context stays alive until the daemon actually processes tmrCOMMAND_DELETE, then is freed exactly once.
  • native_skip=32 with unexpected=0: native FreeRTOS timer pvTimerID values are never freed by the CMSIS cleanup path.
  • heap flat at 61568 bytes over 8300+ loops, min_heap stable: no leak, no double free, no heap corruption.
  • No HardFault, no assert, no stack overflow across the whole run.

Bottom line

The old wrapper's use-after-free is deterministically reproducible on hardware (32/32), and the fixed daemon-delete-callback path shows zero UAF across a long stress run with exact once-only freeing, native-timer safety, and stable heap. This is a real product failure (intermittent HardFaults/crashes), not a theoretical race.

@jkrech jkrech added the safety & security This issues is safety and/or security related label Aug 12, 2026
@Zepp-Hanzj

Copy link
Copy Markdown
Author

The corresponding FreeRTOS-Kernel change has now been submitted as FreeRTOS/FreeRTOS-Kernel#1469. This CMSIS wrapper fix depends on the timer-delete completion callback introduced there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug safety & security This issues is safety and/or security related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants