mirror of https://github.com/raandree/NTFSSecurity
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
278 lines
18 KiB
278 lines
18 KiB
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
|
|
'PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The lab installation password comes from the AutomatedLab lab definition, which stores it as text, and the passwords of the probe users are random and exist only in memory; no credential is written.'
|
|
)]
|
|
[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"
|
|
|