476 lines
17 KiB
PowerShell
476 lines
17 KiB
PowerShell
$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)
|
|
}
|