Repository navigation
Install and upgrade lock the Darling install folder, so an ordinary local user can no longer replace the service's binaries (#4034) - #4038
Merged
Conversation
…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
…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
marked this pull request as ready for review
September 23, 2026 14:16
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
5 tasks done
This was referenced Sep 23, 2026
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>
3 tasks done
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
…4038-shaped leftover Modify ACE
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4034.
Why
The README's install location,
C:\PerformanceMonitorDarling, is a folder made directly underC:\, and such a folder inheritsNT AUTHORITY\Authenticated Users:(M)from the volume root (checked withicacls C:\). Any local user could replacePerformanceMonitor.Darling.Service.exe, a DLL, orpg-runtime\pgsql\bin\postgres.exe, and their code would run as the service account at the next start.install-darling.ps1hardened 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:icacls /inheritance:d, which keeps SYSTEM and Administrators full control as explicit ACEs).pg-runtimeinto the tree.install-darling.ps1step 4b2, after the secret files are hardened and before the first start.upgrade-darling.ps1runs it after the overlay copy, before the service starts, so every existing install is closed at its next upgrade.Set-Aclon an objectGet-Aclread also writes the SACL, which needsSeSecurityPrivilege.icaclstouches only the DACL, and it's the same set of commands the warning prints for fixing it by hand.darling.jsonand the credential blobs keep their own DACLs. Logs and the managed PostgreSQL data live under ProgramData and are outside this tree.icacls /resetfix.Test plan
TheInstallTreeLock_ShipsIdenticallyInTheInstallAndUpgradeScripts: the two copies, comment included, are byte-identical.TheInstallTreeLock_ClosesTheInheritedGrant_KeepsProtectedFiles_AndReportsWhatItCannotCloseruns the shipped function under Windows PowerShell 5.1, against a tree planted directly under the system drive root that inheritsAuthenticated Users:(M). Afterwards:ReadAndExecute;Modify, whichpostgres.exeinherits;darling.jsonkeeps its protected DACL and INTERACTIVE read;pg-runtimeunder the lock.🤖 Generated with Claude Code
https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv