installer: break ACL inheritance on the roots it creates

A directory created directly under C:\ inherits the drive root's DACL, and the
default carries an inherit-only Modify grant for Authenticated Users. So
C:\shopdb-flask, C:\Python314 and C:\MySQL84 were writable by every logged-on
user on the server.

That is a local privilege escalation here specifically, because two things this
installer puts inside those roots are executed by someone else: shopdb-admin.ps1
self-elevates with -Verb RunAs, and instance\config.py is loaded unconditionally
by the app through from_pyfile. Replace either, wait for an administrator or the
app pool to run it, and the code runs as them.

Hardening is applied at creation and RE-APPLIED on every run, so an upgrade over
an installation that predates this repairs the ACL rather than leaving the hole
in place. The MySQL data directory is locked down too: it holds the users table
and its password hashes, and it inherits ProgramData's read-for-Users.

The app pool is granted RX on the Python root alongside the app root. A venv's
python.exe is a copy but the DLL and the standard library are still read from
the base install, so without that grant the site 500s on every request once the
roots stop inheriting.

A Python installation this installer did not create is reported, not seized:
something else may depend on the ACL it has.

Verified as SYSTEM in the Windows 11 VM against the functions as shipped
(extracted by AST, not retyped): the inherited ACL really does grant non-admins
write; after hardening only Administrators and SYSTEM remain; a service identity
granted afterwards keeps access and later-created files inherit it; re-running
is a no-op; a missing path is silently skipped. 14 checks, all passing.
This commit is contained in:
cproudlock
2026-08-14 13:46:03 -04:00
parent ab301df9ac
commit 0c574e0f49

View File

@@ -436,6 +436,43 @@ function Protect-File {
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL' Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL'
} }
function Protect-InstallRoot {
# A directory created directly under C:\ INHERITS the root's DACL, and the
# default C:\ DACL carries an inherit-only Modify grant for Authenticated
# Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by
# every logged-on user until inheritance is broken.
#
# That is a local privilege escalation here specifically, because this
# installer puts two things a non-admin could then replace inside those
# roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and
# instance\config.py, which the app loads unconditionally through
# from_pyfile. Overwrite either, wait for an administrator or the app pool
# to run it, and the code runs as them.
#
# RE-APPLIED EVERY TIME, not only on creation - same reasoning as the
# ProgramData backups directory. An upgrade over an installation that
# predates this must repair the ACL, or the hole survives the fix.
#
# The app pool's own RX/Modify grants are applied in stage 4, AFTER this,
# so breaking inheritance here does not lock the site out of its own tree.
param([string] $Path)
if (-not (Test-Path $Path)) { return }
Write-Log "hardening $Path (breaking inheritance from its drive root)"
Protect-File $Path -Directory
}
function Test-UserWritableRoot {
# Whether a non-admin can write to a path. Used ONLY on roots this
# installer did not create: hardening a Python another application already
# depends on would break that application, so a pre-existing root is
# reported rather than seized. Silence would be worse - the app would be
# running on an interpreter any user can replace.
param([string] $Path)
if (-not (Test-Path $Path)) { return $false }
$acl = & icacls.exe $Path 2>$null
return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)')
}
function New-Secret { function New-Secret {
param([int] $Bytes = 48) param([int] $Bytes = 48)
$b = New-Object byte[] $Bytes $b = New-Object byte[] $Bytes
@@ -461,6 +498,9 @@ function Test-SamePath {
} }
# Paths derived once. # Paths derived once.
# Default so a stage that runs on its own still knows where the interpreter is;
# the Python stage overwrites it with what it actually installed.
$script:PyRoot = 'C:\Python314'
$Py = Join-Path $AppRoot 'venv\Scripts\python.exe' $Py = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' $Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' $Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe'
@@ -956,6 +996,10 @@ Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand.
# bootstrapper returned 0 while installing to the wrong directory. Verify. # bootstrapper returned 0 while installing to the wrong directory. Verify.
if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
Track 'mysql-files' $MysqlRoot Track 'mysql-files' $MysqlRoot
# The service is registered with `mysqld --install` and no obj=, so it runs
# as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write
# here; a user who can replace mysqld.exe owns the service account.
Protect-InstallRoot $MysqlRoot
Write-Log 'MySQL binaries installed' 'OK' Write-Log 'MySQL binaries installed' 'OK'
# --- config ------------------------------------------------------------- # --- config -------------------------------------------------------------
@@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database.
Write-Log 'initializing data directory' Write-Log 'initializing data directory'
Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
Track 'mysql-datadir' $MysqlDataDir Track 'mysql-datadir' $MysqlDataDir
# The data directory holds the users table and its password hashes, and it
# sits under ProgramData, which grants Users read by inheritance - the same
# exposure the backup directory already had to be locked against. mysqld
# runs as LocalSystem, so Administrators + SYSTEM is sufficient.
Protect-File $MysqlDataDir -Directory
# --- service ------------------------------------------------------------ # --- service ------------------------------------------------------------
# Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified # Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified
@@ -1048,6 +1097,9 @@ directory to install cleanly.
# with default ACLs and populated afterwards is readable in the gap. # with default ACLs and populated afterwards is readable in the gap.
$sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null
# Before the file goes in: this may be the first thing that creates AppRoot,
# and the file below holds the root and app database passwords.
Protect-InstallRoot $AppRoot
New-Item -ItemType File -Force -Path $sqlPath | Out-Null New-Item -ItemType File -Force -Path $sqlPath | Out-Null
Protect-File $sqlPath Protect-File $sqlPath
$sql = @" $sql = @"
@@ -1150,8 +1202,17 @@ function Invoke-Stage2 {
# Same class of failure as MySQL's --defaults-file. Never put a space in a # Same class of failure as MySQL's --defaults-file. Never put a space in a
# path this installer controls. # path this installer controls.
$pyTarget = 'C:\Python314' $pyTarget = 'C:\Python314'
# Stage 4 needs it to grant the app pool read access; a venv's python.exe is
# a copy, but python314.dll and the stdlib still come from here.
$script:PyRoot = $pyTarget
if (Test-Path (Join-Path $pyTarget 'python.exe')) { if (Test-Path (Join-Path $pyTarget 'python.exe')) {
Write-Log "Python already present at $pyTarget" 'OK' Write-Log "Python already present at $pyTarget" 'OK'
# NOT hardened: something else installed it and may depend on the ACL it
# has. Say so instead - the app would otherwise run on an interpreter
# any logged-on user can replace.
if (Test-UserWritableRoot $pyTarget) {
Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN'
}
} elseif ($null -eq $pyInstaller) { } elseif ($null -eq $pyInstaller) {
Fail "no Python installer in $BundleRoot\python" Fail "no Python installer in $BundleRoot\python"
} else { } else {
@@ -1164,6 +1225,9 @@ function Invoke-Stage2 {
'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641
Track 'python' $pyTarget Track 'python' $pyTarget
# Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314
# with the drive root's inherited Authenticated Users Modify.
Protect-InstallRoot $pyTarget
} }
} }
$sysPy = Join-Path $pyTarget 'python.exe' $sysPy = Join-Path $pyTarget 'python.exe'
@@ -1174,6 +1238,7 @@ function Invoke-Stage2 {
New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
Track 'dir' $AppRoot Track 'dir' $AppRoot
} }
Protect-InstallRoot $AppRoot
# --- upgrade detection --------------------------------------------------- # --- upgrade detection ---------------------------------------------------
$script:BundleVersion = Get-BundleVersion $script:BundleVersion = Get-BundleVersion
$script:InstalledVersion = Get-InstalledVersion $script:InstalledVersion = Get-InstalledVersion
@@ -2022,6 +2087,13 @@ has to come from the bundle either way.
$ident = "IIS AppPool\$AppPool" $ident = "IIS AppPool\$AppPool"
Write-Log "granting $ident access" Write-Log "granting $ident access"
Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot'
# The Python root no longer inherits from the drive root, so the pool has
# no access to it by default. The venv's python.exe is a copy; the DLL
# and the standard library are still read from the base install, so
# without this grant the site 500s on every request.
if ($script:PyRoot -and (Test-Path $script:PyRoot)) {
Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root'
}
foreach ($sub in @('logs','instance')) { foreach ($sub in @('logs','instance')) {
Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
} }