232 lines
9.9 KiB
PowerShell
232 lines
9.9 KiB
PowerShell
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
|
|
}
|
|
}
|