Repository navigation
Harden for the registered service account, not the caller - #2372
Conversation
--harden-files resolved the account to grant from WindowsIdentity.GetCurrent(), so running it the documented way -- elevated, from something that is not the service -- wrote the ACL for the operator and STRIPPED the account the service runs as. Measured on a live box: an elevated run removed NT SERVICE\PerformanceMonitor Darling from all four targets and printed "All 4 item(s) secured" while doing it. The service kept running on its open handles and would have failed on the next start, unable to read darling.json or either DPAPI credential -- the failure #2185/#2197 exist to diagnose, manufactured by the tool meant to prevent it. Every original caller of DarlingFileSecurity runs INSIDE the service, so the current identity and the service account were the same value and the distinction did not exist. This verb inverts that by construction: it exists because a virtual service account cannot re-ACL a file it does not own, so its caller is never the service. The account now comes from the SCM's ObjectName, which also covers the re-homed domain-account and gMSA cases the property's own doc comment was already worried about, and falls back to the caller when the service is not registered (console run, or hardening before install). The verify pass gains the other half. IsReadableByOrdinaryUsers asks whether anyone too many can read; it cannot see the opposite failure, and an ACL excluding ordinary users AND the service is maximally private and completely broken. That is why the live run reported success on four locked-out targets. A lockout now reports LOCKED OUT and exits non-zero. Fixes #2371
|
Reviewed. This is Darling-only (Windows service ACL hardening) — confirmed there's no Lite counterpart ( The core fix (resolve the account to harden for from the SCM's Left three inline comments on things that weaken the new safety net or produce misleading operator-facing output:
None of these affect the primary bug fix itself (which only ever grants |
| else if (!DarlingFileSecurity.GrantsHardenedAccount(target.Path)) | ||
| { | ||
| /* #2371: private is only half of correct. An ACL that excludes ordinary users but also excludes | ||
| the service is a locked-out install, and it reports as SECURED under the readability check | ||
| alone — then fails on the next start, far enough away that nobody connects the two. */ | ||
| error.WriteLine($" LOCKED OUT {target.Path} ({target.What})"); | ||
| error.WriteLine( | ||
| $" secured, but {DarlingFileSecurity.ServiceAccountDisplayName} cannot read it - " + | ||
| "the service would fail on its next start. Grant that account and re-run."); | ||
| exposed++; | ||
| } |
There was a problem hiding this comment.
exposed++ here feeds the same counter as the STILL READABLE and FAILED branches, but the summary line that reads that counter (line 2923) is hardcoded to:
error.WriteLine($"{exposed} of {touched} item(s) are STILL readable by ordinary users.");
error.WriteLine("Run this from an ELEVATED prompt. If it was already elevated, the owner is the");
error.WriteLine("problem: ownership carries WRITE_DAC, so take ownership first, then re-run.");For a LOCKED OUT target this is backwards — the file is not readable by ordinary users (that's the whole problem), and "take ownership, re-run elevated" doesn't fix a lockout. An operator hitting only lockouts gets a summary that describes and prescribes the wrong failure. Worth tracking lockedOut/stillReadable separately (or building a summary string per-branch) so the final message matches what actually happened.
| account = account.Trim(); | ||
|
|
||
| return account.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase) | ||
| ? new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null) | ||
| : (SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier)); | ||
| } | ||
| catch (Exception) | ||
| { | ||
| /* Same reasoning as the display name below: a resolution failure must degrade to the old | ||
| behaviour, never take the harden down. */ | ||
| return null; | ||
| } |
There was a problem hiding this comment.
This catch (Exception) returns null for two very different situations: the service key genuinely doesn't exist (OpenSubKey returns null, handled above) vs. the key/ObjectName exist but NTAccount(account).Translate(typeof(SecurityIdentifier)) throws — e.g. IdentityNotMappedException for a stale/deleted domain account or gMSA, or the DC being unreachable at the moment --harden-files runs.
HardenFiles treats both as "not registered" and prints:
"the '{ServiceName}' service is not registered on this machine, so this is the account you are running as - install the service first if you expected its own account here"
For the second case that's actively wrong — the service is registered, telling the operator to install it sends them down the wrong path — and the harden silently falls back to hardening for the caller's identity instead of the service's. That's the exact failure mode #2371 describes, just reached through SID-translation failure instead of a missing registration. Might be worth distinguishing "key not found" from "resolution failed" so the fallback message (and the operator's next step) matches the real cause.
| if (rule.AccessControlType == AccessControlType.Allow | ||
| && rule.IdentityReference is SecurityIdentifier sid | ||
| && sid.Equals(target) | ||
| && (rule.FileSystemRights & FileSystemRights.Read) != 0) |
There was a problem hiding this comment.
FileSystemRights.Read is a composite flag (ReadData | ReadExtendedAttributes | ReadAttributes | ReadPermissions), and (rule.FileSystemRights & FileSystemRights.Read) != 0 is satisfied by any single bit of that set. So an ACE granting only, say, ReadAttributes or ReadPermissions (no ReadData) would make this method report "granted" even though the account can't actually read the file's contents — the DPAPI blob, in the case this verb protects.
Compare IsReadableByOrdinaryUsers a few lines up, which correctly requires the exact bit: (rule.FileSystemRights & FileSystemRights.ReadData) != FileSystemRights.ReadData. Today this doesn't bite because HardenFile/HardenDirectory only ever grant FullControl to ServiceAccount, but it defeats the point of this check as a safety net against future divergence — the whole reason it exists per the doc comment above ("an ACL can be perfectly private and still lock the service out of its own credentials"). Suggest matching the sibling method: check for ReadData specifically.
Fixes #2371 — found dogfooding #2352 on the third Darling box during the 3.5.0 soak, on the exact case it was built for.
--harden-filesresolved the account to grant fromWindowsIdentity.GetCurrent(). Run the documented way — elevated, from something that is not the service — that is the OPERATOR, so the harden wrote a DACL for them and dropped the account the service runs as.The live run, via SSM (which runs as
NT AUTHORITY\SYSTEM):NT SERVICE\PerformanceMonitor Darlingheld(F)on all four before that, and on none of them after, while the SCM still reported it as the service's account. The service stayed up on its open handles and would have failed on the next start with no read on its config or either DPAPI credential — the failure #2185/#2197 exist to diagnose, manufactured by the tool meant to prevent it.Why it was not caught by construction. Every original caller of
DarlingFileSecurityruns INSIDE the service, so "the current identity" and "the account the service runs as" were the same value and the distinction did not exist. This verb inverts that: it exists because a virtual service account cannot re-ACL a file it does not own, so its caller is never the service. The property's doc comment already stated the requirement it was missing — "must name the account the service RUNS AS" — one level up from where it was being read.The fix. The account comes from the SCM's
ObjectName, which is what the service is logged on with, and which also covers the re-homed domain-account and gMSA cases that comment was worried about. It falls back to the caller when the service is not registered (a console run, or hardening a tree before install) — the only configuration where the two are legitimately the same — and says so rather than silently hardening for the wrong principal.LocalSystemis mapped by hand since the SCM stores it unqualified and it has noNTAccountspelling to translate.The verify pass gains its other half.
IsReadableByOrdinaryUsersasks whether anyone TOO MANY can read. It cannot see the opposite failure, and an ACL that excludes ordinary users AND the service is maximally private and completely broken — which is exactly why the run above printedSECUREDfour times. A locked-out target now reportsLOCKED OUTand counts as exposure, so the verb exits non-zero and a provisioning script stops instead of proceeding on a broken install.Tests pin the resolution order too: the account must be resolved BEFORE the first target is hardened, or early targets get the caller's ACL and later ones the service's, which is worse than either alone.
The box is restored and healthy — I re-granted the account on all four paths and restarted the service to prove it rather than assuming: