Security note · ByteScan Security Research Lab

BSR-2026-FREERTOS-001
FreeRTOS-Kernel doesn’t check the task stack size for overflow before calling malloc

Project
FreeRTOS-Kernel (github.com/FreeRTOS/FreeRTOS-Kernel)
Where
tasks.c, prvCreateTask()
Tested on
main at commit 8be86d4
Type
Integer overflow leading to an undersized heap allocation (CWE-190, CWE-122)
CVE
Requested, nothing assigned yet
Status
Reported to AWS Security on 2026-10-14, waiting for a reply
Found by
Mohammadreza Ashouri

The short version

When you create a task, FreeRTOS works out how many bytes its stack needs by multiplying the stack depth by the size of one stack word, and passes the result to the allocator. Nothing checks that the multiplication fits in a size_t. With a large enough depth the result wraps around to something tiny, the allocator gives back a tiny block, and the rest of the kernel carries on as if it had received the stack it asked for.

Where it is

It’s in prvCreateTask(). There are two call sites, one for each stack growth direction (lines 1663 and 1681 on main), and they look the same:

pxNewTCB->pxStack = ( StackType_t * ) pvPortMallocStack(
    ( ( size_t ) uxStackDepth ) * sizeof( StackType_t ) );

Other places in the kernel that do size arithmetic before an allocation do guard it. xQueueGenericCreate() checks against SIZE_MAX, pvPortCalloc() in heap_4.c uses a multiply-overflow macro, and xStreamBufferGenericCreate() checks that the size plus the header doesn’t wrap. The task stack is the one that doesn’t.

An example

Take a 32-bit target with 4-byte stack words, which is an ordinary Cortex-M setup. The stack depth type defaults to the stack word type, so it’s 32 bits wide there, and 1,073,741,825 (0x40000001) is a legal value.

1,073,741,825 words × 4 bytes = 4,294,967,300 bytes
size_t is 32 bits, so it wraps:  4,294,967,300 − 4,294,967,296 = 4 bytes

The allocator is asked for 4 bytes and hands them over. The kernel still believes the task has a stack of over a billion words. The port then writes the task’s initial register frame at the top of that block, which is far bigger than 4 bytes, so it lands on whatever sits next to it on the heap. Once the task actually runs, it uses far more stack than it owns.

What I ran

I built the real heap_4.c and the exact expressions from tasks.c into a small test harness on a 64-bit host, using a minimal test port, so this is not a run on a real device. With a depth of 4611686018427387905 (262 + 1) and 4-byte stack words, the size wraps to 4 bytes and pvPortMalloc(4) succeeds. A simulated 64-byte initial frame, written down from the top of that block, starts 63 bytes before the block does.

I also looked at the configASSERT that runs after the stack frame is set up. It doesn’t catch this. It compares how much stack the frame used against the original depth, not against what was actually allocated, and a real frame is always small next to an overflowed depth.

How much does it matter?

Less than the mechanics might suggest. The stack depth is almost always a constant the firmware author writes, like 256 or 1024, so on a normal device nobody can choose it from outside. I’d call this a missing hardening check, not a remotely exploitable bug.

It starts to matter if an application computes the depth from some other value, or if a less trusted caller can supply it. I haven’t tested whether that’s possible through the MPU wrappers (MPU_xTaskCreate() passes the depth straight through), so I’m not claiming it.

Suggested fix

Reject a depth that would overflow before calling the allocator, the same way the other sites do, and treat it as an allocation failure:

if( uxStackDepth > ( SIZE_MAX / sizeof( StackType_t ) ) )
{
    /* too big, fail the task creation */
}
else
{
    pxNewTCB->pxStack = ( StackType_t * ) pvPortMallocStack(
        ( ( size_t ) uxStackDepth ) * sizeof( StackType_t ) );
}

Timeline

Credit

Found and reported by Mohammadreza Ashouri, ByteScan Security Research Lab.
audit@bytescan.net · bytescan.net · linkedin.com/in/drashouri