[CmdletBinding()] param ( [Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, [Parameter(Mandatory)] [string] $Machine, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $OutputRoot, [string] $Variant = 'Elevated,Safer,Standard', [string] $OtherServer = '', [string] $DomainController = 'OSDC1', [string] $LabName = 'NtfsSecurityOsMatrixLab', [string] $LocalCredentialMachine = '', [string] $RepositoryRoot, [ValidateRange(1, 60)] [int] $TimeoutMinutes = 10 ) # Diagnostic of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V host: runs Invoke-EffectiveAccessProbe.ps1 # on one machine under up to four tokens, one after the other, and copies the output back. # Elevated the lab account in a scheduled task at the highest run level (the elevated mode of the local suite) # Limited the same account at the limited run level; a task with a batch logon doesn't get a filtered token, so this repeats Elevated # Safer the token of a basic user that Run-MatrixLocalSuite.ps1 -Mode Basic uses (SAFER level Normal User) # Standard a local standard user that this script creates on the machine and removes again, with a password that only exists there # DomainStandard a standard user of the domain, created on the domain controller (-DomainController) and removed again # The standard users get the batch logon right through the group Performance Log Users, which has no other right that the check needs. # Nothing secret is written; the lab password stays in memory, as in Run-MatrixLocalSuite.ps1. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent } $variants = @($Variant -split ',' | Where-Object -FilterScript { $_ }) if ($variants | Where-Object -FilterScript { $_ -notin 'Elevated', 'Limited', 'Safer', 'Standard', 'DomainStandard' }) { throw '-Variant takes Elevated, Limited, Safer, Standard, and DomainStandard, separated by commas.' } $localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ }) $cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$Machine" $null = New-Item -ItemType Directory -Path $cellFolder -Force $log = Join-Path -Path $cellFolder -ChildPath "$Label-probe.log" $stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $log } Set-Content -LiteralPath $log -Value (($stamp -f [DateTime]::UtcNow) + " START probe-$Label machine=$Machine variants=$($variants -join ',')") $wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw $class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' } $saferHead = @' [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $Executable, [Parameter(Mandatory)] [string] $Arguments, [Parameter(Mandatory)] [string] $WorkDirectory, [Parameter(Mandatory)] [string] $Console ) # Generated by Probe-EffectiveAccess.ps1: starts a process with the token of a basic user (SAFER level Normal User) through the class of # .github\scripts\Invoke-TestsAsBasicUser.ps1 and waits for it. $ErrorActionPreference = 'Stop' '@ $saferTail = @' $commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $Arguments, $Console exit [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $WorkDirectory) '@ $stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-probe-$Label" if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } $null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'module') -Force Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'module') -Recurse Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-EffectiveAccessProbe.ps1') -Destination $stage Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-SaferProcess.ps1') -Encoding UTF8 -Value ($saferHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $saferTail) $dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash Write-Step "module dll=$dllHash" Import-Module -Name AutomatedLab -ErrorAction Stop Import-Lab -Name $LabName -NoValidation -NoDisplay $sessionParameters = @{ ComputerName = $Machine } if ($Machine -in $localCredential) { $sessionParameters.UseLocalCredential = $true } $session = New-LabPSSession @sessionParameters $machineDefinition = Get-LabVM -ComputerName $Machine $runCredential = if ($Machine -in $localCredential) { New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $Machine, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force) } else { $machineDefinition.GetCredential((Get-Lab)) } $root = 'C:\NtfsMatrixProbe\' + $Label # A new name for every run: Windows keeps the SID of a deleted account for its name for a while, and a profile that stays loaded keeps the # folder, so a name that is used again meets the leftovers of its predecessor. $suffix = [DateTime]::UtcNow.ToString('MMddHHmmss') $standardUser = 'NtfsProbeS' + $suffix $domainUser = 'NtfsProbeD' + $suffix $dcSession = $null $domainSid = '' function Get-RandomProbePassword { # Random and never written; it exists in memory and in the account that the probe removes. $bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' 'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) } try { Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { param ($Path) if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } $null = New-Item -ItemType Directory -Path (Join-Path -Path $Path -ChildPath 'out') -Force } Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force Write-Step "staged to $root" $start = { param ($Root, $Variant, $UserName, $Password, $OtherServer, $StandardUser, $DomainSid) $powershell = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' $out = Join-Path -Path $Root -ChildPath 'out' $outFile = Join-Path -Path $out -ChildPath ('{0}.txt' -f $Variant) $probe = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -ModulePath "{1}" -OutFile "{2}" -Variant {3}' -f (Join-Path -Path $Root -ChildPath 'Invoke-EffectiveAccessProbe.ps1'), (Join-Path -Path $Root -ChildPath 'module'), $outFile, $Variant if ($OtherServer) { $probe += ' -OtherServer "{0}"' -f $OtherServer } $taskName = 'NtfsMatrixProbe-' + $Variant Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $runLevel = 'Highest' if ($Variant -eq 'Standard') { # The password exists only here: random, never written, and the account is removed after the run. $bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 $generator = [Security.Cryptography.RandomNumberGenerator]::Create() $generator.GetBytes($bytes) $alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' $Password = 'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) $UserName = '{0}\{1}' -f $env:COMPUTERNAME, $StandardUser if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } $null = New-LocalUser -Name $StandardUser -Password (ConvertTo-SecureString -String $Password -AsPlainText -Force) -PasswordNeverExpires -UserMayNotChangePassword -Description 'Probe of the matrix, removed after the run' Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $StandardUser $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $StandardUser) $runLevel = 'Limited' $execute = $powershell $argument = $probe } elseif ($Variant -eq 'DomainStandard') { # By SID: a name of a deleted account of an earlier run can still resolve to its old SID. try { Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid } catch { if ($_.Exception.GetType().Name -ne 'MemberExistsException') { throw } } $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) $runLevel = 'Limited' $execute = $powershell $argument = $probe } elseif ($Variant -eq 'Limited') { $runLevel = 'Limited' $execute = $powershell $argument = $probe } elseif ($Variant -eq 'Safer') { $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) $execute = $powershell $argument = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Executable "{1}" -Arguments "{2}" -WorkDirectory "{3}" -Console "{4}"' -f (Join-Path -Path $Root -ChildPath 'Start-SaferProcess.ps1'), $powershell, ($probe -replace '"', '\"'), $Root, (Join-Path -Path $out -ChildPath 'safer.console.txt') } else { $execute = $powershell $argument = $probe } $action = New-ScheduledTaskAction -Execute $execute -Argument $argument $null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel $runLevel -User $UserName -Password $Password Start-ScheduledTask -TaskName $taskName $taskName } $isRunning = { param ($TaskName) $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue [bool] ($task -and $task.State -eq 'Running') } $finish = { param ($TaskName) $result = (Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue).LastTaskResult Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue "task result $result" } foreach ($name in $variants) { $password = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.GetNetworkCredential().Password } else { '' } $userName = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.UserName } else { '' } if ($name -eq 'DomainStandard') { if (-not $dcSession) { $dcSession = New-LabPSSession -ComputerName $DomainController } $password = Get-RandomProbePassword $domainSid = Invoke-Command -Session $dcSession -ArgumentList $domainUser, $password -ScriptBlock { param ($Name, $Secret) Import-Module -Name ActiveDirectory New-ADUser -Name $Name -SamAccountName $Name -AccountPassword (ConvertTo-SecureString -String $Secret -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -CannotChangePassword $true -Description 'Probe of the matrix, removed after the run' (Get-ADUser -Identity $Name).SID.Value } $userName = '{0}\{1}' -f ((Get-Lab).Domains[0].Name -split '\.')[0], $domainUser Write-Step "domain user $domainUser created ($domainSid)" } $taskName = Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $root, $name, $userName, $password, $OtherServer, $standardUser, $domainSid Write-Step "variant $name started ($taskName)" $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) do { Start-Sleep -Seconds 5 $alive = Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $taskName } while ($alive -and [DateTime]::UtcNow -lt $deadline) if ($alive) { Write-Step "variant $name TIMED OUT after $TimeoutMinutes minutes" } Write-Step ("variant $name finished: " + (Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $taskName)) } Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'out\*') -Destination $cellFolder -Recurse -Force Write-Step 'results copied back' } finally { # The domain account goes first: its member entry on the machine is then an orphaned SID, which the cleanup of the machine removes. if ($dcSession) { # A failure here must not skip the cleanup of the machine below. try { $dcLeftOver = Invoke-Command -Session $dcSession -ArgumentList $domainUser -ScriptBlock { param ($Name) Import-Module -Name ActiveDirectory if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { Remove-ADUser -Identity $Name -Confirm:$false } if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { "domain user $Name still exists" } } Write-Step ('cleanup of the domain controller: ' + $(if (@($dcLeftOver).Count -eq 0) { 'nothing left' } else { @($dcLeftOver) -join '; ' })) } catch { Write-Step ("cleanup of the domain controller FAILED, remove the domain user $domainUser by hand: " + $_.Exception.Message) } Remove-PSSession -Session $dcSession -ErrorAction SilentlyContinue } if ($session) { # The accounts and the files of the probe don't stay on the machine. The cleanup finds them by name and by orphaned SID, not by # what this run created, so it also repairs what a run that stopped early left. $leftOver = Invoke-Command -Session $session -ArgumentList $root, $standardUser, $domainSid -ScriptBlock { param ($Root, $StandardUser, $DomainSid) $report = New-Object -TypeName 'System.Collections.Generic.List[string]' $users = Join-Path -Path $env:SystemDrive -ChildPath 'Users' function Get-ProbeProfile { @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $users -ChildPath 'NtfsProbe*') }) } function Get-ProbeMember { # net.exe shows the member of a deleted account as its SID. foreach ($line in @(cmd.exe /d /c 'net localgroup "Performance Log Users" 2>&1')) { $member = ('{0}' -f $line).Trim() if ($member -match '^S-1-5-21-[\d-]+$' -or $member -match '\\NtfsProbe') { $member } } } @(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } # net.exe doesn't take the SID of an account that its name cache still resolves, so the member goes by its SID through the cmdlet. if ($DomainSid) { try { Remove-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid -ErrorAction Stop } catch { if ("$($_.Exception.Message)" -notlike '*was not found*') { $report.Add("member ${DomainSid}: $($_.Exception.Message)") } } } # A profile that the last task of the account used stays loaded for a few seconds, so the removal is repeated. $attempt = 0 do { $profiles = Get-ProbeProfile if ($profiles.Count -gt 0) { $profiles | Remove-CimInstance -ErrorAction SilentlyContinue if ((Get-ProbeProfile).Count -gt 0) { Start-Sleep -Seconds 3 } } $attempt++ } while ((Get-ProbeProfile).Count -gt 0 -and $attempt -lt 10) Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue } if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { $report.Add("user $StandardUser still exists") } foreach ($member in @(Get-ProbeMember)) { $report.Add("$member is still in Performance Log Users") } foreach ($folder in @(Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue)) { $report.Add("profile folder $($folder.Name) still exists") } foreach ($userProfile in (Get-ProbeProfile)) { $report.Add("profile $($userProfile.LocalPath) still exists") } if (Test-Path -LiteralPath $Root) { $report.Add("folder $Root still exists") } @(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { $report.Add("task $($_.TaskName) still exists") } $report } Write-Step ('cleanup: ' + $(if (@($leftOver).Count -eq 0) { 'nothing left on the machine' } else { @($leftOver) -join '; ' })) Remove-PSSession -Session $session -ErrorAction SilentlyContinue } } Write-Step "probe-$Label-DONE"