feat(windows): add recoverable existing-agent credential upgrade
This commit is contained in:
1 parent
3cfb0d1abe
commit
97a0139e3d
25 files changed
+11122
-108
No files matched your search
@@ -0,0 +1,475 @@
|
||||
$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)
|
||||
}
|
||||
Reference in new issue
Block a user