Securing Windows services is one of those topics where everyone agrees on the goal and almost nobody implements the same set of controls. Services are still the single most abused persistence and privilege-escalation surface on Windows, mapped by MITRE ATT&CK as T1543.003 (Windows Service). The recommendations below are the baseline we apply to every server we build before it sees production traffic — service accounts, executable security, dependencies, isolation, and monitoring.
Key Takeaways
- Avoid running services as
LocalSystemor domain users. Prefer Group Managed Service Accounts (gMSAs) — automatic 240-character passwords, central management, no plaintext credentials. - Unquoted service paths containing spaces are a classic privilege escalation vector. Audit and fix them as part of the baseline.
- Service executables should be signed, live under
Program FilesorSystem32, and have ACLs that exclude regular users from write access. - Tighten the service's security descriptor with
sc.exe sdsetto restrict who can start, stop, change, or query it. - Monitor service install/change events (4697, 7045) and pair with a SIEM. The PowerShell snippets here are for authorised hardening and audit work.
Environment
- Windows Server 2019/2022 and Windows 10/11 endpoints.
- Windows PowerShell 5.1 or PowerShell 7.4.
- Active Directory domain for the gMSA section (KDS root key must already exist).
- Local administrator rights on each target.
The Problem
Services are attractive to attackers for the same reasons they are useful to administrators: they run automatically, they typically run as SYSTEM, and they survive reboots without a separate persistence mechanism. The default service configuration on most third-party applications is also poor — plain domain users with weak passwords, executables in C:\App\ with broad write ACLs, paths containing spaces and no quotes, security descriptors permitting any local admin to swap the image path at runtime.
None of this is fixable in one click. The hardening below is a checklist applied at server build time and re-verified in the regular audit pass.
The Solution
Step 1 — Audit the current state
Before changing anything, enumerate what is on the box. Three columns matter most: which account each service runs as, whether the path is quoted, and what the binary looks like:
Get-CimInstance Win32_Service |
Select-Object Name, DisplayName, State, StartMode, StartName, PathName |
Sort-Object StartName, Name |
Export-Csv .\service_inventory.csv -NoTypeInformation -Encoding UTF8BOM
Rows where StartName is a domain user and the password is fifteen years old are the first to address. Rows where PathName contains a space but no surrounding quotes are the second.
Step 2 — Replace plain service accounts with gMSAs
Group Managed Service Accounts give you a domain identity with a password that AD manages and rotates automatically. For multi-server services (web farms, scheduled task fleets, application services on a cluster), they replace every reason to use a plain domain user.
# One-time, per forest
Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-10))
# Per service or service family
New-ADGroup -Name 'gMSA_WebApp_Servers' -GroupScope Global
Add-ADGroupMember -Identity 'gMSA_WebApp_Servers' -Members 'Web01$','Web02$','Web03$'
New-ADServiceAccount -Name 'gMSA_WebApp' `
-DNSHostName 'gMSA_WebApp.corp.example.com' `
-PrincipalsAllowedToRetrieveManagedPassword 'gMSA_WebApp_Servers' `
-ServicePrincipalNames 'HTTP/webapp.corp.example.com'
# On each target server
Invoke-Command -ComputerName Web01,Web02,Web03 -ScriptBlock {
Install-ADServiceAccount -Identity 'gMSA_WebApp'
Test-ADServiceAccount -Identity 'gMSA_WebApp'
}
Bind the gMSA to a service with sc.exe config using the username form DOMAIN\gMSA_WebApp$ and an empty password. The trailing $ is required.
Step 3 — Fix unquoted paths with embedded spaces
Unquoted service paths are CWE-428 and one of the longest-running Windows privilege escalation patterns. A path like C:\Program Files\Vendor App\service.exe is interpreted starting from C:\Program.exe, then C:\Program Files\Vendor.exe, and so on. A writable executable in any of those intermediate paths runs as the service identity:
Get-CimInstance Win32_Service |
Where-Object {
$_.PathName -and
$_.PathName -notmatch '^"' -and
($_.PathName -split ' ')[0] -match '\s'
} |
Select-Object Name, PathName
Fix each by editing the registry value HKLM:\SYSTEM\CurrentControlSet\Services\<ServiceName>\ImagePath to wrap the executable portion in double quotes. Restart the service to validate.
Step 4 — Lock down the executable and its directory
Even a correctly-configured service is compromised if the binary it loads is writable by everyone. Audit ACLs and signatures together:
Get-CimInstance Win32_Service | ForEach-Object {
$exe = ($_.PathName -split '"')[1]
if (-not $exe) { $exe = ($_.PathName -split ' ')[0] }
if (-not (Test-Path $exe)) { return }
$acl = Get-Acl $exe
$sig = Get-AuthenticodeSignature $exe
$bad = $acl.Access |
Where-Object {
$_.IdentityReference -match '(Everyone|Users|Authenticated Users)' -and
$_.FileSystemRights -match '(Write|Modify|FullControl)'
}
if ($bad -or $sig.Status -ne 'Valid') {
[PSCustomObject]@{
Service = $_.Name
Path = $exe
SigStatus = $sig.Status
BadAccess = ($bad | ForEach-Object { "$($_.IdentityReference)=$($_.FileSystemRights)" }) -join '; '
}
}
}
Anything that comes back deserves remediation: tighten the ACL with icacls or move the binary to a directory inherited from Program Files, and verify the signing chain.
Step 5 — Tighten the service security descriptor
By default, any local administrator can stop, start, or modify a service. For services with elevated impact (security agents, backup software, hypervisor management), restrict that with a custom security descriptor:
# Allow: SYSTEM full, Builtin Admins full, Authenticated Users query only
$sddl = 'D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;AU)'
sc.exe sdset 'YourService' $sddl
sc.exe sdshow 'YourService'
The SDDL syntax is dense but readable once you have the pattern. Reference the Microsoft documentation when designing the DACL for a specific service; never copy a permissive SDDL from a forum without reading it.
Step 6 — Map dependencies
A service is only as secure as the services it depends on. Mapping the dependency tree once per service tells you what the impact is when a dependency is restarted or compromised:
function Get-ServiceDependencyTree {
param([string]$Name, [int]$Depth = 0, [int]$Max = 6)
$svc = Get-Service -Name $Name -ErrorAction SilentlyContinue
if (-not $svc -or $Depth -gt $Max) { return }
'{0}{1} ({2}, {3})' -f (' ' * $Depth), $svc.Name, $svc.Status, $svc.StartType
foreach ($dep in $svc.ServicesDependedOn) {
Get-ServiceDependencyTree -Name $dep.Name -Depth ($Depth + 1) -Max $Max
}
}
Get-ServiceDependencyTree -Name 'YourService'
Step 7 — Monitor for service changes
Service creation and modification raise specific Windows events. Hook them with a SIEM rule or, on a single host, a scheduled query:
- 4697 — A service was installed in the system (Security log). Requires audit-policy "Audit Security System Extension" enabled.
- 7045 — A new service was installed (System log). Always on, useful in environments without the advanced audit policy.
- 7040 — Service start type changed. Worth watching for the
Disabled→Automatictransition.
Get-WinEvent -FilterHashtable @{
LogName = 'System'; Id = 7045; StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue |
Select-Object TimeCreated,
@{Name='Service'; Expression={ $_.Properties[0].Value }},
@{Name='Image'; Expression={ $_.Properties[1].Value }},
@{Name='Type'; Expression={ $_.Properties[2].Value }},
@{Name='Account'; Expression={ $_.Properties[4].Value }}
Frequently Asked Questions
When should I use a gMSA versus a regular service account?
Use a gMSA for anything running on more than one machine, anything running in a cluster, and anything where the password would otherwise sit in a script or runbook. Use a single-machine MSA for legacy single-server applications. Reserve plain domain users for cases where the application explicitly does not support managed accounts — that list should shrink every year.
Is LocalSystem ever the right choice?
For services that genuinely need SYSTEM privileges (kernel drivers, device management, backup agents that touch every file), yes. For application services that need a database connection and a file share, no — drop them to a least-privileged account.
How do I find unquoted service paths across an entire fleet?
Wrap the Step 3 snippet in Invoke-Command -ComputerName $list. The result is a per-host list of vulnerable services suitable for a remediation ticket. The Remote Management guide linked below covers the wrapper pattern.
What is the difference between Event 4697 and Event 7045?
7045 lives in the System log and is generated by the Service Control Manager whenever a service is installed. 4697 lives in the Security log and is part of the advanced audit policy. Use 4697 in environments where the audit policy is configured; rely on 7045 elsewhere.
Can I block the creation of new services entirely?
Not natively — service creation is a privileged operation, but local administrators can always do it. The mitigations are AppLocker/WDAC code-integrity policy preventing unsigned binaries from running, plus monitoring of 7045 events. For high-assurance servers, lock down local admin membership tightly and stream the audit events to a SIEM.
Conclusion
Service hardening is not glamorous and nobody notices when it is right. The combination of managed service accounts, signed executables, quoted paths, tight security descriptors, and 7045 monitoring covers most of the real-world attack surface. Apply it as part of server build, re-verify it in audit, and you remove a large class of opportunistic compromise without needing to fight an EDR alert every week.
Related Posts
- Windows Security: Detecting Malicious Scheduled Tasks — the related persistence surface, hardened with the same managed-account pattern.
- Windows Security: Registry Forensics – Where Attackers Hide — the registry side of service persistence (
ImagePath,ObjectName). - PowerShell Quick Guide: Remote Management Basics — fan the audit and remediation snippets across the fleet.
Authoritative references: Group Managed Service Accounts on Microsoft Learn and MITRE ATT&CK T1543.003.
Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.
0 comments:
Post a Comment