Skip to content

Install and upgrade lock the Darling install folder, so an ordinary local user can no longer replace the service's binaries (#4034) - #4038

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/4034-install-dir-dacl
Sep 23, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/4034-install-dir-dacl

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #4034.

Why

The README's install location, C:\PerformanceMonitorDarling, is a folder made directly under C:\, and such a folder inherits NT AUTHORITY\Authenticated Users:(M) from the volume root (checked with icacls C:\). Any local user could replace PerformanceMonitor.Darling.Service.exe, a DLL, or pg-runtime\pgsql\bin\postgres.exe, and their code would run as the service account at the next start. install-darling.ps1 hardened only the secret files. Found by the round-1 security review of #4031, outside that PR's diff.

What changes

  • Lock-DarlingInstallTree, byte-identical in both scripts:
    • Stops the root inheriting from the volume root (icacls /inheritance:d, which keeps SYSTEM and Administrators full control as explicit ACEs).
    • Removes Authenticated Users, Users, Everyone and INTERACTIVE.
    • Grants back Users read and execute and the service account Modify. The service held Modify before through Authenticated Users, and it still needs it to extract pg-runtime into the tree.
    • Then reads every directory and file back and returns anything a broad principal can still write.
  • Where it runs: install-darling.ps1 step 4b2, after the secret files are hardened and before the first start. upgrade-darling.ps1 runs it after the overlay copy, before the service starts, so every existing install is closed at its next upgrade.
  • Why icacls: Set-Acl on an object Get-Acl read also writes the SACL, which needs SeSecurityPrivilege. icacls touches only the DACL, and it's the same set of commands the warning prints for fixing it by hand.
  • Unchanged: protected files such as darling.json and the credential blobs keep their own DACLs. Logs and the managed PostgreSQL data live under ProgramData and are outside this tree.
  • What a folder lock can't fix: a child that grants a broad principal write explicitly, behind its own protection, keeps that grant. It is named in the warning with its icacls /reset fix.
  • README: the install section says the folder is locked, and that upgrades lock older installs.

Test plan

  • TheInstallTreeLock_ShipsIdenticallyInTheInstallAndUpgradeScripts: the two copies, comment included, are byte-identical.
  • TheInstallTreeLock_ClosesTheInheritedGrant_KeepsProtectedFiles_AndReportsWhatItCannotClose runs the shipped function under Windows PowerShell 5.1, against a tree planted directly under the system drive root that inherits Authenticated Users:(M). Afterwards:
    • the root is protected with no Authenticated Users;
    • Users have ReadAndExecute;
    • the service has Modify, which postgres.exe inherits;
    • darling.json keeps its protected DACL and INTERACTIVE read;
    • a child with its own broad grant is reported.
  • Red-watch: with the strip step removed from the shipped script, the functional test fails.
  • Every class that reads either script passes, 176/176: InstallLocation, DeployStaleFile, DeployRollbackRetention, FileSecurity, FirewallCheck, HardenFilesVerb, RuntimePreflight and ServiceInstallLocation. Build: 0 warnings.
  • Both scripts parse under Windows PowerShell 5.1, and stay ASCII with CRLF. The upgrade warning's format string was executed once with stub values.
  • An end-to-end install and upgrade on a real box (DARLING01), with the service starting and extracting pg-runtime under the lock.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

…ocal user can no longer replace the service's binaries (#4034)

The documented install location, a folder made directly under C:\, inherits
"Authenticated Users: Modify" from the volume root. Any local user could replace
the service exe, a DLL, or pg-runtime's postgres.exe and run code as the service
account. install-darling.ps1 hardened only the secret files.

Lock-DarlingInstallTree, byte-identical in install-darling.ps1 (new step 4b2)
and upgrade-darling.ps1 (before the service starts, so an older install is closed
at its next upgrade):
- stops the root inheriting (icacls /inheritance:d keeps SYSTEM and Administrators
  full control as explicit ACEs);
- removes Authenticated Users, Users, Everyone and INTERACTIVE;
- grants back Users read and execute, and the service account Modify, which it
  held before through Authenticated Users and still needs to extract pg-runtime.
It uses icacls, not Set-Acl: Set-Acl on what Get-Acl read also writes the SACL,
which needs SeSecurityPrivilege, and icacls matches the by-hand remediation.
It then reads every directory and file back and returns anything a broad
principal can still write. A child's own explicit grant survives a folder lock,
so it is named in the warning with its icacls /reset fix. Protected files such
as darling.json keep their DACL.

Tests (DarlingInstallLocationTests):
- the two copies are byte-identical;
- the shipped function runs under Windows PowerShell 5.1 against a planted tree
  under the system drive root. The inherited grant is gone, Users get RX, the
  service Modify, postgres.exe inherits, darling.json keeps its DACL, and a child
  with its own broad grant is reported. Red-watched: without the strip step the
  test fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata and others added 2 commits September 23, 2026 09:53
…locks before its overlay, and a LocalSystem or .\ logon account no longer leaves the tree open

Round-1 security review of this PR:
- MEDIUM, the upgrade path failed open: it passed Win32_Service.StartName
  straight to the lock. LocalSystem and a .\ account don't translate to a SID
  as written, so the lock threw and the tree was never locked behind a
  warning. The shared function now normalizes them exactly as
  install-darling.ps1 reads its own account (LocalSystem is NT
  AUTHORITY\SYSTEM, a leading .\ is this computer), and the upgrade
  normalizes before it prints the fix too.
- MEDIUM, the plant-before-lock window. install-darling.ps1 locks the tree at
  a new step 1b2, before the pre-flight or anything else runs from it, with
  no service to grant yet; 4b2 re-runs the lock with the account once sc.exe
  create has made it. upgrade-darling.ps1 locks BEFORE laying the new build
  down, and re-verifies after. What no lock can undo, a file swapped before
  the script runs, is documented (extract fresh, run straight away) and
  filed as #4043, with narrowing the service's Modify to pg-runtime.
- LOW: the verification reports any junction or link below the root, which
  the walk does not descend and a real install never holds.

Tests: the functional test now runs the two-phase install flow (no grant,
then grant), plants a junction, and calls the lock with LocalSystem and a .\
account. Red-watched: without the LocalSystem normalization it fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…n a file, stops the install when anything stays open, and never strips the service's grant around a guessed account

Round-2 security review of this PR:
- HIGH: whoever OWNS an object can rewrite its DACL, and a child that
  grants a specific account write keeps that grant through a folder lock.
  A local user who created the folder in advance, or re-created a file
  while an older install was open, could grant themselves Full Control
  again and overwrite svc.exe, and a second run reported 0 open. The lock
  now makes Administrators the owner of the tree (icacls /setowner /T /L),
  with darling.json and its backups returned to the service account. It
  closes every explicit write grant held by an account outside SYSTEM,
  Administrators, TrustedInstaller, CREATOR OWNER and the service:
  darling.json and its backups lose just that grant; anything else is reset
  to the tree's ACEs. The walk then reports any owner or writer still
  outside that set.
- MEDIUM: step 1b2 stops the install (Fail) when anything stays open,
  instead of going on to run the exe elevated from the folder.
- LOW: an upgrade through install-darling.ps1 passes the existing service's
  account at 1b2, since the lock would otherwise strip its grant. It stops
  if that account can't be read, as step 4 does.
- LOW: upgrade-darling.ps1 reads the account through the same lookup, with
  the sc.exe qc fallback. When it can't be read, the lock is SKIPPED with a
  warning rather than run around a guess.
- LOW: a root that is itself a junction is refused, not locked through.
- LOW: the upgrade locks as soon as the service stops, before the rollback
  backup is written.
- Found while testing: Windows PowerShell 5.1 turns icacls' redirected
  stderr into a terminating error under the scripts' Stop preference. The
  function sets its own preference and judges icacls by exit code and
  output, and an object whose permissions can't be read is reported.

Get-DarlingServiceLogonName, the one account lookup for step 4, 1b2 and the
upgrade, is byte-identical in both scripts, and the test compares it. The
functional test adds a user-owned, self-granted file and folder (closed and
reset), darling.json as step 4b leaves it, and a junction root (refused).
Elevated, it asserts ownership went to Administrators. Not elevated, it
asserts every remaining entry is an object this user still owns, never a
writer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 23, 2026 14:16
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 23, 2026 14:16
… parent's PSModulePath, so Get-Acl and Set-Acl load under a PowerShell 7 test host

#4038's first CI run failed the lock's functional test with 'The Set-Acl
command was found in the module Microsoft.PowerShell.Security, but the module
could not be loaded': CI's step shell is PowerShell 7, and the PSModulePath it
hands down points Windows PowerShell 5.1 at PowerShell 7's own copy of that
module. Removing the variable lets 5.1 build its default, as an operator's
console does. Reproduced locally by running the test with PowerShell 7's real
module folder in PSModulePath: fails without the change, passes with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
@erikdarlingdata
erikdarlingdata merged commit 8133783 into dev Sep 23, 2026
14 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4034-install-dir-dacl branch September 23, 2026 14:43
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…#4038 lock's own grant (#4043)

Round-1 review found the pre-lock check refused C:\Program Files and C:\Program Files\dotnet
(CREATOR OWNER is an inherit-only template, not a real grant) and would false-refuse any re-run
or upgrade over a tree #4038 already locked (the service account's own Modify grant read back as
a stranger's). Excludes the four CREATOR */SERVER inherit-only template SIDs by SID, adds a direct
owner check (covers OWNER RIGHTS, which is not one of those four), and lets Get-DarlingPreLockTrustedSids
trust an already-registered service's current logon account. Adds four realistic-ACL test cases,
including a red-watch proving the old code refused Program Files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…re the lock (#4043) (#4050)

* Darling install scripts refuse an install tree ordinary users can already write to (#4043)

The #4038 lock only stops writes from the moment it runs; it cannot undo one made before it existed. A
folder made directly under C:\ inherits Authenticated Users: Modify from the volume root the instant it
is extracted, so a local user could swap the service exe, a DLL, or a pg-runtime binary before
install-darling.ps1 ever reaches its lock step, and the lock would never notice.

install-darling.ps1 now checks the install root and the service exe's own ACL first (step 1a, before
1b2's lock), naming any principal outside SYSTEM/Administrators/TrustedInstaller/the installing admin
that already holds a write grant, and refuses unless -AcceptWritableExtraction is passed for a
deliberate dev loop. upgrade-darling.ps1 carries the same check on a folder -Source before it copies
that folder's content over the already-locked install root - a folder -Source has no content-hash check
the way a zip -Source does, so it is its own pre-lock writable-extraction window.

The Authenticode/hash-manifest options from the original issue are withdrawn per the issue's own revised
plan: a check run BY this script only catches an attacker who leaves the script untouched, and the same
attacker could replace the script itself.

README updated to prefer C:\Program Files\PerformanceMonitorDarling over a bare C:\ folder, and to
describe the new refusal.

Part 2 (narrowing the service account's Modify to pg-runtime only) is not done - see the PR body for the
audit and why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Round-1 fixes: CREATOR OWNER false refusal, owner check, and trust the #4038 lock's own grant (#4043)

Round-1 review found the pre-lock check refused C:\Program Files and C:\Program Files\dotnet
(CREATOR OWNER is an inherit-only template, not a real grant) and would false-refuse any re-run
or upgrade over a tree #4038 already locked (the service account's own Modify grant read back as
a stranger's). Excludes the four CREATOR */SERVER inherit-only template SIDs by SID, adds a direct
owner check (covers OWNER RIGHTS, which is not one of those four), and lets Get-DarlingPreLockTrustedSids
trust an already-registered service's current logon account. Adds four realistic-ACL test cases,
including a red-watch proving the old code refused Program Files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Recursive pre-lock ACL walk, protected zip staging, and an install-root check (#4043 round-1)

Addresses round-1 security review findings on the #4043 pre-lock ACL check: H1 (zip -Source hashed and
extracted from a protected staging copy), M1 (docs point at a protected staging folder, not C:\staging),
M2 (the pre-lock walk is now recursive, with a direct-Administrators-member trust exception), M3
(upgrade-darling.ps1 now refuses a pre-existing writable -InstallRoot before its lock), and L1-L5 wording
and message fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Every pointer to the install folder names C:\Program Files\PerformanceMonitorDarling, so the #2185 refusal no longer sends people into the #4043 one (#4043)

install-darling.ps1's profile-path refusal (#2185) told users to move the install to
C:\PerformanceMonitorDarling. A folder made directly under C:\ inherits Authenticated Users: Modify,
and this PR's pre-lock check refuses exactly that folder, so following one refusal landed users in
the other. The refusal, the script's help text, the service's own install-location message
(DarlingInstallLocation.DocumentedInstallDirectory), the README's commands and the retention runbook
all name C:\Program Files\PerformanceMonitorDarling now. The refusal also says to extract fresh
rather than move, as the #4043 message does. The README's manual sc create quotes the binPath, which
the documented path now needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* The UAT walkthrough extracts to C:\Program Files\PerformanceMonitorDarling from an elevated shell, so following it no longer ends in the #4043 refusal (#4043)

bf2f5bc swept the README, the scripts, the runbook and the service's own messages, but missed
docs/uat-onboarding.md, which still called C:\PerformanceMonitorDarling "the documented location and
you should use it". Someone following it step by step would reach 1.6 and have install-darling.ps1
refuse the folder the doc told them to make.

- 1.2 now says to verify from an elevated PowerShell in the download folder, since 1.3 extracts into
  Program Files, which only administrators can write.
- 1.3 extracts to the Program Files path and says why a folder made directly under C:\ is refused.
- 1.4 says to stay elevated so Notepad can save darling.json there.
- Every later command quotes the path, and the bare exe invocations gain the call operator (&) that a
  quoted path needs.
- A test comment explaining #1647 names the documented folder, which inherits BUILTIN\Users read and
  execute from Program Files rather than from the root DACL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* The upgrade's zip-staging folder ends up SYSTEM and Administrators only even when it starts with explicit entries (#4043)

CI's elevated runner failed ProtectDarlingStagingFolder_LocksTheFolderToSystemAndAdministratorsOnly with
three explicit rules where two were expected. icacls /inheritance:r strips only INHERITED entries. A
folder created under a parent that passes nothing on gets its creator's default DACL as EXPLICIT entries,
and those survived the strip, so the folder kept a third full-control grant. Protect-DarlingStagingFolder
now runs /reset first, which replaces every explicit entry with what the parent passes on; /inheritance:r
then removes those, and the two grants are the whole DACL.

A new test builds a parent that passes nothing on, so any machine reproduces the case, elevated or not.
It asserts the folder starts with explicit entries, then that only SYSTEM and Administrators remain.
Red-watch: with /reset removed the new test fails; with it, both staging tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* A folder the writable-tree walk cannot list is a finding, not a silent skip (#4043 round-2 review)

- Get-UntrustedWriteGrantees (install and upgrade, still identical) captures the recursive
  Get-ChildItem's errors and reports each unlisted folder, so a subtree that refuses listing
  can no longer hide a swapped binary behind a clean-looking result.
- The loader diagnosis names the documented install directory from the one constant, which
  moves to a platform-neutral DarlingInstallPaths so any platform can name it (CA1416).
- The staging-lock test plants its explicit entry directly instead of relying on the
  creator's default DACL, which CI's runner does not supply.
- Wiring pins: SHA256SUMS.txt is read once, only after its folder passes the check and its
  refusal; each script checks its tree before anything that changes state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* UAT walkthrough: three service-exe calls run by full path, and the export says to stay elevated (#4043 round-2 addendum)

The --configure-firewall, --export-viewer-config and --print-web-token blocks called .\ with no cd
of their own, so they only worked in the same shell as an earlier cd. --export-viewer-config writes
viewer-config\ beside the service's darling.json inside Program Files, so the step now says to stay
elevated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…4069)

A folder made under C:\ inherits BUILTIN\Users as two ACEs, and dev's
upgrade refused DARLING01's pre-#4038 install root with "BUILTIN\Users on
C:\PerformanceMonitorDarling" listed twice. Two of the four callers of
Get-UntrustedWriteGrantees dropped repeated lines and two did not.

The function now returns each finding once (both script copies change
identically, so the byte-identity test still holds), and the two
call-site de-duplications it replaces are removed. New test: two write
ACEs for one principal produce one finding; red before the fix (count=2).


Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ntries in their sections (#4080)

Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran.

#3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased].


Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 24, 2026
erikdarlingdata added a commit that referenced this pull request Sep 24, 2026
…ll root and Modify only where it writes (#4052) (#4090)

* wip(4052): doc-comment and signature prep for narrowed service Modify grant (incomplete, see PR body)

* fix(4052): narrow the service account's install-tree grant to Read&Execute + Modify on pg-runtime/pg-runtime-prev

Lock-DarlingInstallTree (install-darling.ps1, upgrade-darling.ps1) previously granted the service
account Modify on the whole install tree. It now grants Read & Execute on the root and Modify only
on pg-runtime\, pg-runtime-prev\, and any $extraServiceDirectories entry (created ahead of time if
missing). The verify/close walk trusts the service SID only under those specific paths - a write
grant it holds anywhere else in the tree is treated like a stranger's and closed - and re-grants
Modify on a service write directory immediately after closing a planted ACE there, so the real
grant is never left stripped. Byte-identical between both scripts, as DarlingInstallLocationTests
checks.

Continuation of #4052 (see PR #4090 body for the accepted research this builds on).

* fix(4052): narrow the root grant with /grant:r, add BYO-Postgres extra service directory helper, update tests

* fix(4052): use /grant:r for the service SID's root grant, closing a #4038-shaped leftover Modify ACE

* 4052: keep the service trusted on darling.json, hand the key folder over as a name, avoid PS 5.1's ambiguous Split-Path

Found on a PowerShell 5.1 run of the lock on a standalone Windows 11 box, with a
real (untrusted) service account and step 4b's darling.json ACL:
- the narrowed trust stripped the service's own FullControl from darling.json
  and its backups (the explicit-ACE branch), so the service could not read its
  config, and reported both files as owned by a stranger;
- Get-DarlingExtraServiceWriteDirectories returned a rooted path, which the
  lock Join-Paths onto the root (C:\a + C:\a\b = C:\a\C:\a\b);
- Split-Path -LiteralPath -Parent is an ambiguous parameter set on 5.1 and
  threw on every bring-your-own-Postgres install.
The live lock test runs as TrustedInstaller (trusted everywhere), so it could
not see the first. A static pin covers all three.

* 4052: the lock's success line no longer says the service account can change what runs from the root

* 4043 pre-lock test: look for the service's grant where #4052 moved it (the runtime folders), recursing like every real caller

After #4052 the service holds RX on the root and Modify only on pg-runtime and
pg-runtime-prev, so a root-only probe finds nothing untrusted and the test's
premise (without the account > 0) cannot hold. The real checks all run
Get-UntrustedWriteGrantees -Recurse, so the probe does too. Verified on a
Windows 11 VM against the branch's own functions: tree without the account = 2
(the two runtime folders), with it = 0, root alone = 0 (pinned as the #4052
gain).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant