fix(launcher): harden LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:45:15 +02:00
parent accd01a008
commit 9f9c116792
7 changed files with 514 additions and 89 deletions

View file

@ -1,31 +1,127 @@
Set-StrictMode -Version Latest
function Get-CampaignLaSha256([string]$Text) {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
return [Convert]::ToHexString(
[Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
}
function Get-CampaignLaCommandLineFingerprint(
[string]$ExecutablePath,
[string]$ConfigArgument,
[string]$SessionConfigPath) {
if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or
-not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or
$ConfigArgument -cnotin @('--config', '--session-config')) {
throw 'Cannot fingerprint an incomplete launcher-child command line.'
}
$executable = [IO.Path]::GetFullPath($ExecutablePath)
$config = [IO.Path]::GetFullPath($SessionConfigPath)
# Only the executable and the recognized config argument are retained in
# this projection. Launcher credentials use stdin; unrelated argv is
# deliberately excluded so an accidental secret can never enter evidence.
return Get-CampaignLaSha256(
"campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config")
}
function Get-CampaignLaLinuxProcessIdentity(
[string]$ProcessDirectory,
[string]$BootId) {
$stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat'))
$commandEnd = $stat.LastIndexOf(')')
if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) {
throw 'Linux process stat record is malformed.'
}
# The tail begins at field 3 (state); field 22 (starttime) is index 19.
$tail = @($stat.Substring($commandEnd + 2).Split(
' ',
[StringSplitOptions]::RemoveEmptyEntries))
if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' }
$startTicks = [uint64]::Parse(
$tail[19],
[Globalization.NumberStyles]::None,
[Globalization.CultureInfo]::InvariantCulture)
return "linux-proc-start-v1:$BootId`:$startTicks"
}
function Get-CampaignLaProcessInstanceIdentity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)][int]$ProcessId)
if ($IsWindows) {
$candidate = Get-CimInstance Win32_Process `
-Filter "ProcessId=$ProcessId" -ErrorAction Stop
if ($null -eq $candidate) { return $null }
if ($null -eq $candidate.CreationDate) {
throw "Windows process $ProcessId has no creation time."
}
return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
}
if ($IsLinux) {
$directory = "/proc/$ProcessId"
if (-not [IO.Directory]::Exists($directory)) { return $null }
try {
$bootId = [IO.File]::ReadAllText(
'/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
return Get-CampaignLaLinuxProcessIdentity $directory $bootId
}
catch [IO.FileNotFoundException] { return $null }
catch [IO.DirectoryNotFoundException] { return $null }
catch [IO.IOException] {
if (-not [IO.Directory]::Exists($directory)) { return $null }
throw
}
}
throw 'Campaign LA process identity supports Windows and Linux only.'
}
function Get-CampaignLaSessionProcessCorrelations {
[CmdletBinding()]
param()
$matches = [Collections.Generic.List[object]]::new()
$correlations = [Collections.Generic.List[object]]::new()
if ($IsWindows) {
$pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
$pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
$commandLine = [string]$candidate.CommandLine
if ([string]::IsNullOrWhiteSpace($commandLine)) { continue }
$executablePath = [string]$candidate.ExecutablePath
if ([string]::IsNullOrWhiteSpace($commandLine) -or
-not [IO.Path]::IsPathFullyQualified($executablePath) -or
$null -eq $candidate.CreationDate) {
continue
}
$identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
foreach ($match in [Text.RegularExpressions.Regex]::Matches(
$commandLine,
$pattern)) {
$value = if ($match.Groups[1].Success) {
$match.Groups[1].Value
} else { $match.Groups[2].Value }
$argument = $match.Groups[1].Value.ToLowerInvariant()
$value = if ($match.Groups[2].Success) {
$match.Groups[2].Value
} else { $match.Groups[3].Value }
if ([IO.Path]::IsPathFullyQualified($value)) {
$matches.Add([pscustomobject]@{
$configPath = [IO.Path]::GetFullPath($value)
$correlations.Add([pscustomobject]@{
ProcessId = [int]$candidate.ProcessId
SessionConfigPath = [IO.Path]::GetFullPath($value)
ProcessInstanceIdentity = $identity
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$executablePath $argument $configPath
})
}
}
}
}
elseif ($IsLinux) {
$bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) {
$leaf = [IO.Path]::GetFileName($directory)
$processId = 0
@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations {
continue
}
try {
$identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId
$bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline'))
if ($bytes.Length -eq 0) { continue }
$arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split(
[char]0,
[StringSplitOptions]::RemoveEmptyEntries))
$identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId
if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or
-not [IO.Path]::IsPathFullyQualified($arguments[0])) {
continue
}
for ($index = 0; $index + 1 -lt $arguments.Count; $index++) {
if ($arguments[$index] -cin @('--config', '--session-config') -and
[IO.Path]::IsPathFullyQualified($arguments[$index + 1])) {
$matches.Add([pscustomobject]@{
$configPath = [IO.Path]::GetFullPath($arguments[$index + 1])
$correlations.Add([pscustomobject]@{
ProcessId = $processId
SessionConfigPath = [IO.Path]::GetFullPath(
$arguments[$index + 1])
ProcessInstanceIdentity = $identityBefore
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$arguments[0] $arguments[$index] $configPath
})
}
}
}
catch [IO.IOException] {
# A process may exit between /proc enumeration and cmdline read.
# A process may exit between /proc enumeration and either read.
}
catch [UnauthorizedAccessException] {
# Other-user processes cannot be the owner-readable gate child.
@ -65,7 +171,7 @@ function Get-CampaignLaSessionProcessCorrelations {
throw 'Campaign LA process correlation supports Windows and Linux only.'
}
return @($matches)
return @($correlations)
}
function Get-CampaignLaCorrelatedProcessIds {
@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds {
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$matches = [Collections.Generic.HashSet[int]]::new()
$processIds = [Collections.Generic.HashSet[int]]::new()
foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
if ([string]::Equals(
$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$null = $matches.Add([int]$candidate.ProcessId)
$null = $processIds.Add([int]$candidate.ProcessId)
}
}
return @($matches | Sort-Object)
return @($processIds | Sort-Object)
}
function Test-CampaignLaCapturedProcessState {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][int]$ProcessId,
[Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity,
[Parameter(Mandatory = $true)][string]$SessionConfigPath,
[AllowNull()][string]$CurrentProcessInstanceIdentity,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()][object[]]$Correlations)
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity
$exactConfigPathAlive = $false
$pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity
foreach ($candidate in $Correlations) {
if ([string]::Equals(
[string]$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$exactConfigPathAlive = $true
}
}
return [pscustomobject]@{
SameInstanceAlive = $sameInstanceAlive
ExactConfigPathAlive = $exactConfigPathAlive
PidReused = $pidReused
}
}