Skip site navigation (1)Skip section navigation (2)
Date:      Thu, 11 Jun 2026 09:13:23 +0000
From:      bugzilla-noreply@freebsd.org
To:        bugs@FreeBSD.org
Subject:   [Bug 295991] lib/libc posix_spawnp(): PATH-search child runs on an undersized rfork_thread stack and underflows into the caller's heap (heavily-linked processes; PHP proc_open)
Message-ID:  <bug-295991-227@https.bugs.freebsd.org/bugzilla/>

index | next in thread | raw e-mail

https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=295991

            Bug ID: 295991
           Summary: lib/libc posix_spawnp(): PATH-search child runs on an
                    undersized rfork_thread stack and underflows into the
                    caller's heap (heavily-linked processes; PHP
                    proc_open)
           Product: Base System
           Version: 15.0-RELEASE
          Hardware: amd64
                OS: Any
            Status: New
          Severity: Affects Only Me
          Priority: ---
         Component: bin
          Assignee: bugs@FreeBSD.org
          Reporter: kernel-error@kernel-error.com

## Description

On amd64, `do_posix_spawn()` runs the spawn child via
`rfork_thread(RFSPAWN, stack + stacksz, _posix_spawn_thr, &psa)` on a small
`malloc`'d
buffer (`lib/libc/gen/posix_spawn.c`):

- `_RFORK_THREAD_STACK_SIZE` = 4096 (`:240`)
- for a PATH-searched (relative) command, `stacksz = 4096 + MAX(3,
argc+2)*sizeof(char*)`,
  16-byte aligned (`:285`, `:295-298`) — e.g. 4128 bytes for `argv = {"true",
NULL}`
- `stack = malloc(stacksz)` (`:308`); the child is run on it (`:341`)
- with `use_env_path` set, the child calls `__libc_execvpe()` (`:264`), i.e.
the PATH search

`RFSPAWN`/`rfork_thread` gives the child a **shared address space** (vfork
semantics) until
it execs. In a process that has **many shared objects loaded**, the spawn
child's stack
usage while running `__libc_execvpe()` exceeds this ~4 KB buffer and
**underflows it**,
scribbling over whatever heap sits below the buffer in the shared parent
address space. The
caller then crashes or misbehaves later, far from `posix_spawn`.

This is **distinct from CVE-2020-7458 / SA-20:18** (that was a *long $PATH*
overflow, fixed
in 2020). The problem here is **independent of $PATH length** — it reproduces
with a single
PATH entry — and is driven by how much stack `__libc_execvpe` uses in a
heavily-linked
process, against a fixed ~4 KB child stack.

## Real-world impact (PHP) — likely the cause behind PR 277888

PHP's `proc_open()` with an array (argv) command calls `posix_spawnp()` with a
relative
program name. On FreeBSD with a normal set of PHP extensions loaded:

```
pkg install php84 <plus a typical set of extensions: dom intl pdo pdo_pgsql
pgsql
                   session simplexml sodium xml xmlwriter zip imagick memcached
redis ...>
php -r 'proc_open(["true"], [], $p);'      # → Segmentation fault (~100%)
```

The crash itself surfaces much later, at PHP module shutdown (in `_efree`/
`zend_interned_strings_dtor`), because the underflow had overwritten the header
of a
permanent interned string back during the `proc_open` call.

Tellingly, it depends on the spawn path, not on PHP logic:

| call | spawn path | crash |
|---|---|---|
| `proc_open(["true"], …)` (relative) | `posix_spawnp` → `__libc_execvpe` (PATH
search) | yes |
| `proc_open(["/usr/bin/true"], …)` (absolute) | `posix_spawnp` → `execvPe`
direct branch | no |
| `proc_open("true", …)` (string) | `posix_spawn` (`/bin/sh -c`) → `_execve` |
no |

This very likely explains the `proc_open(["date"], $p)` reproducer discussed in
**PR 277888**
("lang/php83 and lang/php84: segmentation fault on unloading modules"). That PR
was closed
FIXED via an `ext/xsl` change, yet its own comments report that the
`proc_open(["date"], $p)`
repro still segfaults on 14.2-RELEASE-p4 / php 8.4.10 / php 8.3.21 — i.e. that
part appears
mis-attributed; the cause looks to be here in libc. (Upstream PHP tracking:
php/php-src#21995.)

## Evidence that it is the spawn child stack

Interpose `rfork_thread()` via `LD_PRELOAD` and give the spawn child a
different stack
(everything else in libc unchanged; PHP heap layout unchanged because libc
still does its
own `malloc(stacksz)`):

- child stack = **1 MB** → the PHP crash disappears: **0/30** vs **30/30**
baseline.
- child stack = **~4 KB with a PROT_NONE guard page just below it** → the spawn
child dies
  with **SIGSEGV** (it grows down into the guard page). Because the guard sits
immediately
  below the child stack, this is strong, **location-independent** evidence that
the child
  exhausts its stack downward on this path (it rules out the "maybe it only
mattered what was
  adjacent below the stack" confound). It does not by itself pin down which
frame overflows
  (`execvPe` / `_execve` / run-time-linker work / the `rfork_thread`
trampoline).

Minimal interposer (essence):

```c
pid_t rfork_thread(int flags, void *stack, int (*fn)(void*), void *arg) {
    long pg = sysconf(_SC_PAGESIZE);
    size_t sz = GUARDSPAWN_KB*1024;                 /* 1024 -> fixes it; 4 ->
child SIGSEGV */
    char *b = mmap(0, pg+sz, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE, -1,
0);
    mprotect(b, pg, PROT_NONE);                     /* guard page below the
child stack */
    return real_rfork_thread(flags, b+pg+sz, fn, arg);
}
```

Note: a *minimal* standalone program calling `posix_spawnp("true", …)` does
**not**
underflow the 4 KB stack — the excess usage only appears once enough shared
objects are
loaded (so it bites real applications like PHP, not toy reproducers). I could
not pin down
*why*; a plausible (unproven) hypothesis is run-time-linker work on the child's
small stack
in the shared address space.

The recent commit 4daf2d3e7db5 ("posix_spawn: use rfork_thread on all arches",
main,
2026-01) does not change `_RFORK_THREAD_STACK_SIZE`; it uses `rfork_thread()`
on all arches
but the ~4 KB custom child stack remains the x86 path (other arches set `stack
= NULL;
stacksz = 0`). So current `main` still has this on x86/amd64.

## Suggested fix (maintainers' call)

Size the spawn child stack adequately for `__libc_execvpe` under realistic load
(the
`stacksz` reservation currently only accounts for the ENOEXEC `alloca`), and/or
place a
guard page below it so an underflow faults deterministically instead of
silently corrupting
the caller's heap.

---

*Disclosure: this analysis was produced with LLM assistance. All stacks,
traces, repro
counts, source references and the interposer experiment are from real runs on
FreeBSD
15.0-RELEASE amd64; the interpretation is the part worth double-checking.*

-- 
You are receiving this mail because:
You are the assignee for the bug.

home | help

Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?bug-295991-227>