Files
minerops/agents/windows/tests/MinerOpsNativeHarnessSupport.ps1
T

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)
}