acdream/tools/CampaignLaProcessCorrelation.ps1

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
}
}