feat(windows): add recoverable existing-agent credential upgrade

This commit is contained in:
lussierj committed 2026-10-14 13:50:19 +00:00
1 parent 3cfb0d1abe
commit 97a0139e3d
25 files changed
+11122 -108

No files matched your search

@@ -0,0 +1,247 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Operation,
[Parameter(Mandatory = $true)][string]$InputPath,
[string]$DestinationPath = "",
[ValidateSet("None", "Path")][string]$BackupMode = "None",
[string]$BackupPath = "",
[Parameter(Mandatory = $true)][string]$ResultPath,
[Parameter(Mandatory = $true)][string]$SupportPath
)
$ErrorActionPreference = "Stop"
. $SupportPath
$sourceCreatedByCaller = $false
$sourceReadVerified = $false
$sourceModifyVerified = $false
$sourceRenameVerified = $false
$sourceDeleteAccessVerified = $false
$sourceReplaceAccessVerified = $false
$replaceInvoked = $false
$sourceExistedBeforeAttack = $null
$destinationExistedBeforeAttack = $null
$backupExistedBeforeAttack = $null
$inputFullPath = ""
$destinationFullPath = ""
$backupFullPath = ""
$backupArgumentWasNativeNull = $false
function Get-ProbeMetadata {
param(
[Parameter(Mandatory = $true)][string]$Status,
[Exception]$Exception
)
$inputOwnedByCaller = $false
if ([IO.File]::Exists($InputPath)) {
try {
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
[Security.Principal.SecurityIdentifier]
)
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
}
catch {
$inputOwnedByCaller = $false
}
}
$details = if ($null -ne $Exception) {
Get-MinerOpsNativeExceptionDetails -Exception $Exception
}
else { $null }
return [ordered]@{
status = $Status
operation = $Operation
# Retain these two outer-exception fields for compatibility with
# earlier native-harness result readers.
exception_type = if ($null -ne $Exception) {
$Exception.GetType().FullName
}
else {
""
}
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
outer_exception_type = if ($null -ne $details) {
$details.OuterExceptionType
}
else { "" }
outer_hresult = if ($null -ne $details) {
$details.OuterHResult
}
else { 0 }
leaf_exception_type = if ($null -ne $details) {
$details.LeafExceptionType
}
else { "" }
leaf_hresult = if ($null -ne $details) {
$details.LeafHResult
}
else { 0 }
native_error = if ($null -ne $details) {
$details.NativeError
}
else { $null }
exception_chain_types = if ($null -ne $details) {
@($details.ExceptionChainTypes)
}
else { @() }
exception_chain_truncated = if ($null -ne $details) {
[bool]$details.ExceptionChainTruncated
}
else { $false }
source_exists = [IO.File]::Exists($InputPath)
destination_exists = if (
[string]::IsNullOrWhiteSpace($DestinationPath)
) {
$null
}
else {
[IO.File]::Exists($DestinationPath)
}
input_owned_by_caller = $inputOwnedByCaller
source_created_by_caller = $sourceCreatedByCaller
source_read_verified = $sourceReadVerified
source_modify_verified = $sourceModifyVerified
source_rename_verified = $sourceRenameVerified
source_delete_access_verified = $sourceDeleteAccessVerified
source_replace_access_verified = $sourceReplaceAccessVerified
input_full_path = $inputFullPath
destination_full_path = $destinationFullPath
backup_mode = $BackupMode
backup_full_path = $backupFullPath
backup_argument_was_native_null = $backupArgumentWasNativeNull
source_existed_before_attack = $sourceExistedBeforeAttack
destination_existed_before_attack = $destinationExistedBeforeAttack
backup_existed_before_attack = $backupExistedBeforeAttack
replace_overload = "System.IO.File.Replace(String,String,String)"
replace_invoked = $replaceInvoked
}
}
function Write-ProbeResult {
param([Parameter(Mandatory = $true)]$Value)
$json = $Value | ConvertTo-Json -Compress
[IO.File]::WriteAllText($ResultPath, $json)
}
$exception = $null
$status = "succeeded"
try {
switch ($Operation) {
"read" { [void][IO.File]::ReadAllText($InputPath) }
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"delete" { [IO.File]::Delete($InputPath) }
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
"prepare_swap" {
if ([IO.File]::Exists($InputPath)) {
throw [ArgumentException]::new(
"swap source unexpectedly existed before caller creation"
)
}
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
[IO.File]::WriteAllText(
$InputPath,
"attacker-controlled-swap-source"
)
$sourceCreatedByCaller = $true
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source"
[IO.File]::AppendAllText($InputPath, "-modified")
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source-modified"
[IO.File]::Move($InputPath, $renameProof)
[IO.File]::Move($renameProof, $InputPath)
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
-not [IO.File]::Exists($renameProof)
$requiredRights =
[Security.AccessControl.FileSystemRights]::ReadData -bor
[Security.AccessControl.FileSystemRights]::WriteData -bor
[Security.AccessControl.FileSystemRights]::Delete -bor
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[Security.AccessControl.FileSystemRights]::Synchronize
$rightsProbe = [IO.FileStream]::new(
$InputPath,
[IO.FileMode]::Open,
$requiredRights,
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
4096,
[IO.FileOptions]::None
)
try {
$sourceDeleteAccessVerified = $true
$sourceReplaceAccessVerified = $true
}
finally {
$rightsProbe.Dispose()
}
}
"swap" {
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
if ($inputFullPath.Equals(
$destinationFullPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw [ArgumentException]::new(
"swap source and destination are the same path"
)
}
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
$destinationExistedBeforeAttack =
[IO.File]::Exists($destinationFullPath)
if (-not $sourceExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap source does not exist",
$inputFullPath
)
}
if (-not $destinationExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap destination does not exist",
$destinationFullPath
)
}
if ($BackupMode -ceq "None") {
if (-not [string]::IsNullOrEmpty($BackupPath)) {
throw [ArgumentException]::new(
"backup path was supplied while backup mode is None"
)
}
# A bare PowerShell $null is coerced to String.Empty for this
# .NET string parameter. NullString is the explicit native
# null required to request no ReplaceFile backup.
$backupArgumentWasNativeNull = $true
}
else {
throw [ArgumentException]::new(
"backup-producing swap fixtures are prohibited because " +
"they can expose protected destination content"
)
}
$replaceInvoked = $true
[IO.File]::Replace(
$inputFullPath,
$destinationFullPath,
[NullString]::Value
)
}
default { throw [ArgumentException]::new("unknown operation") }
}
}
catch {
$exception = $_.Exception
$status = Get-MinerOpsNativeExceptionStatus `
-Operation $Operation -Exception $exception
}
$result = Get-ProbeMetadata -Status $status -Exception $exception
Write-ProbeResult -Value $result
exit (Get-MinerOpsNativeStatusExitCode -Status $status)