The Calysteon Corner

A Guide to the Cyber Galaxy

Home

CVE-2025-43295: A Stack Exhaustion in Libc’s posix_spawnp()

Apple Security Advisory: macOS Tahoe 26


Introduction

This is a stack-based buffer overflow I found in Libc’s posix_spawnp(), in sys/posix_spawn.c. The function sizes a stack allocation from the length of the caller’s PATH and never bounds it.

posix_spawnp() is the variant of posix_spawn() that searches the PATH. When given a program name without a slash, it walks each directory in PATH looking for a match. To do that, it makes a private, writable copy of the PATH string so it can split it apart at the colons. I was reading through how that copy is made when the sizing of the allocation caught my attention.

The problem is where that copy lives, and how big it is allowed to get. Whatever length PATH happens to be is how much stack posix_spawnp() will try to claim, in a single move.

The Vulnerability

Below is the relevant code in sys/posix_spawn.c:

100     if (*file == '\0')
101             return (ENOENT);
102
103     if ((cur = alloca(strlen(env_path) + 1)) == NULL)
104             return ENOMEM;
105     strcpy(cur, env_path);

The buffer is allocated with alloca(strlen(env_path) + 1), and env_path is the raw value of the caller’s PATH. alloca() does not allocate on the heap; it takes space directly out of the current stack frame by moving the stack pointer. There is no upper bound applied to strlen(env_path) here. The size of the allocation is whatever the length of PATH is, and PATH is attacker-influenced. Once the space is claimed, strcpy(cur, env_path) copies the whole string into it.

Root Cause

Two things go wrong on these two lines, and together they leave nothing between a large PATH and a stack that has run off its guard page.

First, the allocation is unbounded. alloca() moves the stack pointer by exactly the requested size, and that size comes straight from strlen(env_path). A large enough PATH moves the stack pointer past the end of the mapped stack, and the next access faults.

Second, the safety check is dead code. The code tests alloca(...) against NULL and returns ENOMEM on failure, but alloca() does not report failure by returning NULL. When the requested size exceeds the remaining stack, alloca() does not return a null pointer; it simply moves the stack pointer too far. The ENOMEM guard can therefore never fire, so there is no check standing between the caller’s PATH and an exhausted stack.

This is reachable by any process that calls posix_spawnp() while inheriting a large PATH. That includes a lot of code that would not normally be audited, because posix_spawnp() is used throughout the system by helpers that fork child processes with an inherited environment.

Reproduction

Conceptually, the trigger is short. There is no packet to craft and no handshake to negotiate. We only need to make PATH large and then call posix_spawnp(). Build a large PATH (on the order of 10 MB), install it into the environment, and call posix_spawnp to launch a program such as ls. The oversized PATH forces the alloca(strlen(env_path) + 1) inside posix_spawnp to request more stack than exists. Running such a program on macOS 15.5 (Build 24F74) gives the following crash:

calysteon@Charbyte spawnp % ./poc
poc(15606,0x1f5c59f00) malloc: nano zone abandoned due to inability to reserve vm space.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==15606==ERROR: AddressSanitizer: SEGV on unknown address 0x00016b427ff8 (pc 0x000187b45bc0 bp 0x00016bc22730 sp 0x00016bc22210 T0)
==15606==The signal is caused by a READ memory access.
==15606==WARNING: failed to spawn external symbolizer (errno: 0)
==15606==WARNING: failed to spawn external symbolizer (errno: 0)
==15606==WARNING: failed to spawn external symbolizer (errno: 0)
==15606==WARNING: failed to spawn external symbolizer (errno: 0)
==15606==WARNING: failed to spawn external symbolizer (errno: 0)
==15606==WARNING: Failed to use and restart external symbolizer!
    #0 0x000187b45bc0 in ___chkstk_darwin+0x3c (/usr/lib/system/libsystem_pthread.dylib:arm64e+0x1bc0)
    #1 0x0001046e46bc in int PosixSpawnImpl<int (int*, char const*, void const*, void const*, char* const*, char* const*)>(void*, int (*)(int*, char const*, void const*, void const*, char* const*, char* const*), int*, char const*, void const*, void const*, char* const*, char* const*)+0x884 (/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/17/lib/darwin/libclang_rt.asan_osx_dynamic.dylib:arm64e+0x206bc)
    #2 0x0001046e4a88 in posix_spawnp+0x74 (/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/17/lib/darwin/libclang_rt.asan_osx_dynamic.dylib:arm64e+0x20a88)
    #3 0x0001041dca8c in main+0x234 (/Users/calysteon/Library/Mobile Documents/com~apple~CloudDocs/Analysis/Reports/2 - valid/libc/spawnp/poc:arm64+0x100000a8c)
    #4 0x0001877aab94 in start+0x17b8 (/usr/lib/dyld:arm64e+0xfffffffffff3ab94)

==15606==Register values:
 x[0] = 0x0000000000a00000   x[1] = 0x000000000013ffff   x[2] = 0x0000000000000005   x[3] = 0x0000000187b8192b  
 x[4] = 0x000000016bc23080   x[5] = 0x000000016bc237f8   x[6] = 0x000000016bc23080   x[7] = 0x000000016bc237f8  
 x[8] = 0x0000000000000001   x[9] = 0x0000000000a00001  x[10] = 0x000000016b22220f  x[11] = 0x000000016b428000  
x[12] = 0x0000007020f61900  x[13] = 0x0000000000027fff  x[14] = 0x0000000000027ff8  x[15] = 0x0000000187a59403  
x[16] = 0x0000000187b45b84  x[17] = 0x00000001f6cc4bd0  x[18] = 0x0000000000000000  x[19] = 0x000000016bc22210  
x[20] = 0x000000002d7846ff  x[21] = 0x0000000000000000  x[22] = 0x000000002d784718  x[23] = 0x0000000000000000  
x[24] = 0x000000016bc23080  x[25] = 0x00000001041dcc60  x[26] = 0x000000010700c805  x[27] = 0x000000016bc238c0  
x[28] = 0x000000000000006d     fp = 0x000000016bc22730     lr = 0x0000000187a0c460     sp = 0x000000016bc22210  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/usr/lib/system/libsystem_pthread.dylib:arm64e+0x1bc0) in ___chkstk_darwin+0x3c
==15606==ABORTING
zsh: abort      ./poc

The most relevant frame in that trace is the top one: ___chkstk_darwin. This is the stack-probing routine the compiler inserts because a large alloca() can skip over the stack’s guard page in a single jump. Its job is to walk down through the requested allocation one page at a time so that any overflow lands on the guard page instead of writing into whatever lives beyond the stack. Here it is doing exactly that: it probes into unmapped memory, faults with a SEGV, and the process dies inside PosixSpawnImpl and posix_spawnp, before ls is ever launched. The crash occurs at the moment of allocation, not the moment of use. In practice, a local user can hand a large PATH to any set-uid or otherwise privileged helper that calls posix_spawnp() with an inherited environment and crash it on demand.