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:
@@ -436,6 +436,43 @@ function Protect-File {
|
||||
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 {
|
||||
param([int] $Bytes = 48)
|
||||
$b = New-Object byte[] $Bytes
|
||||
@@ -461,6 +498,9 @@ function Test-SamePath {
|
||||
}
|
||||
|
||||
# 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'
|
||||
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.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.
|
||||
if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
|
||||
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'
|
||||
|
||||
# --- config -------------------------------------------------------------
|
||||
@@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database.
|
||||
Write-Log 'initializing data directory'
|
||||
Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
|
||||
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 ------------------------------------------------------------
|
||||
# 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.
|
||||
$sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
|
||||
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
|
||||
Protect-File $sqlPath
|
||||
$sql = @"
|
||||
@@ -1150,8 +1202,17 @@ function Invoke-Stage2 {
|
||||
# Same class of failure as MySQL's --defaults-file. Never put a space in a
|
||||
# path this installer controls.
|
||||
$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')) {
|
||||
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) {
|
||||
Fail "no Python installer in $BundleRoot\python"
|
||||
} else {
|
||||
@@ -1164,6 +1225,9 @@ function Invoke-Stage2 {
|
||||
'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
|
||||
) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641
|
||||
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'
|
||||
@@ -1174,6 +1238,7 @@ function Invoke-Stage2 {
|
||||
New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
|
||||
Track 'dir' $AppRoot
|
||||
}
|
||||
Protect-InstallRoot $AppRoot
|
||||
# --- upgrade detection ---------------------------------------------------
|
||||
$script:BundleVersion = Get-BundleVersion
|
||||
$script:InstalledVersion = Get-InstalledVersion
|
||||
@@ -2022,6 +2087,13 @@ has to come from the bundle either way.
|
||||
$ident = "IIS AppPool\$AppPool"
|
||||
Write-Log "granting $ident access"
|
||||
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')) {
|
||||
Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user