src/Private/Analyze/Get-FotonEventSummaryHelpers.ps1
|
$script:FotonPerUserServiceTemplates = @( 'AarSvc', 'BcastDVRUserService', 'BluetoothUserService', 'CaptureService', 'cbdhsvc', 'CDPUserSvc', 'CloudBackupRestoreSvc', 'ConsentUxUserSvc', 'CredentialEnrollmentManagerUserSvc', 'DeviceAssociationBrokerSvc', 'DevicePickerUserSvc', 'DevicesFlowUserSvc', 'LxssManagerUser', 'MessagingService', 'NPSMSvc', 'OneSyncSvc', 'P9RdrService', 'PenService', 'PimIndexMaintenanceSvc', 'PrintWorkflowUserSvc', 'UdkUserSvc', 'UnistoreSvc', 'UserDataSvc', 'webthreatdefusersvc', 'WpnUserService' ) function Test-FotonPerUserServiceName { <# True for per-user service instances (OneSyncSvc_1701f8, CDPUserSvc_2a8824). Windows creates one per template service at every interactive logon and logs each as a service install (4697), so on any Windows 10/11 host they outnumber real installs. The suffix is a hex logon-session identifier. The base name must be a known Microsoft per-user template: a bare Name_<hex> shape is attacker-choosable (Updater_1a2b3c) and would hide a real install. A template Microsoft adds later is counted rather than hidden - noisy, not blind. #> param([string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return $false } $m = [regex]::Match($Name.Trim(), '^(?<base>[A-Za-z][A-Za-z0-9]*)_[0-9a-fA-F]{4,8}$') if (-not $m.Success) { return $false } return ($script:FotonPerUserServiceTemplates -contains $m.Groups['base'].Value) } function Get-FotonServiceNameFromEvent { <# Service name from a 7045/4697 row: EvtxECmd PayloadData1 ("Name: X" or "ServiceName: X") or the native Message text ("Service Name: X"). #> param([object]$Row) $end = '(?=\s{2,}|\s+Service File Name:|\s+Service Type:|\s+Image Path:|\r|\n|$)' foreach ($field in @('PayloadData1', 'Message')) { $text = [string]$Row.$field if (-not $text) { continue } # "Service Name:" first: a native 4697 message names the subject account # ("Account Name: HOST$") before the service, so a bare "Name:" match there # returns the account. $m = [regex]::Match($text, '(?i)Service\s?Name:\s+(?<n>.+?)' + $end) if ($m.Success) { return $m.Groups['n'].Value.Trim() } # EvtxECmd 7045 PayloadData1 is exactly "Name: <service>". $m = [regex]::Match($text, '(?i)^\s*Name:\s+(?<n>.+?)' + $end) if ($m.Success) { return $m.Groups['n'].Value.Trim() } } return '' } function Get-FotonServiceImageFromEvent { <# Service binary from a 7045/4697 row: EvtxECmd 7045 ExecutableInfo (ImagePath), EvtxECmd 4697 PayloadData2 ("ServiceFileName: X"), or native Message text ("Service File Name: X"). Empty when the row does not carry it. #> param([object]$Row) $exe = [string]$Row.ExecutableInfo if ($exe.Trim()) { return $exe.Trim() } foreach ($field in @('PayloadData2', 'Message')) { $m = [regex]::Match([string]$Row.$field, '(?i)Service\s?File\s?Name:\s+(?<p>.+?)(?=\s{2,}|\s+Service Type:|\r|\n|$)') if ($m.Success) { return $m.Groups['p'].Value.Trim() } } return '' } function Test-FotonSystem32Leaf { <# True when -Path names a file directly in System32 (no subfolder), written as %SystemRoot%\System32, %windir%\System32, \SystemRoot\System32, <drive>:\Windows\System32, or the host's own SystemDirectory from os_info (Windows not on C:\Windows). Leading quotes and trailing arguments are ignored. #> param([string]$Path, [string]$SystemDirectory) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } $roots = @('%SystemRoot%\\System32', '%windir%\\System32', '\\SystemRoot\\System32', '[A-Z]:\\Windows\\System32') if ($SystemDirectory) { $roots += [regex]::Escape($SystemDirectory.TrimEnd('\')) } $pattern = '(?i)^"?(?:' + ($roots -join '|') + ')\\[^\\"]+?(?:"|\s|$)' return ($Path.Trim() -match $pattern) } function Test-FotonPerUserServiceEvent { <# True only when the service name is a known per-user template instance AND the image is the System32 svchost.exe that hosts every per-user service. A copied template name pointing at another binary is counted, and so is a row whose image cannot be read: unknown is not proof of benign. -ServiceIndex (name -> services.csv row) adds the ServiceDll the event lacks. A genuine instance has no ServiceDll of its own (Collect reads the template's, ServiceDllSource 'Template:<name>'), so an instance with its own DLL counts, as does a DLL outside System32, in a user-writable path, or NotSigned/HashMismatch. Instances already removed at collection time (they go at logoff) have no row and keep the name-and-image decision. #> param([object]$Row, [hashtable]$ServiceIndex, [string]$SystemDirectory) $name = Get-FotonServiceNameFromEvent -Row $Row if (-not (Test-FotonPerUserServiceName -Name $name)) { return $false } $img = Get-FotonServiceImageFromEvent -Row $Row if ($img -notmatch '(?i)^"?[^"]*\\svchost\.exe"?(?:\s|$)' -or -not (Test-FotonSystem32Leaf -Path $img -SystemDirectory $SystemDirectory)) { return $false } if ($ServiceIndex -and $ServiceIndex.ContainsKey($name)) { $svc = $ServiceIndex[$name] $dll = [string]$svc.ServiceDll if ($dll) { $src = [string]$svc.ServiceDllSource if ($src -and $src -notlike 'Template:*') { return $false } if ($dll -notmatch '(?i)\.dll"?$' -or -not (Test-FotonSystem32Leaf -Path $dll -SystemDirectory $SystemDirectory)) { return $false } if ([string]$svc.ServiceDllUserWritablePath -eq 'True') { return $false } if ([string]$svc.ServiceDllSignatureStatus -in @('NotSigned', 'HashMismatch')) { return $false } } } return $true } function Get-FotonServiceIndex { <# Name -> services.csv row, for ServiceDll lookups. Empty when services were not collected. #> param([object[]]$Services) $idx = @{} foreach ($s in @($Services)) { if ($null -ne $s -and $s.Name -and -not $idx.ContainsKey([string]$s.Name)) { $idx[[string]$s.Name] = $s } } return $idx } function Get-FotonAccountEventFacts { <# Normalise account lifecycle events (4720 create, 4726 delete, 4728/4732/4756 group add) from either native Message text or EvtxECmd PayloadData columns. Returns EventId, TimeUtc (datetime or $null), AccountSid, AccountName, GroupSid, GroupName. #> param([object[]]$Rows) $out = @() foreach ($r in @($Rows)) { if ($null -eq $r) { continue } $eid = [string]$r.EventId if (-not $eid) { $eid = [string]$r.EventID } if ($eid -notin @('4720', '4726', '4728', '4732', '4756')) { continue } $tsRaw = $r.TimeCreatedUtc if (-not $tsRaw) { $tsRaw = $r.TimeCreated } $ts = $null # Typed values (Get-WinEvent objects, ConvertFrom-Json in PS7) do not stringify # as yyyy-MM-dd and would otherwise drop out of transient-pair matching. if ($tsRaw -is [datetimeoffset]) { $ts = $tsRaw.UtcDateTime } elseif ($tsRaw -is [datetime]) { if ($tsRaw.Kind -eq [DateTimeKind]::Local) { $ts = $tsRaw.ToUniversalTime() } else { $ts = [datetime]::SpecifyKind($tsRaw, [DateTimeKind]::Utc) } } $tsText = [string]$tsRaw if ($null -eq $ts -and $tsText.Length -ge 19) { $d = [datetime]::MinValue if ([datetime]::TryParseExact($tsText.Substring(0, 19), 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$d)) { $ts = $d } } $fact = [PSCustomObject]@{ EventId = $eid; TimeUtc = $ts; AccountSid = ''; AccountName = ''; GroupSid = ''; GroupName = '' } $msg = [string]$r.Message if ($msg) { # Rendered messages show "Security ID:" as a SID when it cannot be # resolved and as DOMAIN\Name when it can (BUILTIN\Remote Desktop Users). # Names may contain spaces, so each value runs to the next field label. if ($eid -in @('4720', '4726')) { $m = [regex]::Match($msg, '(?:New Account|Target Account):\s+Security ID:\s+(?<sid>.+?)\s+Account Name:\s+(?<n>.+?)\s+Account Domain:') if ($m.Success) { $fact.AccountSid = $m.Groups['sid'].Value.Trim(); $fact.AccountName = $m.Groups['n'].Value.Trim() } } else { $m = [regex]::Match($msg, 'Member:\s+Security ID:\s+(?<sid>.+?)\s+Account Name:') if ($m.Success) { $fact.AccountSid = $m.Groups['sid'].Value.Trim() } $g = [regex]::Match($msg, 'Group:\s+Security ID:\s+(?<sid>.+?)\s+Group Name:\s+(?<n>.+?)\s+Group Domain:') if ($g.Success) { $fact.GroupSid = $g.Groups['sid'].Value.Trim(); $fact.GroupName = $g.Groups['n'].Value.Trim() } } } else { $p1 = [string]$r.PayloadData1 $t = [regex]::Match($p1, 'Target:\s+(?<n>[^(]+?)\s+\((?<sid>S-[\d-]+)\)') if ($eid -in @('4720', '4726')) { if ($t.Success) { $fact.AccountName = (($t.Groups['n'].Value -split '\\')[-1]).Trim(); $fact.AccountSid = $t.Groups['sid'].Value } } else { if ($t.Success) { $fact.GroupName = (($t.Groups['n'].Value -split '\\')[-1]).Trim(); $fact.GroupSid = $t.Groups['sid'].Value } foreach ($f in @('PayloadData2', 'PayloadData3')) { $ms = [regex]::Match([string]$r.$f, 'MemberSid:\s+(?<sid>S-[\d-]+)') if ($ms.Success) { $fact.AccountSid = $ms.Groups['sid'].Value } } } } $out += $fact } return $out } function Test-FotonPrivilegedGroupSid { <# True for groups whose membership grants administrative or remote-access rights. Adding a new account to the default primary group (RID 513, "None" / "Domain Users") happens on every account creation and is not a privilege change. -Sid may be a SID or a resolved DOMAIN\Name; -Name is the event's Group Name. Names are the English built-in names; a localized OS that renders neither a SID nor these names is missed, so the SID check runs first. #> param([string]$Sid, [string]$Name) if ($Sid -match '^S-1-5-32-(544|547|548|549|550|551|555|562|573|578|580)$') { return $true } if ($Sid -match '^S-1-5-21-[\d-]+-(512|518|519|520|526|527)$') { return $true } $names = @('Administrators', 'Power Users', 'Account Operators', 'Server Operators', 'Print Operators', 'Backup Operators', 'Remote Desktop Users', 'Distributed COM Users', 'Event Log Readers', 'Hyper-V Administrators', 'Remote Management Users', 'Domain Admins', 'Cert Publishers', 'Schema Admins', 'Enterprise Admins', 'Group Policy Creator Owners', 'Key Admins', 'Enterprise Key Admins') foreach ($candidate in @($Name, (([string]$Sid) -split '\\')[-1])) { if ($candidate -and ($names -contains $candidate.Trim())) { return $true } } return $false } function Get-FotonAccountChangeSummary { <# Split account events into transient create-then-delete pairs (same SID, deleted within $TransientSeconds), accounts that persisted, and group adds into privileged versus default groups. Provisioning scripts and LAPS-style tooling often create and remove an account in the same second; counting those as "account creation" makes routine automation look like persistence. #> param( [object[]]$Facts, [int]$TransientSeconds = 60 ) $creates = @($Facts | Where-Object { $_.EventId -eq '4720' }) $deletes = @($Facts | Where-Object { $_.EventId -eq '4726' }) $adds = @($Facts | Where-Object { $_.EventId -in @('4728', '4732', '4756') }) $transient = @() $persistent = @() foreach ($c in $creates) { $match = $deletes | Where-Object { $_.AccountSid -and $_.AccountSid -eq $c.AccountSid -and $c.TimeUtc -and $_.TimeUtc -and ($_.TimeUtc - $c.TimeUtc).TotalSeconds -ge 0 -and ($_.TimeUtc - $c.TimeUtc).TotalSeconds -le $TransientSeconds } | Select-Object -First 1 if ($match) { $transient += $c } else { $persistent += $c } } $transientSids = @($transient | ForEach-Object { $_.AccountSid }) $priv = @($adds | Where-Object { Test-FotonPrivilegedGroupSid -Sid $_.GroupSid -Name $_.GroupName }) # Adds of a transient account into its default group are part of the same blip. $other = @($adds | Where-Object { -not (Test-FotonPrivilegedGroupSid -Sid $_.GroupSid -Name $_.GroupName) -and $transientSids -notcontains $_.AccountSid }) return [PSCustomObject]@{ Transient = $transient Persistent = $persistent Deleted = $deletes PrivilegedAdds = $priv OtherGroupAdds = $other } } |