Compare commits

...

4 Commits

Author SHA1 Message Date
Xavier Roche
5d22b59809 Probe run 4: hunt the full window and count distinct PIDs per set
Run 3 answered the question on the first five spawns and stopped there, which
leaves no collision rate and a sample of five. Runs the full 300s, counts
distinct reissued PIDs per set, and caps only the tree kills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-08-12 17:18:17 +02:00
Xavier Roche
65940365ee Probe run 3: fix the null sets and the console-host confound
Run 2 built every PID set through a helper that returned the HashSet, which
PowerShell unrolls, so each set came back null and matched everything. It also
showed 500 console hosts surviving the orphan-makers, each holding a handle on
its dead creator and reserving that PID -- the likely cause of run 1's null.
Children now inherit this shell's console, and a wscript set adds a child that
attaches to none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-08-12 17:16:11 +02:00
Xavier Roche
95c03624dd Probe run 2: four sets to separate PID reservation from bad luck
Run 1 saw zero collisions in 8095 spawns against 300 dead orphan-parent PIDs,
about 100x below chance, so those PIDs were unavailable rather than unlucky.
Adds baseline, held-handle and dead-child control sets, all freed at the same
instant, so the B result has something to be compared against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-08-12 17:06:57 +02:00
Xavier Roche
bfb9d4611d Scratch probe: does taskkill /F /T follow a stale recorded parent PID?
Branch-scoped windows-2022 job, not wired into any gate. Part 1 dumps the
recorded-parent state of every runner process; part 2 hunts a PID collision
against 300 held orphans and tree-kills the reused PID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-08-12 16:59:18 +02:00

364
.github/workflows/zz-pidreuse-probe.yml vendored Normal file
View File

@@ -0,0 +1,364 @@
# Scratch probe: does taskkill /F /T follow a recorded parent PID whose owner is
# long dead? Not part of the build gate; branch-scoped, delete when answered.
name: zz pid-reuse probe
on:
push:
branches: [zz-pidreuse-probe]
permissions:
contents: read
jobs:
probe:
runs-on: windows-2022
timeout-minutes: 15
steps:
- name: Probe
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$sum = $env:GITHUB_STEP_SUMMARY
function Say([string]$m) {
Write-Host $m
if ($sum) { try { Add-Content -LiteralPath $sum -Value $m -ErrorAction SilentlyContinue } catch { } }
}
Say "# PID-reuse probe on $env:COMPUTERNAME $(Get-Date -Format o)"
Say ""
Say '```'
# ---------------------------------------------------------------
# Part 1: is the hazard even present?
# ---------------------------------------------------------------
Say "== PART 1: recorded-parent state of the runner's own processes =="
$snap = @(Get-CimInstance Win32_Process)
$byPid = @{}
foreach ($p in $snap) { $byPid[[int]$p.ProcessId] = $p }
Say ("snapshot: {0} live processes" -f $snap.Count)
function Stamp($d) { if ($null -eq $d) { return '?' } else { return ([datetime]$d).ToString('HH:mm:ss.fff') } }
function Classify([int]$q) {
$c = $byPid[$q]
if ($null -eq $c) { return 'NOPROC' }
$par = $byPid[[int]$c.ParentProcessId]
if ($null -eq $par) { return 'DEAD-PARENT' }
if ($null -eq $par.CreationDate -or $null -eq $c.CreationDate) { return 'OK' }
if ($par.CreationDate -gt $c.CreationDate) { return 'RECYCLED-PARENT' }
return 'OK'
}
function Chain([int]$q) {
$out = @()
$seen = @{}
$cur = $q
for ($i = 0; $i -lt 24; $i++) {
$c = $byPid[$cur]
if ($null -eq $c) { $out += ("{0}=<no such process>" -f $cur); break }
$out += ("{0}/{1}@{2}" -f $c.ProcessId, $c.Name, (Stamp $c.CreationDate))
if ($seen.ContainsKey($cur)) { $out += '<cycle>'; break }
$seen[$cur] = $true
$st = Classify $cur
if ($st -ne 'OK') { $out += ("<<{0}>>" -f $st); break }
$cur = [int]$c.ParentProcessId
if ($cur -eq 0) { break }
}
return ($out -join ' <- ')
}
$interesting = @($snap | Where-Object {
$_.Name -like 'Runner.*' -or $_.Name -like 'Agent.*' -or $_.Name -like 'hosted-*' -or
$_.Name -eq 'bash.exe' -or $_.Name -eq 'pwsh.exe' -or
$_.Name -eq 'powershell.exe' -or $_.Name -eq 'dotnet.exe' -or
$_.Name -eq 'node.exe' -or $_.Name -eq 'cmd.exe' -or
$_.Name -eq 'conhost.exe' -or $_.Name -eq 'sh.exe'
} | Sort-Object Name, ProcessId)
$armed = 0
foreach ($p in $interesting) {
$st = Classify ([int]$p.ProcessId)
Say (" {0,-24} pid={1,-7} ppid={2,-7} {3,-16} {4}" -f $p.Name, $p.ProcessId, $p.ParentProcessId, $st, (Chain ([int]$p.ProcessId)))
if ($st -ne 'OK' -and ($p.Name -like 'Runner.*' -or $p.Name -like 'Agent.*' -or $p.Name -like 'hosted-*')) { $armed++ }
}
$dead = 0; $rec = 0; $ok = 0
$boxOrphan = @{}
foreach ($p in $snap) {
$st = Classify ([int]$p.ProcessId)
if ($st -eq 'DEAD-PARENT') {
$dead++
$k = [int]$p.ParentProcessId
if (-not $boxOrphan.ContainsKey($k)) { $boxOrphan[$k] = @() }
$boxOrphan[$k] += ("{0}/{1}" -f $p.ProcessId, $p.Name)
} elseif ($st -eq 'RECYCLED-PARENT') { $rec++ } else { $ok++ }
}
Say ""
Say ("box-wide: {0} processes, {1} with a DEAD recorded parent, {2} with a RECYCLED one, {3} sound" -f $snap.Count, $dead, $rec, $ok)
foreach ($k in ($boxOrphan.Keys | Sort-Object)) {
Say (" dead ppid {0,-7} still claimed as parent by: {1}" -f $k, ($boxOrphan[$k] -join ', '))
}
Say ""
if ($armed -gt 0) {
Say "PART 1 VERDICT: HAZARD PRESENT - $armed runner-agent process(es) record a parent PID that is dead or already recycled."
} elseif ($dead -gt 0) {
Say "PART 1 VERDICT: no Runner.*/Agent.* process has a stale recorded parent, but $dead other live processes do."
} else {
Say "PART 1 VERDICT: NOT ARMED - every live process's recorded parent is alive and older."
}
Say ""
# ---------------------------------------------------------------
# Part 2. Five sets of freed PIDs, released at the same instant, so
# that a null on the set the theory needs can be told apart from a
# probe that never recycles anything:
# A plain ping, killed, .NET handle disposed (baseline: does reuse happen?)
# C plain ping, killed, handle deliberately HELD (does a handle pin the PID?)
# D cmd orphan-maker, child killed (is the construction sound?)
# B cmd orphan-maker, ping child left ALIVE (the question)
# E cmd orphan-maker, wscript child left ALIVE (same, with a child that
# attaches to no console)
# Every process inherits this shell's console, so no console host survives
# to hold a handle on the dead cmd. Run 2 created 500 of those.
# ---------------------------------------------------------------
Say "== PART 2: can a dead parent's PID be handed out again, and does taskkill /T follow it? =="
$vbs = Join-Path $env:TEMP 'zzsleep.vbs'
Set-Content -LiteralPath $vbs -Value 'WScript.Sleep 600000' -Encoding ascii
function New-Ping {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "$env:SystemRoot\System32\PING.EXE"
$psi.Arguments = '-n 600 127.0.0.1'
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
return [System.Diagnostics.Process]::Start($psi)
}
function New-Orphan([string]$childArgs) {
# cmd exits at once, leaving the child alive with cmd's now-free PID recorded as parent.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "$env:ComSpec"
$psi.Arguments = $childArgs
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $false
return [System.Diagnostics.Process]::Start($psi)
}
function Get-Proc([int]$q) {
return (Get-CimInstance Win32_Process -Filter "ProcessId = $q" -ErrorAction SilentlyContinue | Select-Object -First 1)
}
# --- control: a tree kill of a LIVE parent must kill its child, or the probe is blind.
Say ""
Say "-- control: taskkill /F /T on a live cmd whose child ping is genuine"
$ctl = New-Orphan '/c ping -n 600 127.0.0.1 > NUL'
Start-Sleep -Milliseconds 1500
$ctlKid = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $($ctl.Id)")
if ($ctlKid.Count -eq 0) {
Say " control INCONCLUSIVE: no child found under cmd $($ctl.Id)"
try { $ctl.Kill() } catch { }
} else {
$kp = [int]$ctlKid[0].ProcessId
Say (" cmd {0} -> child {1}/{2}" -f $ctl.Id, $kp, $ctlKid[0].Name)
$co = (& taskkill.exe /F /T /PID $ctl.Id 2>&1 | Out-String).Trim()
Say (" taskkill: " + ($co -replace "`r?`n", ' | '))
Start-Sleep -Milliseconds 800
$still = Get-Proc $kp
Say (" control result: child {0} {1}" -f $kp, $(if ($null -eq $still) { 'GONE - the probe can observe a tree kill' } else { 'STILL ALIVE - probe is blind, part 2 is inconclusive' }))
}
try { $ctl.Dispose() } catch { }
$conBefore = @(Get-Process conhost -ErrorAction SilentlyContinue).Count
# --- build the sets.
$NB = 300; $ND = 200; $NE = 200; $NA = 200; $NC = 200
Say ""
Say "-- building sets: B=$NB (ping child alive) E=$NE (wscript child alive) D=$ND (child killed) A=$NA (baseline) C=$NC (handle held)"
$bCmd = New-Object 'System.Collections.Generic.List[System.Diagnostics.Process]'
for ($i = 0; $i -lt $NB; $i++) { try { $bCmd.Add((New-Orphan '/c start /b ping -n 600 127.0.0.1 > NUL')) } catch { } }
$eCmd = New-Object 'System.Collections.Generic.List[System.Diagnostics.Process]'
for ($i = 0; $i -lt $NE; $i++) { try { $eCmd.Add((New-Orphan ('/c start /b wscript.exe //B //Nologo "' + $vbs + '"'))) } catch { } }
$dCmd = New-Object 'System.Collections.Generic.List[System.Diagnostics.Process]'
for ($i = 0; $i -lt $ND; $i++) { try { $dCmd.Add((New-Orphan '/c start /b ping -n 600 127.0.0.1 > NUL')) } catch { } }
$aProc = New-Object 'System.Collections.Generic.List[System.Diagnostics.Process]'
for ($i = 0; $i -lt $NA; $i++) { try { $aProc.Add((New-Ping)) } catch { } }
$pinned = New-Object 'System.Collections.Generic.List[System.Diagnostics.Process]'
for ($i = 0; $i -lt $NC; $i++) { try { $pinned.Add((New-Ping)) } catch { } }
$bPid = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in $bCmd) { [void]$bPid.Add([int]$p.Id) }
$ePid = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in $eCmd) { [void]$ePid.Add([int]$p.Id) }
$dPid = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in $dCmd) { [void]$dPid.Add([int]$p.Id) }
$aPid = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in $aProc) { [void]$aPid.Add([int]$p.Id) }
$cPid = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in $pinned) { [void]$cPid.Add([int]$p.Id) }
Say (" distinct PIDs held: B={0} E={1} D={2} A={3} C={4}" -f $bPid.Count, $ePid.Count, $dPid.Count, $aPid.Count, $cPid.Count)
if ($bPid.Count -eq 0 -or $aPid.Count -eq 0) { Say " set construction failed, aborting"; Say '```'; exit 1 }
Start-Sleep -Seconds 3
# Map the surviving children back to the cmd that made them.
$orphan = @{}
$dKids = New-Object 'System.Collections.Generic.List[int]'
$nB = 0; $nE = 0
foreach ($p in @(Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'ping.exe' -or $_.Name -eq 'wscript.exe' })) {
$pp = [int]$p.ParentProcessId
if ($bPid.Contains($pp) -or $ePid.Contains($pp)) {
if (-not $orphan.ContainsKey($pp)) { $orphan[$pp] = @() }
$orphan[$pp] += [pscustomobject]@{ Pid = [int]$p.ProcessId; Created = $p.CreationDate }
if ($bPid.Contains($pp)) { $nB++ } else { $nE++ }
} elseif ($dPid.Contains($pp)) {
$dKids.Add([int]$p.ProcessId)
}
}
Say (" surviving orphan children: {0} under B parents, {1} under E parents, {2} under D parents (to be killed)" -f $nB, $nE, $dKids.Count)
# Free every set at the same instant. Only C keeps its .NET handle open.
foreach ($p in $bCmd) { try { [void]$p.WaitForExit(2000) } catch { }; try { $p.Dispose() } catch { } }
foreach ($p in $eCmd) { try { [void]$p.WaitForExit(2000) } catch { }; try { $p.Dispose() } catch { } }
foreach ($p in $dCmd) { try { [void]$p.WaitForExit(2000) } catch { }; try { $p.Dispose() } catch { } }
foreach ($p in $aProc) { try { $p.Kill() } catch { } }
foreach ($p in $pinned) { try { $p.Kill() } catch { } }
Start-Sleep -Milliseconds 1000
foreach ($p in $aProc) { try { $p.Dispose() } catch { } }
$aProc.Clear(); $bCmd.Clear(); $eCmd.Clear(); $dCmd.Clear()
if ($dKids.Count -gt 0) { Stop-Process -Id $dKids.ToArray() -Force -ErrorAction SilentlyContinue }
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
Start-Sleep -Seconds 2
$liveNow = New-Object 'System.Collections.Generic.HashSet[int]'
foreach ($p in @(Get-CimInstance Win32_Process)) { [void]$liveNow.Add([int]$p.ProcessId) }
function CountLive($s) { $n = 0; foreach ($x in $s) { if ($liveNow.Contains($x)) { $n++ } }; return $n }
Say (" set members still live (must be 0): B={0} E={1} D={2} A={3} C={4}" -f (CountLive $bPid), (CountLive $ePid), (CountLive $dPid), (CountLive $aPid), (CountLive $cPid))
$conAfter = @(Get-Process conhost -ErrorAction SilentlyContinue).Count
Say (" conhost.exe: {0} before, {1} after (a surviving console host would hold a handle on its dead creator and pin its PID)" -f $conBefore, $conAfter)
$kidsAlive = 0
foreach ($k in $orphan.Keys) { foreach ($o in $orphan[$k]) { if ($liveNow.Contains($o.Pid)) { $kidsAlive++ } } }
Say (" orphan children still alive under a dead B/E parent: {0}" -f $kidsAlive)
# --- hunt.
$hitA = New-Object 'System.Collections.Generic.HashSet[int]'
$hitB = New-Object 'System.Collections.Generic.HashSet[int]'
$hitC = New-Object 'System.Collections.Generic.HashSet[int]'
$hitD = New-Object 'System.Collections.Generic.HashSet[int]'
$hitE = New-Object 'System.Collections.Generic.HashSet[int]'
$boxHits = 0
$spawns = 0; $tested = 0; $followed = 0; $survived = 0
$KILLCAP = 25
$distinct = New-Object 'System.Collections.Generic.HashSet[int]'
$window = New-Object 'System.Collections.Generic.Queue[System.Diagnostics.Process]'
$minPid = [int]::MaxValue; $maxPid = 0
function Test-Hit([int]$np) {
if ($script:aPid.Contains($np)) { [void]$script:hitA.Add($np) }
if ($script:dPid.Contains($np)) { [void]$script:hitD.Add($np) }
if ($script:cPid.Contains($np)) {
if ($script:hitC.Add($np) -and $script:hitC.Count -le 5) { Say ("!! set C hit: PID $np was handed out although an open process handle on it is still held") }
}
if ($script:boxOrphan.ContainsKey($np)) {
$script:boxHits++
Say ""
Say ("!! REAL COLLISION: our spawn got PID $np, which live box process(es) still record as their parent: $($script:boxOrphan[$np] -join ', ')")
Say " (not tree-killing this one: it would reap a process we did not start)"
Say ""
}
$isB = $script:bPid.Contains($np)
$isE = $script:ePid.Contains($np)
if (-not ($isB -or $isE)) { return $false }
if ($isB) { [void]$script:hitB.Add($np) } else { [void]$script:hitE.Add($np) }
if ($script:tested -ge $script:KILLCAP) { return $false }
$live = @()
foreach ($o in $script:orphan[$np]) {
$cur = Get-Proc $o.Pid
if ($null -ne $cur -and $cur.CreationDate -eq $o.Created) { $live += $o }
}
if ($live.Count -eq 0) { return $false }
$script:tested++
Say ""
Say ("### HIT #$($script:tested) on set $(if ($isB) { 'B' } else { 'E' }): spawn #$($script:spawns) at t=$([int]$script:sw.Elapsed.TotalSeconds)s was handed PID $np, the PID of a dead parent whose child is still running")
foreach ($k in @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $np")) {
Say (" the process table calls this a child of $np : {0}/{1} created {2}" -f $k.ProcessId, $k.Name, $k.CreationDate)
}
foreach ($o in $live) { Say (" stale-PPID orphan {0} is alive before the kill" -f $o.Pid) }
Say (" issuing: taskkill /F /T /PID $np")
$out = (& taskkill.exe /F /T /PID $np 2>&1 | Out-String).Trim()
Say (" taskkill said: " + ($out -replace "`r?`n", ' | '))
Start-Sleep -Milliseconds 1000
$anyDied = $false
foreach ($o in $live) {
$cur = Get-Proc $o.Pid
$alive = ($null -ne $cur -and $cur.CreationDate -eq $o.Created)
if (-not $alive) { $anyDied = $true }
Say (" stale-PPID orphan {0}: alive after the kill = {1}" -f $o.Pid, $alive)
}
if (-not $anyDied) { $script:survived++ }
if ($anyDied) {
$script:followed++
Say " >>> THE TREE WALK FOLLOWED A STALE PPID. taskkill /T does not validate that the recorded parent is the real one."
} else {
Say " >>> the orphan survived: taskkill /T did not follow the stale PPID."
}
Say ""
return $false
}
$sw = [Diagnostics.Stopwatch]::StartNew()
Say ""
Say "-- hunting: rolling window of 200 live spawns, 300s cap"
$done = $false
while (-not $done -and $sw.Elapsed.TotalSeconds -lt 300) {
try { $np = (New-Ping) } catch { Start-Sleep -Milliseconds 50; continue }
$window.Enqueue($np)
$spawns++
$id = [int]$np.Id
[void]$distinct.Add($id)
if ($id -lt $minPid) { $minPid = $id }
if ($id -gt $maxPid) { $maxPid = $id }
try { $done = Test-Hit $id } catch { Say ("Test-Hit failed on $id : " + $_.Exception.Message) }
while ($window.Count -gt 200) {
$old = $window.Dequeue()
try { $old.Kill() } catch { }
try { $old.Dispose() } catch { }
}
if (($spawns % 250) -eq 0) { Write-Host (" ... spawn $spawns, distinct $($distinct.Count), A=$($hitA.Count) B=$($hitB.Count) C=$($hitC.Count) D=$($hitD.Count) E=$($hitE.Count), t=$([int]$sw.Elapsed.TotalSeconds)s") }
}
Say ""
Say "== RESULT =="
Say ("spawns {0}, distinct PIDs {1}, range {2}..{3}, {4:N0}s" -f $spawns, $distinct.Count, $minPid, $maxPid, $sw.Elapsed.TotalSeconds)
Say ("distinct set PIDs reissued, by set:")
Say (" A killed, handle disposed, no child : {0} of {1}" -f $hitA.Count, $aPid.Count)
Say (" C killed, handle still OPEN : {0} of {1}" -f $hitC.Count, $cPid.Count)
Say (" D dead orphan-maker, child killed : {0} of {1}" -f $hitD.Count, $dPid.Count)
Say (" B dead orphan-maker, ping child ALIVE : {0} of {1}" -f $hitB.Count, $bPid.Count)
Say (" E dead orphan-maker, wscript ALIVE : {0} of {1}" -f $hitE.Count, $ePid.Count)
Say ("tree kills issued on a reissued stale-parent PID: {0}; reached the orphan: {1}; orphan survived: {2}" -f $tested, $followed, $survived)
Say ("collisions with a dead PPID recorded by a process we did NOT start: {0}" -f $boxHits)
if ($tested -gt 0 -and $followed -eq 0) {
Say "INTERPRETATION: a dead parent's PID IS reissued while a live process still records it as its parent, in $tested measured cases, and taskkill /F /T left that process alone every time. The tree walk does not follow a stale PPID: it skips a claimed child that predates the process now holding the PID."
} elseif ($hitA.Count -eq 0 -and $hitB.Count -eq 0 -and $hitE.Count -eq 0) {
Say "INTERPRETATION: nothing came back, so this run recycled nothing and says nothing either way."
} elseif ($tested -eq 0) {
Say "INTERPRETATION: PIDs recycle (A>0) but no PID with a live process recording it as parent ever came back. On this kernel such a PID stays reserved, so a tree kill cannot land on one and the stale-PPID mechanism cannot fire."
} elseif ($followed -gt 0) {
Say "INTERPRETATION: a dead parent's PID IS reissued while a live process records it, and taskkill /F /T reaped that process. The mechanism is real on this image."
} else {
Say "INTERPRETATION: the PID was reissued, but taskkill /F /T left the stale-PPID orphan alone: the tree walk validates parentage."
}
Say '```'
while ($window.Count -gt 0) { $o = $window.Dequeue(); try { $o.Kill() } catch { } }
try { Get-Process ping, wscript -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } catch { }
Write-Host "probe done"