BudgetScan

Stale child signals and PID reuse can corrupt child process tracking

medium swesweep-fasthttp · prefork/prefork.go · Prefork.prefork · run #4

Description

Child exit signals are buffered in sigCh and processed asynchronously. If a replacement child is spawned before a stale signal is processed and the OS reuses that child PID, childProcs[newPid] can overwrite a stale map entry. When the stale signal is later processed, delete(childProcs, pid) may remove the active replacement child, causing the master to lose track of it. The deferred kill may then fail to terminate the orphaned process, and recovery accounting can be incorrect. An attacker who can repeatedly crash child processes, for example through malformed HTTP causing a handler panic or ServeFunc to return, may increase the likelihood of PID reuse and orphaned child processes.

Triage

Verdict
INVALID confidence 0.0
Model
qwen/qwen3.8-27b
Rounds
5
Cost
$0.0197 · 31.6 s
The reported vulnerability posits a race condition where a 'stale' exit signal in the buffered `sigCh` channel could be processed after a replacement child process (potentially reusing the same PID) has been registered in the `childProcs` map, leading to incorrect map state and orphaned processes. To verify this, I must analyze the concurrency model of the `prefork` master process. The code in `prefork.go` shows that child process management is handled entirely within the `prefork` method. The initial spawning loop runs sequentially. Subsequently, the master enters a single loop: `for sig := range sigCh`. Inside this loop, the following steps occur strictly sequentially in the same goroutine: 1) Receive a signal from `sigCh`, 2) Execute `delete(childProcs, sig.pid)`, 3) Spawn a new child via `p.doCommand()`, and 4) Execute `childProcs[newPid] = cmd`. Because these operations are performed by a single goroutine without concurrent access to the `childProcs` map or the `sigCh` processing logic from other master-side goroutines, it is impossible for a signal for PID X to be processed *after* a new child with PID X has been added. If PID X is reused, the sequence is: Process Exit Signal X -> Remove X from Map -> Spawn New Child (gets PID X) -> Add X to Map. The 'stale signal' is the one just processed. There is no window where a second, separate signal for X could interfere because signals are generated by `Wait()` goroutines that block until the process exits, and the processing is serialized. The `childProcs` map is only read and written by this single loop goroutine. Therefore, the specific race condition described does not exist. The code is safe from this specific corruption pattern because the event loop is single-threaded and sequential with respect to map state updates.

CRUX: The child process management logic in the prefork master runs in a single sequential loop, ensuring that map deletions and insertions for specific PIDs are atomic with respect to signal processing order, preventing the described race.
GREP: for sig := range sigCh
All rounds
[
  {
    "finding_title": "Stale child signals and PID reuse can corrupt child process tracking",
    "verdict": "INVALID",
    "reasoning": "The report describes a potential race condition involving stale PID signals in the `prefork` master process. Specifically, it claims that if a child process exits, a new one is spawned, and the OS reuses the PID of the exited child for the new one, the asynchronous signal processing might delete the new (active) child from the `childProcs` map. However, a review of the code in `prefork/prefork.go` reveals that the master process is strictly single-threaded regarding the management of child processes. The `prefork` function spawns children in a loop and then enters a single `for sig := range sigCh` loop. Inside this loop, it processes one signal at a time: it deletes the PID from the map, checks the recovery threshold, spawns a new child if necessary, and updates the map. Because there is only one thread executing this loop, it is impossible for a 'stale' signal to be processed *after* a new child with the same PID has been registered in the map. The sequence is strictly: receive signal for PID X -> delete PID X from map -> spawn new child (which gets a new PID, potentially Y). If PID X were reused for the new child, the deletion would have already happened *before* the new child was added. More importantly, PIDs are unique among running processes. If the old child with PID X has exited, PID X is free. The new child might be assigned PID X. In the loop: `delete(childProcs, sig.pid)` (removes old entry for X). Then `newPid := cmd.Process.Pid` (could be X). Then `childProcs[newPid] = cmd` (adds new entry for X). There is no race because the map operation and the spawn happen sequentially in the same goroutine. The `sigCh` is just a notification mechanism; the processing logic is sequential. There is no concurrent modification of `childProcs` by other goroutines. Therefore, the described scenario of a stale signal overwriting or deleting an active replacement due to concurrency does not exist. The map is only accessed by the single loop goroutine. The 'stale signal' concern is invalid because the signal is processed immediately in the loop before the next child is spawned. Even if the new child reuses the PID, the old entry is deleted first, and then the new entry is added. No corruption occurs. This is a false positive based on a misunderstanding of the single-threaded event loop.\n\nCRUX: The child process management logic in the `prefork` master runs in a single sequential loop, so map operations on `childProcs` are not concurrent and cannot race with signal processing.\nGREP: for sig := range sigCh",
    "elapsed": 9.7,
    "tokens": 5981,
    "tokens_in": 5402,
    "tokens_out": 579,
    "cost_usd": 0.00400584,
    "file": "prefork/prefork.go",
    "round": 1,
    "grep_used": true,
    "grep_results": "GREP `for sig := range sigCh`:\n```\nprefork/prefork.go:342:\tfor sig := range sigCh {\n```"
  },
  {
    "finding_title": "Stale child signals and PID reuse can corrupt child process tracking",
    "verdict": "INVALID",
    "reasoning": "The vulnerability report claims a race condition where stale signals in `sigCh` can lead to PID reuse issues, causing the master to lose track of active child processes. The core of this argument relies on the assumption that signal processing is asynchronous relative to the map updates in a way that allows interference. However, analyzing the code in `prefork/prefork.go` reveals that the master process manages child processes strictly within a single sequential loop: `for sig := range sigCh`. Within this loop, the deletion of the old process (`delete(childProcs, sig.pid)`) occurs strictly before the spawning of the new process (`p.doCommand()`) and the addition of the new process to the map (`childProcs[newPid] = cmd`). Since all these operations happen in the same goroutine without yielding control to the scheduler in a way that would allow another signal for the same PID to be processed concurrently (the channel receive blocks until a signal is available, and the body executes atomically with respect to other loop iterations), there is no window where a 'stale' signal for PID X could be processed after a new child with PID X has been registered. Even if PID X is reused for the new child, the sequence is: 1. Receive signal for PID X (old exit). 2. Delete PID X from map. 3. Spawn new child, get PID X (reused). 4. Add PID X to map. There is no concurrency on the `childProcs` map or `sigCh` processing that would allow a second signal for PID X to interfere. The 'stale signal' would just be the original exit signal which has already been consumed. Therefore, the described corruption scenario is impossible in this implementation.\n\nCRUX: The child process management logic in the `prefork` master runs in a single sequential loop, ensuring that map deletions and insertions for specific PIDs are atomic with respect to signal processing order.\nGREP: for sig := range sigCh",
    "elapsed": 6.0,
    "tokens": 6573,
    "tokens_in": 6149,
    "tokens_out": 424,
    "cost_usd": 0.00385458,
    "file": "prefork/prefork.go",
    "round": 2,
    "grep_used": true,
    "grep_results": "GREP `for sig := range sigCh`:\n```\nprefork/prefork.go:342:\tfor sig := range sigCh {\n```"
  },
  {
    "finding_title": "Stale child signals and PID reuse can corrupt child process tracking",
    "verdict": "INVALID",
    "reasoning": "The reported vulnerability is based on a misunderstanding of how the event loop operates. The `prefork` function manages child processes in a single sequential loop: `for sig := range sigCh`. Inside this loop, the code executes the following steps strictly sequentially: (1) receive a signal from `sigCh`, (2) delete the exited PID from the `childProcs` map, (3) spawn a new child process, and (4) add the new PID to the `childProcs` map. Because these operations occur within the same goroutine without any concurrent modification of the `childProcs` map, there is no race condition. Even if the OS reuses the PID of the exited process for the new child, the deletion of the old entry happens *before* the new entry is added. Therefore, a 'stale' signal cannot corrupt the map because the signal is processed immediately and sequentially. The scenario described in the report—where a stale signal removes an active replacement child—is impossible in this implementation. Since the bug pattern (race condition/corruption) does not exist in the code, the finding is invalid.\n\nCRUX: The child process management logic in the `prefork` master runs in a single sequential loop, so map operations on `childProcs` are not concurrent and cannot race with signal processing.\nGREP: for sig := range sigCh",
    "elapsed": 4.2,
    "tokens": 6890,
    "tokens_in": 6592,
    "tokens_out": 298,
    "cost_usd": 0.00366264,
    "file": "prefork/prefork.go",
    "round": 3,
    "grep_used": true,
    "grep_results": "GREP `for sig := range sigCh`:\n```\nprefork/prefork.go:342:\tfor sig := range sigCh {\n```"
  },
  {
    "finding_title": "Stale child signals and PID reuse can corrupt child process tracking",
    "verdict": "INVALID",
    "reasoning": "The vulnerability report claims a race condition where a stale signal in the buffered channel `sigCh` is processed after a replacement child process has been spawned and registered in `childProcs`, potentially causing the active child to be removed from the map if the OS reused the PID. \n\nHowever, analysis of the code in `prefork/prefork.go` shows that the child process management is handled by a single goroutine executing the loop `for sig := range sigCh`. \n1. The loop receives a signal (exit notification) from `sigCh`.\n2. It immediately executes `delete(childProcs, sig.pid)`.\n3. It spawns a new child process via `p.doCommand()`.\n4. It adds the new child to the map via `childProcs[newPid] = cmd`.\n\nThis sequence is strictly sequential within the same goroutine. There is no concurrent access to `childProcs` by the master process. The \"stale signal\n\nCRUX: The child process management loop in the master process is single-threaded and processes signals sequentially, preventing concurrent map corruption or stale signal race conditions.\nGREP: for sig := range sigCh",
    "elapsed": 4.6,
    "tokens": 7174,
    "tokens_in": 6909,
    "tokens_out": 265,
    "cost_usd": 0.0036967799999999998,
    "file": "prefork/prefork.go",
    "round": 4,
    "grep_used": true,
    "grep_results": "GREP `for sig := range sigCh`:\n```\nprefork/prefork.go:342:\tfor sig := range sigCh {\n```"
  },
  {
    "finding_title": "Stale child signals and PID reuse can corrupt child process tracking",
    "verdict": "INVALID",
    "reasoning": "The reported vulnerability posits a race condition where a 'stale' exit signal in the buffered `sigCh` channel could be processed after a replacement child process (potentially reusing the same PID) has been registered in the `childProcs` map, leading to incorrect map state and orphaned processes. To verify this, I must analyze the concurrency model of the `prefork` master process. The code in `prefork.go` shows that child process management is handled entirely within the `prefork` method. The initial spawning loop runs sequentially. Subsequently, the master enters a single loop: `for sig := range sigCh`. Inside this loop, the following steps occur strictly sequentially in the same goroutine: 1) Receive a signal from `sigCh`, 2) Execute `delete(childProcs, sig.pid)`, 3) Spawn a new child via `p.doCommand()`, and 4) Execute `childProcs[newPid] = cmd`. Because these operations are performed by a single goroutine without concurrent access to the `childProcs` map or the `sigCh` processing logic from other master-side goroutines, it is impossible for a signal for PID X to be processed *after* a new child with PID X has been added. If PID X is reused, the sequence is: Process Exit Signal X -> Remove X from Map -> Spawn New Child (gets PID X) -> Add X to Map. The 'stale signal' is the one just processed. There is no window where a second, separate signal for X could interfere because signals are generated by `Wait()` goroutines that block until the process exits, and the processing is serialized. The `childProcs` map is only read and written by this single loop goroutine. Therefore, the specific race condition described does not exist. The code is safe from this specific corruption pattern because the event loop is single-threaded and sequential with respect to map state updates.\n\nCRUX: The child process management logic in the prefork master runs in a single sequential loop, ensuring that map deletions and insertions for specific PIDs are atomic with respect to signal processing order, preventing the described race.\nGREP: for sig := range sigCh",
    "elapsed": 7.1,
    "tokens": 7657,
    "tokens_in": 7184,
    "tokens_out": 473,
    "cost_usd": 0.00443628,
    "file": "prefork/prefork.go",
    "round": 5,
    "grep_used": true,
    "grep_results": "GREP `for sig := range sigCh`:\n```\nprefork/prefork.go:342:\tfor sig := range sigCh {\n```"
  }
]
Raw scan report for this file

```json
[
  {
    "severity": "medium",
    "title": "Stale child signals and PID reuse can corrupt child process tracking",
    "function": "Prefork.prefork",
    "description": "Child exit signals are buffered in sigCh and processed asynchronously. If a replacement child is spawned before a stale signal is processed and the OS reuses that child PID, childProcs[newPid] can overwrite a stale map entry. When the stale signal is later processed, delete(childProcs, pid) may remove the active replacement child, causing the master to lose track of it. The deferred kill may then fail to terminate the orphaned process, and recovery accounting can be incorrect. An attacker who can repeatedly crash child processes, for example through malformed HTTP causing a handler panic or ServeFunc to return, may increase the likelihood of PID reuse and orphaned child processes."
  },
  {
    "severity": "low",
    "title": "Nil serve callback causes child process panic",
    "function": "Prefork.ListenAndServe",
    "description": "ListenAndServe, ListenAndServeTLS, and ListenAndServeTLSEmbed call p.ServeFunc, p.ServeTLSFunc, or p.ServeTLSEmbedFunc in the child branch. If a Prefork value is used without New, or if one of these fields is nil, the child process dereferences a nil function after listenAsChild and panics. This can crash the child repeatedly and lead to denial of service."
  },
  {
    "severity": "low",
    "title": "New(nil) panics due to unchecked fasthttp.Server pointer",
    "function": "New",
    "description": "New accesses s.Logger before checking whether s is nil. Calling New(nil) panics in the caller. A malicious or buggy caller can use this to crash the host process."
  },
  {
    "severity": "low",
    "title": "FASTHTTP_PREFORK_CHILD environment variable can force child-mode behavior",
    "function": "IsChild",
    "description": "IsChild trusts the FASTHTTP_PREFORK_CHILD environment variable. If an attacker can influence the process environment, a master process calling ListenAndServe will take the child path and call listen, which assumes file descriptor 3 is the prefork listener. If fd 3 is invalid or points to an arbitrary inherited descriptor, the server may fail or listen on an unintended socket. This also bypasses the normal master/child spawn path."
  },
  {
    "severity": "low",
    "title": "Child listener blindly trusts file descriptor 3",
    "function": "Prefork.listen",
    "description": "In non-reuseport mode the child wraps fd 3 with os.NewFile without verifying that it is the listener created by the master. If a custom CommandProducer does not pass the provided files as ExtraFiles, or if an inherited fd 3 is reused for another purpose, the child may operate on an unintended descriptor. If net.FileListener fails, the os.File wrapper is also not closed."
  },
  {
    "severity": "low",
    "title": "File descriptor and listener leaks in prefork setup",
    "function": "Prefork.setTCPListenerFiles",
    "description": "setTCPListenerFiles assigns the open listener to p.ln before obtaining an extra file. If tcplistener.File() fails, the already-open listener is not closed. The master also never closes the files stored in p.files after child processes are spawned. Repeated calls to ListenAndServe, especially after error paths or recovery shutdown, can leak file descriptors and eventually exhaust process resources."
  },
  {
    "severity": "low",
    "title": "RecoverThreshold can allow unbounded child respawn under child-crash conditions",
    "function": "Prefork.prefork",
    "description": "RecoverThreshold is used directly without validation. If it is set to a large value from untrusted configuration and an attacker can repeatedly terminate child processes, the master will keep spawning replacement processes, causing process, CPU, and descriptor exhaustion. The default threshold is bounded, but the public field allows a misconfigured or attacker-influenced value to create a denial-of-service condition."
  }
]
```