Skip to content

Harden for the registered service account, not the caller - #2372

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/2371-harden-service-account
Aug 19, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/2371-harden-service-account

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

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-files resolved the account to grant from WindowsIdentity.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):

Hardening for service account: NT AUTHORITY\SYSTEM

  SECURED  C:\PerformanceMonitorDarling\darling.json (the live config)
  SECURED  C:\ProgramData\PerformanceMonitorDarling (the store directory)
  SECURED  C:\ProgramData\PerformanceMonitorDarling\pg-credential.dpapi (the store credential)
  SECURED  C:\ProgramData\PerformanceMonitorDarling\pg-admin-credential.dpapi (the admin credential)

All 4 item(s) secured. The service re-asserts these ACLs at every start.

NT SERVICE\PerformanceMonitor Darling held (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 DarlingFileSecurity runs 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. LocalSystem is mapped by hand since the SCM stores it unqualified and it has no NTAccount spelling to translate.

The verify pass gains its other half. IsReadableByOrdinaryUsers asks 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 printed SECURED four times. A locked-out target now reports LOCKED OUT and 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:

2026-08-19 17:32:07.374 [INFO ] [DarlingWorker] Postgres store ready (schema v79, 0 migration(s) applied)

--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
@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewed. This is Darling-only (Windows service ACL hardening) — confirmed there's no Lite counterpart (--harden-files/DarlingFileSecurity don't exist under Lite/), so no parity drift to flag. No T-SQL in this diff.

The core fix (resolve the account to harden for from the SCM's ObjectName instead of the caller's identity, via HardenForAccount/RegisteredServiceAccount) is sound, and the write path (HardenFile/HardenDirectory) and the new verify path (GrantsHardenedAccount) correctly read the same ServiceAccount property, so they can't drift from each other for the case that mattered (#2371).

Left three inline comments on things that weaken the new safety net or produce misleading operator-facing output:

  1. The final summary line always says "STILL readable by ordinary users" even when the failure was LOCKED OUT (the opposite problem) — the remediation text it prints is wrong for that case.
  2. RegisteredServiceAccount returns null both when the service isn't registered and when SID resolution fails for a registered service (stale/deleted domain account, unreachable DC) — the caller treats both as "not registered" and silently falls back to the caller's identity, which is the exact class of bug this PR fixes, via a different path.
  3. GrantsHardenedAccount treats any bit of the composite FileSystemRights.Read flag as "can read," instead of requiring ReadData like its sibling IsReadableByOrdinaryUsers does — so an ACE granting only e.g. ReadAttributes/ReadPermissions would be reported as fine even though the service can't actually read the file's contents.

None of these affect the primary bug fix itself (which only ever grants FullControl), but they blunt the new verification/diagnostics this PR adds.

Comment on lines +2895 to +2905
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++;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +100 to +111
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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