Private/Security/Elevation.ps1
|
function Start-VSOElevationProcess { param([Diagnostics.ProcessStartInfo]$Info) return [Diagnostics.Process]::Start($Info) } function Invoke-VSOElevation { param([ValidateSet('Install-VSO','Update-VSO','Reset-VSO','Stop-VSO')][string]$Operation, [hashtable]$Arguments, $Context) if ($Context.Quiet) { Throw-VSOError $Context Admin } $parent=[IO.Path]::GetTempPath().TrimEnd('\') $work=Join-Path $parent ('.vso-work-'+[guid]::NewGuid().ToString('N')) New-VSOSecureDirectory $work CurrentUser $Context try { $resultPath=Join-VSOChild $work 'result.json' $Context [IO.File]::WriteAllText($resultPath,'', [Text.UTF8Encoding]::new($false)) $files=@(foreach ($relative in $script:RuntimeFiles) { $file=Join-VSOChild $script:ModuleRoot $relative $Context if ((Get-VSOHash $file) -cne $script:RuntimeHashes[$relative]) { Throw-VSOError $Context Elevation @('Imported module source changed. Re-import a reviewed copy before elevation.') } [ordered]@{ Path=$relative; Sha256=$script:RuntimeHashes[$relative] } }) $request=[ordered]@{ Operation=$Operation; Arguments=$Arguments; ModuleRoot=$script:ModuleRoot; Files=$files; Result=$resultPath; Owner=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value } $encodedRequest=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($request|ConvertTo-Json -Depth 8 -Compress))) # Only base64 data is substituted into this constant program. Paths and arguments # are never concatenated into executable PowerShell text. $bootstrap=@' $ErrorActionPreference='Stop' $request=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__REQUEST__'))|ConvertFrom-Json $handles=New-Object 'Collections.Generic.List[IO.FileStream]' $result=$null try { if($request.Operation -cnotin @('Install-VSO','Update-VSO','Reset-VSO','Stop-VSO')){throw 'Operation rejected.'} $root=[IO.Path]::GetFullPath([string]$request.ModuleRoot).TrimEnd('\') foreach($file in $request.Files){ if([IO.Path]::IsPathRooted([string]$file.Path)-or[string]$file.Path-match '(^|[\\/])\.\.([\\/]|$)|:'){throw 'Module path rejected.'} $path=[IO.Path]::GetFullPath((Join-Path $root ([string]$file.Path))) if(-not$path.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'Module path escaped.'} $cursor=$path while($cursor){$item=Get-Item -LiteralPath $cursor -Force -ErrorAction Stop;if($item.Attributes-band[IO.FileAttributes]::ReparsePoint){throw 'Module link rejected.'};$cursor=[IO.Path]::GetDirectoryName($cursor)} $handle=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) $handles.Add($handle) $sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($handle))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} if($hash-cne[string]$file.Sha256){throw 'Module changed before elevation.'} } $known=@('VSO.psd1','VSO.psm1') foreach($name in $known){if(@($request.Files|Where-Object Path -CEQ $name).Count-ne1){throw 'Module inventory rejected.'}} Import-Module -Name (Join-Path $root 'VSO.psd1') -Force -ErrorAction Stop $allowed=@{ 'Install-VSO'=@('Path','Scope','Version','Prerelease','AssetName','Language','Force'); 'Update-VSO'=@('Path','Version','Prerelease','AssetName','Language','Force'); 'Reset-VSO'=@('Path','Full','RemoveBackups','AcknowledgeBackupLoss','Language','Force'); 'Stop-VSO'=@('Path','Language','Force') } $parameters=@{Quiet=$true;PassThru=$true;Confirm=$false} foreach($property in $request.Arguments.PSObject.Properties){ if($property.Name-cnotin$allowed[[string]$request.Operation]){throw 'Parameter rejected.'} $parameters[$property.Name]=$property.Value } $data=@(& ('VSO\'+[string]$request.Operation) @parameters) $result=@{Success=$true;Data=$data} }catch{$result=@{Success=$false;Message=$_.Exception.Message;ErrorId=$_.FullyQualifiedErrorId}} finally{ foreach($handle in $handles){$handle.Dispose()} $destination=[IO.Path]::GetFullPath([string]$request.Result) $cursor=$destination while($cursor){$item=Get-Item -LiteralPath $cursor -Force -ErrorAction Stop;if($item.Attributes-band[IO.FileAttributes]::ReparsePoint){throw 'Result link rejected.'};$cursor=[IO.Path]::GetDirectoryName($cursor)} $acl=Get-Acl -LiteralPath ([IO.Path]::GetDirectoryName($destination)) if($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value-cne[string]$request.Owner){throw 'Result owner changed.'} $allowedSids=@([string]$request.Owner,'S-1-5-18','S-1-5-32-544') $mask=[Security.AccessControl.FileSystemRights]::Write-bor[Security.AccessControl.FileSystemRights]::Delete-bor[Security.AccessControl.FileSystemRights]::ChangePermissions-bor[Security.AccessControl.FileSystemRights]::TakeOwnership foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){if($rule.AccessControlType-eq'Allow'-and($rule.FileSystemRights-band$mask)-and$rule.IdentityReference.Value-cnotin$allowedSids){throw 'Result permissions changed.'}} [IO.File]::WriteAllText($destination,($result|ConvertTo-Json -Depth 15),[Text.UTF8Encoding]::new($false)) } if(-not$result.Success){exit 1} '@ $bootstrap=$bootstrap.Replace('__REQUEST__',$encodedRequest) $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)) if ($encoded.Length -gt 30000) { Throw-VSOError $Context Elevation @('UAC command exceeds the Windows command-line limit; use an elevated PowerShell session.') } $info=[Diagnostics.ProcessStartInfo]::new() $info.FileName=Join-Path (Get-VSOSystemDirectory $Context) 'WindowsPowerShell\v1.0\powershell.exe' # UAC starts a fresh environment and does not inherit the caller's # process execution policy. The child imports only the pinned inventory. $info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand '+$encoded $info.Verb='runas'; $info.UseShellExecute=$true; $info.WindowStyle=[Diagnostics.ProcessWindowStyle]::Hidden try { $process=Start-VSOElevationProcess $info } catch { $ex=$_.Exception; while ($ex.InnerException) { $ex=$ex.InnerException } if ($ex -is [ComponentModel.Win32Exception] -and $ex.NativeErrorCode -eq 1223) { Throw-VSOError $Context UacCancelled } Throw-VSOError $Context Elevation @($ex.Message) $ex } if (-not $process) { Throw-VSOError $Context Elevation @('no process result') } try { $process.WaitForExit(); $exitCode=$process.ExitCode } finally { $process.Dispose() } $result=Read-VSOJson $resultPath $Context -MaximumBytes 8388608 if (-not (Get-VSOProperty $result Success $false) -or $exitCode -ne 0) { Throw-VSOError $Context Elevation @([string](Get-VSOProperty $result Message ('exit '+$exitCode))) } return $result.Data } finally { Remove-VSOPrivateTree $work $parent $Context } } |