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() $correlations = [Collections.Generic.List[object]]::new() if ($IsWindows) { $pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))' foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { $commandLine = [string]$candidate.CommandLine $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)) { $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)) { $configPath = [IO.Path]::GetFullPath($value) $correlations.Add([pscustomobject]@{ ProcessId = [int]$candidate.ProcessId 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 if (-not [int]::TryParse( $leaf, [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$processId)) { 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])) { $configPath = [IO.Path]::GetFullPath($arguments[$index + 1]) $correlations.Add([pscustomobject]@{ ProcessId = $processId ProcessInstanceIdentity = $identityBefore SessionConfigPath = $configPath CommandLineFingerprintSha256 = Get-CampaignLaCommandLineFingerprint ` $arguments[0] $arguments[$index] $configPath }) } } } catch [IO.IOException] { # A process may exit between /proc enumeration and either read. } catch [UnauthorizedAccessException] { # Other-user processes cannot be the owner-readable gate child. } } } else { throw 'Campaign LA process correlation supports Windows and Linux only.' } return @($correlations) } function Get-CampaignLaCorrelatedProcessIds { [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$SessionConfigPath) if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { throw 'Session-config correlation requires an absolute path.' } $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } $processIds = [Collections.Generic.HashSet[int]]::new() foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { if ([string]::Equals( $candidate.SessionConfigPath, $SessionConfigPath, $comparison)) { $null = $processIds.Add([int]$candidate.ProcessId) } } 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 } }