Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)

- Loader 1.3 downloads into a protected run folder of its own
  (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
  is signed by the pinned publisher and declares the installer role and version
  2.7 or later, removes the folder after a successful run and never touches the
  agent folder. Unbound arguments now reach the installer by name; the array
  splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
  read HKLM through the 64-bit view (a 32-bit host never saw the deployment
  marker), guard 5 is covered by the agent check. An agent's folder stays as it
  is, a skip without agent removes the folder, an install starts from an emptied,
  protected folder and stages the ZIP in a run folder. It moves the MSI's working
  directory out, writes stderr only on failure and removes the copy older loaders
  left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
  Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
  (#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
  publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
  Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
  (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
  the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
  a real standard user.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-22 13:34:54 +02:00
parent 9377671349
commit 3a898b5071
21 changed files with 3465 additions and 599 deletions
@@ -0,0 +1,72 @@
<#
.SYNOPSIS
Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device.
.DESCRIPTION
Registers a one-time scheduled task as SYSTEM that runs
scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it,
prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as
SYSTEM, the tests use the real folder permissions, including Administrators as owner.
CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all
users.
#>
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path,
[int]$TimeoutMinutes = 20
)
$ErrorActionPreference = 'Stop'
$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID"
New-Item -ItemType Directory -Force -Path $work | Out-Null
$runner = Join-Path $work 'run.ps1'
$output = Join-Path $work 'output.txt'
$result = Join-Path $work 'result.txt'
$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1'
$runnerText = @'
$ErrorActionPreference = 'Stop'
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
try {
Import-Module Pester -MinimumVersion 5.5.0
$configuration = New-PesterConfiguration
$configuration.Run.Path = '__TESTS__'
$configuration.Run.PassThru = $true
$configuration.Output.Verbosity = 'Detailed'
$run = Invoke-Pester -Configuration $configuration
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount)
} catch {
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message)
}
'@
Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result))
$taskName = "apm-bootstrap-system-pass-$PID"
$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`""
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
try {
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
do {
Start-Sleep -Seconds 5
$state = (Get-ScheduledTask -TaskName $taskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
if ($state -eq 'Running') {
Stop-ScheduledTask -TaskName $taskName
throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes."
}
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output }
if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' }
$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3)
if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." }
if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" }
if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." }
Write-Host "SYSTEM pass: $passed passed, 0 failed."
+60 -18
View File
@@ -17,7 +17,7 @@
cosmetics.
Steps, in order:
1. Parse $ScriptVersion from the loader and the bootstrap source.
1. Parse the $ScriptVersion line from the loader and the bootstrap source.
2. Render the -Dev variants by literal substitution of the URL/manifest
defaults. A missing anchor is a hard failure -- a silently un-substituted
dev script would point the dev fleet at the stable agent.
@@ -25,17 +25,21 @@
carries the same $ScriptVersion, abort. Otherwise the docs badge,
version.json.bootstrapVersion and the portal's "your script is outdated"
hint would all keep asserting a version that is no longer what ships.
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
4. Build the publish set: both installers, then both loaders, then the extras.
A loader that went live before the installer it requires would refuse the
installer still being served. -Channel dev keeps only the two -Dev renders.
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
and nothing may touch them afterwards or the signature breaks.
6. Upload every script blob with Cache-Control: no-cache (they rotate in
6. Run the loader's own identity check on the exact bytes of both installers:
what fails here, every device would refuse.
7. Upload every script blob with Cache-Control: no-cache (they rotate in
place), mirrored fail-soft to the legacy account.
7. Reconcile the version oracles: version.json.bootstrapVersion and
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion
and .loaderVersion (read-modify-write under If-Match, so a concurrent agent
release cannot lose its agent fields) and
AdminConfiguration.LatestBootstrapV2ScriptVersion.
8. Verify through the alias -- re-download each blob and compare SHA-256.
9. Verify through the alias -- re-download each blob and compare SHA-256.
Comparisons against a published script always strip its signature block first
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
@@ -67,6 +71,11 @@
Optional directory that receives a copy of every published file exactly as uploaded.
Used by CI to offer the signed scripts as a build artifact for local inspection.
.PARAMETER Channel
release (default): every script, plus version.json and the portal's script version.
dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a
script change can run on dev devices, signed, before it goes to customers.
.EXAMPLE
./Publish-BootstrapScripts.ps1 -DryRun
@@ -83,6 +92,8 @@ param(
[string]$CertificateName,
[string]$ExpectedPublisher,
[string]$SignedOutputDir,
[ValidateSet('release', 'dev')]
[string]$Channel = 'release',
[switch]$DryRun
)
@@ -293,7 +304,9 @@ function Assert-VersionBump {
function Get-BootstrapScriptVersion {
param([string]$Content, [string]$Origin, [switch]$Optional)
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
# The assignment line itself, as the loader reads it: a comment or help text that mentions
# $ScriptVersion must not count.
if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] }
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
# the state a republish is meant to repair, and a guard that blocks the repair is worse
# than no guard.
@@ -301,6 +314,19 @@ function Get-BootstrapScriptVersion {
throw "Could not parse ScriptVersion from $Origin"
}
# The loader's own check, run on the exact bytes that will be served: dot-sourced in this
# function's scope, so none of the loader's variables reach the publisher's.
function Assert-LoaderAcceptsInstaller {
param([object]$Item, [string]$WorkDir)
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$path = Join-Path $WorkDir $Item.BlobName
[System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes)
. $LoaderSource
if (-not (Test-InstallerIdentity -Path $path)) {
throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded."
}
}
# What the script actually DOES, with comments and layout removed: the PowerShell token
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
# a behaviour change (must be versioned -- consumers act on the version) apart from a
@@ -353,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
# ------------------------------------------------------------------ 3. bump guards
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
# The versions are what customers act on, so the guard watches the release blobs only.
if ($Channel -eq 'release') {
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
}
# ------------------------------------------------------------------ 4. publish set
# Sources beyond the bootstrap pair, read straight from the repo.
@@ -369,10 +398,15 @@ $extraSources = @(
# Text is the unsigned publish form and stays the reference for every comparison against a
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
$publishSet = [System.Collections.Generic.List[object]]::new()
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
if ($Channel -eq 'dev') {
$publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) })
$extraSources = @()
Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only"
}
foreach ($extra in $extraSources) {
$extraPath = Join-Path $RepoRoot $extra.Path
if (-not (Test-Path $extraPath)) {
@@ -391,13 +425,18 @@ if ($SigningEnabled) {
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
}
# ------------------------------------------------------------------ 6. loader check
foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) {
Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID")
}
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
# ------------------------------------------------------------------ 6. upload
# ------------------------------------------------------------------ 7. upload
foreach ($item in $publishSet) {
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
@@ -464,11 +503,12 @@ foreach ($item in $publishSet) {
}
}
# ------------------------------------------------------------------ 7. version oracles
# ------------------------------------------------------------------ 8. version oracles
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
# agent release cannot lose its version/sha256 fields.
if (-not $DryRun) {
# agent release cannot lose its version/sha256 fields. They describe the release scripts, so
# a dev publish leaves them alone.
if (-not $DryRun -and $Channel -eq 'release') {
try {
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
@@ -512,7 +552,9 @@ if (-not $DryRun) {
}
}
if ($DryRun) {
if ($Channel -ne 'release') {
Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are"
} elseif ($DryRun) {
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
} else {
$tableHeaders = @{
@@ -525,7 +567,7 @@ if ($DryRun) {
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
}
# ------------------------------------------------------------------ 8. verify via alias
# ------------------------------------------------------------------ 9. verify via alias
if (-not $DryRun) {
$pending = [System.Collections.ArrayList]::new()
$publishSet | ForEach-Object { [void]$pending.Add($_) }
@@ -555,7 +597,7 @@ if (-not $DryRun) {
if ($env:GITHUB_STEP_SUMMARY) {
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
$summary = @(
"### Bootstrap scripts: $mode",
"### Bootstrap scripts: $mode (channel $Channel)",
'',
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
+176
View File
@@ -0,0 +1,176 @@
<#
.SYNOPSIS
Checks the bootstrap's folders against a real standard user.
.DESCRIPTION
Creates a temporary local standard user and, with that user's token, tries what any local user
on an enrolling device could try against the folders the loader and the installer create:
write, change, rename and delete files, add entries, move or delete the folder. Every attempt
must be denied. Folders the user creates first, or entries the user plants in an unprotected
agent folder of an older installer, must make the installer stop instead of installing into
them. A control attempt proves the user can write where users may.
The folders come from the installer's own functions, in a scratch copy of ProgramData that
carries the real ProgramData permissions.
CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local
account and removes it again.
#>
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
)
$ErrorActionPreference = 'Stop'
# The user's token, for impersonation in this process (a CI helper; customer scripts never
# compile code).
Add-Type -Namespace ApmCi -Name Native -MemberDefinition @'
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
'@
. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1')
$results = New-Object System.Collections.Generic.List[object]
function Invoke-AsUser {
param([scriptblock]$Action)
[System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action)
}
# An attempt the user must not get through: it has to fail with "access denied", not with
# any other error that would only hide a broken test.
function Assert-Denied {
param([string]$Name, [scriptblock]$Attempt)
$outcome = 'allowed'
try {
Invoke-AsUser $Attempt
} catch {
$base = $_.Exception.GetBaseException()
$outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" }
}
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome })
}
function Assert-That {
param([string]$Name, [bool]$Condition, [string]$Detail = '')
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) })
}
# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created
# entries differs between Windows editions.
function Set-AdministratorsOwner {
param([string]$Path)
$security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner')
$security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))
(Get-Item -LiteralPath $Path -Force).SetAccessControl($security)
}
$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$password = [guid]::NewGuid().ToString('N') + 'Aa1!'
$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$pd = Join-Path $root 'ProgramData'
New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null
try {
try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } }
$token = $null
# Interactive logon first (what a local user has); network logon where a server allows no
# local logon for Users.
if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and
-not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) {
throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())."
}
$script:token = $token
$userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier])
$script:probe = $null
Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() }
Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)"
Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
# A scratch ProgramData with the real one's permissions.
New-Item -ItemType Directory -Path $pd -Force | Out-Null
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access')
# Nothing from the drive root, whose default lets authenticated users change subfolders.
$security.SetAccessRuleProtection($true, $false)
(Get-Item -LiteralPath $pd).SetAccessControl($security)
# Control: the user may create in ProgramData, so every denial below is the folders' doing.
$control = Join-Path $pd 'user-may-create-here'
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') }
Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt'))
# The loader's run folder with a downloaded installer in it.
$run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
$download = Join-Path $run 'Install-AutopilotMonitor.ps1'
[System.IO.File]::WriteAllText($download, 'verified installer')
Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') }
Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null }
Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') }
Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') }
Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') }
Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) }
Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') }
Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) }
Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer')
# The agent folder an install prepares.
$base = Join-Path $pd 'AutopilotMonitor'
Initialize-AgentBase -Path $base
$logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName
[System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log')
Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null }
Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') }
Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) }
Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') }
$owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner
[System.IO.Directory]::Delete($base, $true)
# The user creates the agent folder first: the installer must neither install into it nor
# remove it.
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign')
$refused = $false
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' }
Assert-That 'squatted agent folder: the install refuses it' $refused
Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).')
Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))
[System.IO.Directory]::Delete($base, $true)
# An agent folder an older installer created without protection: the user plants a file,
# the install protects the folder and stops at the planted file.
[System.IO.Directory]::CreateDirectory($base) | Out-Null
[System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old')
Set-AdministratorsOwner $base
Set-AdministratorsOwner (Join-Path $base 'old-installer.log')
Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
$refused = $false
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' }
Assert-That 'planted entry: the install stops' $refused
Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll')))
Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') }
[System.IO.Directory]::Delete($base, $true)
# The user creates a run folder name first: it is never used.
$squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000'
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null }
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111'))
function New-RunFolderName { return $script:names.Dequeue() }
$fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh
$squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier])
Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid)
} finally {
if ($script:token) { $script:token.Dispose() }
Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
$failed = @($results | Where-Object { $_.Actual -ne $_.Expected })
if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." }
Write-Host "Attacker test: all $($results.Count) checks passed."