$ErrorActionPreference = "Stop" function Get-MinerOpsNativeGetItemState { $effective = Get-Command Get-Item -ErrorAction Stop $function = if ( $effective.CommandType -eq [Management.Automation.CommandTypes]::Function ) { $effective } else { $null } return [pscustomobject]@{ FunctionExisted = $null -ne $function FunctionScriptBlock = if ($null -ne $function) { $function.ScriptBlock } else { $null } EffectiveCommandType = [string]$effective.CommandType EffectiveName = [string]$effective.Name EffectiveModuleName = [string]$effective.ModuleName EffectiveSource = [string]$effective.Source EffectiveDefinition = [string]$effective.Definition } } function Assert-MinerOpsNativeGetItemState { param( [Parameter(Mandatory = $true)]$Expected, [string]$Context = "Get-Item state check" ) $actual = Get-MinerOpsNativeGetItemState if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) { throw "$Context changed the global Get-Item function's existence." } if ( [bool]$Expected.FunctionExisted -and ( -not [object]::ReferenceEquals( $actual.FunctionScriptBlock, $Expected.FunctionScriptBlock ) -or $actual.FunctionScriptBlock.ToString() -cne $Expected.FunctionScriptBlock.ToString() ) ) { throw "$Context changed the global Get-Item function's ScriptBlock." } foreach ($property in @( "EffectiveCommandType", "EffectiveName", "EffectiveModuleName", "EffectiveSource", "EffectiveDefinition" )) { if ([string]$actual.$property -cne [string]$Expected.$property) { throw "$Context changed effective Get-Item resolution ($property)." } } } function Invoke-MinerOpsNativeGetItemTrace { param([Parameter(Mandatory = $true)][scriptblock]$Action) $before = Get-MinerOpsNativeGetItemState $observedGetItemPaths = [System.Collections.Generic.List[string]]::new() $invokeWithLocalHook = { param( [Parameter(Mandatory = $true)][scriptblock]$TraceAction, [Parameter(Mandatory = $true)]$ObservedPaths ) # Use a child scope because PowerShell can continue resolving a # removed active function until the scope that defined it exits. # Exiting this child scope makes cleanup deterministic before the # caller-state assertion runs. Set-Item -LiteralPath "Function:\local:Get-Item" -Value { [CmdletBinding(DefaultParameterSetName = "Path")] param( [Parameter(Position = 0, ParameterSetName = "Path")] [string[]]$Path, [Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")] [Alias("PSPath")] [string[]]$LiteralPath, [switch]$Force ) $observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") { $LiteralPath } else { $Path } foreach ($candidate in @($observed)) { $ObservedPaths.Add([string]$candidate) } Microsoft.PowerShell.Management\Get-Item @PSBoundParameters } try { & $TraceAction | Out-Null } finally { Remove-Item -LiteralPath "Function:\local:Get-Item" -Force ` -ErrorAction SilentlyContinue } } try { & $invokeWithLocalHook $Action $observedGetItemPaths return @($observedGetItemPaths) } finally { Assert-MinerOpsNativeGetItemState -Expected $before ` -Context "Invoke-MinerOpsNativeGetItemTrace cleanup" } } function Get-MinerOpsNativeWin32Error { param([Parameter(Mandatory = $true)][int]$HResult) # HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat # the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error. $unsignedHResult = [int64]$HResult -band 4294967295L if ( ($unsignedHResult -band 4294901760L) -eq 2147942400L ) { return [int]($unsignedHResult -band 65535L) } return $null } function Get-MinerOpsNativeExceptionDetails { param([Parameter(Mandatory = $true)][Exception]$Exception) $maximumDepth = 32 $visited = [System.Collections.Generic.List[Exception]]::new() $chainTypes = [System.Collections.Generic.List[string]]::new() $current = $Exception $leaf = $Exception $truncated = $false for ($depth = 0; $depth -lt $maximumDepth; $depth++) { if ($null -eq $current) { break } $alreadyVisited = $false foreach ($candidate in $visited) { if ([object]::ReferenceEquals($candidate, $current)) { $alreadyVisited = $true break } } if ($alreadyVisited) { $truncated = $true break } $visited.Add($current) $chainTypes.Add($current.GetType().FullName) $leaf = $current $current = $current.InnerException } if ($null -ne $current) { $truncated = $true } $nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult) return [pscustomobject]@{ OuterException = $Exception OuterExceptionType = $Exception.GetType().FullName OuterHResult = [int]$Exception.HResult LeafException = $leaf LeafExceptionType = $leaf.GetType().FullName LeafHResult = [int]$leaf.HResult NativeError = $nativeError ExceptionChainTypes = @($chainTypes) ExceptionChainTruncated = $truncated } } function Get-MinerOpsNativeExceptionStatus { param( [Parameter(Mandatory = $true)][string]$Operation, [Parameter(Mandatory = $true)][Exception]$Exception ) $details = Get-MinerOpsNativeExceptionDetails -Exception $Exception $meaningfulException = $details.LeafException $nativeError = $details.NativeError if ($details.ExceptionChainTruncated) { return "unexpected_failure" } if ($meaningfulException -is [UnauthorizedAccessException]) { return "security_denied" } if ( $meaningfulException -is [ArgumentException] -or $meaningfulException -is [IO.FileNotFoundException] -or $meaningfulException -is [IO.DirectoryNotFoundException] -or $meaningfulException -is [IO.DriveNotFoundException] -or $meaningfulException -is [IO.PathTooLongException] -or $meaningfulException -is [NotSupportedException] ) { return "fixture_error" } if ( $meaningfulException -is [IO.IOException] -and $null -ne $nativeError ) { if ([int]$nativeError -eq 5) { return "security_denied" } if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) { # ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as # retaining both original names. The caller must still prove every # security and identity postcondition before treating this as a # passing denial. return "security_denied_requires_postconditions" } if ([int]$nativeError -eq 17) { # ReplaceFile requires source and destination on the same volume. # This says nothing about the protected target's access boundary. return "api_precondition_failure" } } return "unexpected_failure" } function Get-MinerOpsNativeStatusExitCode { param([Parameter(Mandatory = $true)][string]$Status) switch ($Status) { "succeeded" { return 0 } "security_denied" { return 5 } "fixture_error" { return 6 } "unexpected_failure" { return 7 } "security_denied_requires_postconditions" { return 8 } "api_precondition_failure" { return 9 } default { throw "Unknown standard-user helper status '$Status'." } } } function Get-MinerOpsNativeSwapFixtureStatus { param([Parameter(Mandatory = $true)]$Fixture) $fixtureFailures = [System.Collections.Generic.List[string]]::new() $requiredFixtureChecks = [ordered]@{ SourceExists = "source is missing" TargetExists = "target is missing" PathsDistinct = "source and target are the same path" SourceCreatedByStandardUser = ` "source was not created by the standard-user process" SourceOwnedByStandardUser = ` "standard user does not own the source" SourceReadable = "standard user cannot read the source" SourceModifiable = "standard user cannot modify the source" SourceRenameable = "standard user cannot rename the source" SourceDeleteCapable = ` "standard user does not have required delete access to the source" SourceReplaceAccessVerified = ` "standard user lacks source access required by ReplaceFile" TargetAclTrusted = "target ACL was not trusted before attack" TargetOwnerTrusted = "target owner was not trusted before attack" TargetIdentityCaptured = "target identity was not captured" SourceIdentityCaptured = "source identity was not captured" FileIdentitiesDistinct = "source and target resolve to the same file" BackupArgumentValid = "backup argument is invalid" BackupPathsDistinct = ` "backup path is not distinct from source and target" BackupExpectedStateVerified = ` "backup pre-attack state was not verified" BackupConfidentialitySafe = ` "backup choice could expose protected target content" } foreach ($entry in $requiredFixtureChecks.GetEnumerator()) { if (-not [bool]$Fixture.($entry.Key)) { $fixtureFailures.Add([string]$entry.Value) } } if ($fixtureFailures.Count -gt 0) { return [pscustomobject]@{ Status = "fixture_error" Failures = @($fixtureFailures) } } $apiFailures = [System.Collections.Generic.List[string]]::new() if (-not [bool]$Fixture.SameVolume) { $apiFailures.Add("source, target, and backup are not on one volume") } if (-not [bool]$Fixture.NtfsVolume) { $apiFailures.Add("attack fixture is not on NTFS") } if ($apiFailures.Count -gt 0) { return [pscustomobject]@{ Status = "api_precondition_failure" Failures = @($apiFailures) } } return [pscustomobject]@{ Status = "ready" Failures = @() } } function Assert-MinerOpsNativeSwapFixtureReady { param([Parameter(Mandatory = $true)]$Fixture) $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture switch ([string]$classification.Status) { "ready" { return } "fixture_error" { throw "Standard-user swap fixture is invalid: " + "$(@($classification.Failures) -join '; ')." } "api_precondition_failure" { throw "Standard-user swap has an API precondition failure: " + "$(@($classification.Failures) -join '; ')." } default { throw "Standard-user swap fixture classification failed closed." } } } function Assert-MinerOpsNativeSwapSecurityOutcome { param( [Parameter(Mandatory = $true)]$Result, [Parameter(Mandatory = $true)]$Postconditions ) if ([string]$Result.operation -cne "swap") { throw "Swap result recorded the wrong operation." } $detail = "outer=$([string]$Result.outer_exception_type), " + "outer_hresult=$([string]$Result.outer_hresult), " + "leaf=$([string]$Result.leaf_exception_type), " + "leaf_hresult=$([string]$Result.leaf_hresult), " + "native=$([string]$Result.native_error), " + "chain=$(@($Result.exception_chain_types) -join ' -> ')" switch ([string]$Result.status) { "succeeded" { if (-not [bool]$Result.replace_invoked) { throw "Successful swap result did not record File.Replace invocation." } throw "Standard user completed the protected replacement swap." } "security_denied" { } "security_denied_requires_postconditions" { } "fixture_error" { throw "Standard-user swap fixture was invalid ($detail)." } "api_precondition_failure" { throw "Standard-user swap hit an unrelated API precondition ($detail)." } default { throw "Standard-user swap failed unexpectedly ($detail)." } } if (-not [bool]$Result.replace_invoked) { throw "Standard-user swap did not reach System.IO.File.Replace." } $required = [ordered]@{ TargetExists = "protected target is missing" TargetIdentityUnchanged = "protected target identity changed" TargetContentUnchanged = "protected target content changed" TargetAclTrusted = "protected target ACL is not trusted" TargetAclUnchanged = "protected target ACL changed" TargetOwnerTrusted = "protected target owner is not trusted" TargetOwnerUnchanged = "protected target owner changed" TargetSingleLink = "protected target is not single-link" SourceExists = "attacker source is missing" SourceIdentityUnchanged = "attacker source identity changed" SourceContentUnchanged = "attacker source content changed" AttackerSourceNotPromoted = "attacker source was promoted to authority" TargetDeniedStandardUserRead = ` "protected content became readable by the standard user" TargetDeniedStandardUserModify = ` "protected target became modifiable by the standard user" TargetDeniedStandardUserDelete = ` "protected target became deletable by the standard user" TargetDeniedStandardUserRename = ` "protected target became renameable by the standard user" NoAttackerReadableBackup = ` "replacement exposed protected content through a backup" } $failures = [System.Collections.Generic.List[string]]::new() foreach ($entry in $required.GetEnumerator()) { if (-not [bool]$Postconditions.($entry.Key)) { $failures.Add([string]$entry.Value) } } if ($failures.Count -gt 0) { throw "Swap security postconditions failed ($detail): " + "$($failures -join '; ')." } } function Get-MinerOpsNativeFileIdentity { param([Parameter(Mandatory = $true)]$Stream) if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) { Add-Type -TypeDefinition @' using System; using System.ComponentModel; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public sealed class MinerOpsNativeHarnessFileIdentityResult { public string Identity { get; set; } public uint VolumeSerialNumber { get; set; } public ulong FileIndex { get; set; } public uint NumberOfLinks { get; set; } } public static class MinerOpsNativeHarnessFileIdentity { [StructLayout(LayoutKind.Sequential)] private struct ByHandleFileInformation { public uint FileAttributes; public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; public uint VolumeSerialNumber; public uint FileSizeHigh; public uint FileSizeLow; public uint NumberOfLinks; public uint FileIndexHigh; public uint FileIndexLow; } [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle( SafeFileHandle file, out ByHandleFileInformation information); public static MinerOpsNativeHarnessFileIdentityResult Get( SafeFileHandle file) { ByHandleFileInformation information; if (!GetFileInformationByHandle(file, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | information.FileIndexLow; return new MinerOpsNativeHarnessFileIdentityResult { Identity = String.Format( "{0:X8}:{1:X8}{2:X8}", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow), VolumeSerialNumber = information.VolumeSerialNumber, FileIndex = fileIndex, NumberOfLinks = information.NumberOfLinks }; } } '@ } return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle) }