PrintDemon: Print Spooler Privilege Escalation, Persistence & Stealth (CVE-2020-1048 & more)

We promised you there would be a Part 1 to FaxHell, and with today’s Patch Tuesday and CVE-2020-1048, we can finally talk about some of the very exciting technical details of the Windows Print Spooler, and interesting ways it can be used to elevate privileges, bypass EDR rules, gain persistence, and more. Ironically, the Print Spooler continues to be one of the oldest Windows components that still hasn’t gotten much scrutiny, even though it’s largely unchanged since Windows NT 4, and was even famously abused by Stuxnet (using some similar APIs we’ll be looking at!). It’s extra ironic that an underground ‘zine first looked at the Print Spooler, which was never found by Microsoft, and that’s what the team behind Stuxnet ended up using!

First, we’d like to shout out to Peleg Hadar and Tomer Bar from SafeBreach Labs who earned the MSRC acknowledgment for one of the CVEs we’ll describe — there are a few others that both the team and ourselves have found, which may be patched in future releases, so there’s definitely still some dragons hiding. We understand that Peleg and Tomer will be presenting their research at Blackhat USA 2020, which should be an exciting addition to this post.

Secondly, Alex would like to apologize for the naming/branding of a CVE — we did not originally anticipate a patch for this issue to have collided with other research, and we thought that since the Spooler is a service, or a daemon in Unix terms, and given the existence of FaxHell, the name PrintDemon would be appropriate.

Printers, Drivers, Ports, & Jobs

While we typically like to go into the deep, gory, guts of Windows components (it’s an internals blog, after all!), we felt it would be worth keeping things simple, just to emphasize the criticality of these issues in terms of how easy they are to abuse/exploit — while also obviously providing valuable tips for defenders in terms of protecting themselves.

So, to begin with, let’s look at a very simple description of how the printing process works, extremely dumbed down. We won’t talk about monitors or providors (sp) or processors, but rather just the basic printing pipeline.

To begin with, a printer must be associated with a minimum of two elements:

  • A printer port — you’d normally think of this as LPT1 back in the day, or a USB port today, or even a TCP/IP port (and address)
    • Some of you probably know that it can also “FILE:” which means the printer can print to a file (PORTPROMPT: on Windows 8 and above)
  • A printer driver — this used to be a kernel-mode component, but with the new “V4” model, this is all done in user mode for more than a decade now

Because the Spooler service, implemented in Spoolsv.exe, runs with SYSTEM privileges, and is network accessible, these two elements have drawn people to perform all sorts of interesting attacks, such as trying to

  • Printing to a file in a privilege location, hoping Spooler will do that
  • Loading a “printer driver” that’s actually malicious
  • Dropping files remotely using Spooler RPC APIs
  • Injecting “printer drivers” from remote systems
  • Abusing file parsing bugs in EMF/XPS spooler files to gain code execution

Most of which have resulted in actual bugs found, and some hardening done by Microsoft. That being said, there remain a number of logical issues, that one could call downright design flaws which lead to some interesting behavior.

Back to our topic: to make things work, we must first load a printer driver. You’d naturally expect that this requires privileges, and some MSDN pages still suggest the SeLoadDriverPrivilege is required. However, starting in Vista, to make things easier for Standard User accounts, and due to the fact these now run in user-mode, the reality is more complicated. As long as the driver is a pre-existing, inbox driver, no privileges are needed — whatsoever — to install a print driver.

So let’s install the simplest driver there is: the Generic / Text-Only driver. Open up a PowerShell window (as a standard user, if you’d like), and write:

> Add-PrinterDriver -Name "Generic / Text Only"

Now you can enumerate the installed drivers:

> Get-PrinterDriver

Name                                PrinterEnvironment MajorVersion    Manufacturer
----                                ------------------ ------------    ------------
Microsoft XPS Document Writer v4    Windows x64        4               Microsoft
Microsoft Print To PDF              Windows x64        4               Microsoft
Microsoft Shared Fax Driver         Windows x64        3               Microsoft
Generic / Text Only                 Windows x64        3               Generic

If you’d like to do this in plain old C, it couldn’t be easier:

hr = InstallPrinterDriverFromPackage(NULL, NULL, L"Generic / Text Only", NULL, 0);

Our next required step is to have a port that we can associate with our new printer. Here’s an interesting, not well documented twist, however: a port can be a file — and that’s not the same thing as “printing to a file”. It’s a file port, which is an entirely different concept. And adding one is just as easy as yet another line of PowerShell (we used a world writeable directory as our example):

> Add-PrinterPort -Name "C:\windows\tracing\myport.txt"

Let’s see the fruits of our labour:

> Get-PrinterPort | ft Name


To do this in C, you have two choices. First, you can prompt the user to input the port name, by using the AddPortW API. You don’t actually need to have your own GUI — you can pass NULL as the hWnd parameter — but you also have no control and will block until the user creates the port. The UI will look like this:

Another choice is to manually replicate what the dialog does, which is to use the XcvData API. Adding a port is as easy as:

PWCHAR g_PortName = L"c:\\windows\\tracing\\myport.txt";
dwNeeded = ((DWORD)wcslen(g_PortName) + 1) * sizeof(WCHAR);

The more complicated part is getting that hMonitor — which requires a bit of arcane knowledge:

PRINTER_DEFAULTS printerDefaults;
printerDefaults.pDatatype = NULL;
printerDefaults.pDevMode = NULL;
printerDefaults.DesiredAccess = SERVER_ACCESS_ADMINISTER;
OpenPrinter(L",XcvMonitor Local Port", &hMonitor, &printerDefaults);

You might see ADMINISTER in there and go a-ha — that needs Adminstrator privileges. But in fact, it does not: anyone can add a port. What you’ll note though, is that passing in a path you don’t have access to will result in an “Access Denied” error. More on this later.

Don’t forget to be a good citizen and call ClosePrinter(hMonitor) when you’re done!

We have a port, we have a printer driver. That is all we need to create a printer and bind it to these two elements. And again, this does not require a privileged user, and is yet another single line of PowerShell:

> Add-Printer -Name "PrintDemon" -DriverName "Generic / Text Only" -PortName "c:\windows\tracing\myport.txt"

Which you can now check with:

> Get-Printer | ft Name, DriverName, PortName

Name DriverName PortName
---- ---------- --------
PrintDemon Generic / Text Only C:\windows\tracing\myport.txt

The C code is equally simple:

PRINTER_INFO_2 printerInfo = { 0 };
printerInfo.pPortName = L"c:\\windows\\tracing\\myport.txt";
printerInfo.pDriverName = L"Generic / Text Only";
printerInfo.pPrinterName = L"PrintDemon";
printerInfo.pPrintProcessor = L"WinPrint";
printerInfo.pDatatype = L"RAW";
hPrinter = AddPrinter(NULL, 2, (LPBYTE)&printerInfo);

Now you have a printer handle, and we can see what this is good for. Alternatively, you can use OpenPrinter once you know the printer exists, which only needs the printer name.

What can we do next? Well the last step is to actually print something. PowerShell delivers another simple command to do this:

> "Hello, Printer!" | Out-Printer -Name "PrintDemon"

If you take a look at the file contents, however, you’ll notice something “odd”:

0D 0A 0A 0A 0A 0A 0A 20 20 20 20 20 20 20 20 20
20 48 65 6C 6C 6F 2C 20 50 72 69 6E 74 65 72 21
0D 0A …

Opening this in Notepad might give you a better visual indication of what’s going on — PowerShell thinks this is an actual printer. So it’s respecting the margins of the Letter (or A4) format, adding a few new lines for the top margin, and then spacing out your string for the left margin. Cute.

Bear in mind, this is behavior that in C, you can configure — but typically Win32 applications will print this way, since they think this is a real printer.

Speaking about C, how can you achieve the same effect? Well, here, we actually have two choices — but we’ll cover the simpler and more commonly taken approach, which is to use the GDI API, which will internally create a print job to handle our payload.

DOC_INFO_1 docInfo;
docInfo.pDatatype = L"RAW";
docInfo.pOutputFile = NULL;
docInfo.pDocName = L"Document";
StartDocPrinter(hPrinter, 1, (LPBYTE)&docInfo);

PCHAR printerData = "Hello, printer!\n";
dwNeeded = (DWORD)strlen(printerData);
WritePrinter(hPrinter, printerData, dwNeeded, &dwNeeded);


And, voila, the file contents now simply store our string.

To conclude this overview, we’ve seen how with a simple set of unprivileged PowerShell commands, or equivalent lines of C, we can essentially write data on the file system by pretending it’s a printer. Let’s take a look at what happens behind the scenes in Process Monitor.

Spooling as Evasion

Let’s take a look at all of the operations that occurred when we ran these commands. We’ll skip the driver “installation” as that’s just a mess of PnP and Windows Servicing Stack, and begin with adding the port:

Here we have our first EDR / DFIR evidence trail : it turns out that printer ports are nothing more than registry values under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Ports. Obviously, only privileged users can write to this registry key, but the Spooler service does it for us over RPC, as you can see in the stack trace below:

Next, let’s see how the printer creation looks like:

Again, we see that the operations are mostly registry based. Here’s how a printer looks like — note the Port value, for example, which is showing our file path.

Now let’s look at what that PowerShell command did when printing out our document. Here’s a full view of the relevant file system activity (the registry is no longer really involved), with some interesting parts marked out:

Whoa — what’s going on here? First, let’s go a bit deeper in the world of printing. As long as spooling is enabled, data printed doesn’t directly go to the printer. Instead, the job is spooled, which essentially will result in the creation of a spool file. By default, this will live in the c:\windows\system32\spool\PRINTERS directory, but that is actually customizable on a per-system as well as per-printer basis (that’s a thread worth digging into later).

Again, also by default, this file name will either be FPnnnnn.SPL for EMF print operations, or simply nnnnn.SPL for RAW print operations. The SPL file is nothing more than a copy, essentially, of all the data that is meant to go the printer. In other words, it briefly contained the “Hello, printer!” string.

A more interesting file is the shadow job file. This file is needed because print jobs aren’t necessarily instant. They can error out, be scheduled, be paused, either manually or due to issues with the printer. During this time, information about the job itself must remain in more than just Spoolsv.exe’s memory, especially since it is often prone to crashing due to 3rd party printer driver bugs — and due to the fact that print jobs survive reboots. Below, you can see the Spooler writing out this file, whose data structure has changed over the years, but has now reached the SHADOWFILE_4 data structure that is documented on our GitHub repository.

We’ll talk about some interesting things you can do with the shadow job file later in the persistence section.

Next, we have the actual creation of the file that is serving as our port. Unfortunately, Process Monitor always shows the primary token, so if you double-click on the event, you’ll see this operation is actually done under impersonation:

This is may actually seem like a key security feature of the Spooler service — without it, you could create a printer port to any privileged location on the disk, and have the Spooler “print” to it, essentially achieving an arbitrary file system read/write primitive. However, as we’ll describe later, the situation is a bit more complicated. It may also seem like from an EDR perspective, you still have some idea as to who the user is. But, stay tuned.

Finally, once the write is done, both the spool file and the shadow job file are deleted (by default), which is seen as those SetDisposition calls:

So far, what we’ve shown is that we can write anywhere on disk — presumably to locations that we have access to — under the guise of the Spooler service. Additionally, we’ve shown that the file creation is done under impersonation, which should reveal the original user behind the operation. Investigating the job itself will also show the user name and machine name. So far, forensically, it seems like as long as this information can be gathered, it’s hard to hide…

We will break both of those assumptions soon, but first, let’s take a look at an interesting way that this behavior can be used.

Spooling as IPC

The first interesting use of the Spooler, and most benign, is to leverage it for communication between processes, across users, and even across reboots (and potentially networks). You can essentially treat a printer as a securable object (technically, a printer job is too, but that’s not officially exposed) and issue both read and write operations in it, through two mechanisms:

  • Using the GDI API, and issuing ReadPrinter and WritePrinter commands.
    • First, you must have issued a StartDocPrinter and EndDocPrinter pair of calls (in between the write) to create the printer job and spool data in it.
    • The trick is to use SetJob to make the job enter a paused state from the beginning (JOB_CONTROL_PAUSE), so the spool file remains persistent
    • The former API will return a print job ID, that the client side can then use as part of a call to OpenPrinter with the special syntax of adding the suffix ,Job n to the printer name, which opens a print job instead of a printer.
      • Clients can use the EnumJobs API to enumerate all the printer jobs and find the one they want to read from based on some properties.
  • Using the raw print job API, and using WriteFile after obtaining a handle to the spool file.
    • Once the writes are complete, call ScheduleJob to officially make it visible.
    • Client continues to use ReadPrinter like in the other option

You might wonder what advantages any of this has versus just using regular File I/O. We’ve thought of a few:

  • If going with the full GDI approach, you’re not importing any obvious I/O APIs
  • The read and writes, when done by ReadPrinter and WritePrinter are not done impersonated. This means that they appear as if coming from SYSTEM running inside Spoolsv.exe
    • This also potentially means you can read and write from a spooler file in a location where you’d normally not have access to.
  • It’s doubtful any security products, until just about now, have ever investigated or looked at spooler files
    • And, with the right API/registry changes, you can actually move the spooler directory somewhere else for your printer
  • By cancelling the job, you get immediate deletion of the data, again, from a service context
  • By resuming the job, you essentially achieve a file copy — albeit this one does happen impersonated, as we’ve learnt so far

We’ve published on our GitHub repository a simple printclient and printserver application, which implement client/server mechanism for communicating between two processes by leveraging these ideas.

Let’s see what happens when we run the server:

As expected, we now have a spool file created, and we can see the print queue below showing our job — which is highly visible and traceable, if you know to look.

On the client side, let’s run the binary and look at the result:

The information you see at the top comes from the printer API — using EnumJob and GetJob to retrieve the information that we want. Additionally, however, we went a step deeper, as we wanted to look at the information stored in the shadow job itself. We noted some interesting discrepancies:

  • Even though MSDN claims otherwise, and the API will always return NULL, print jobs to indeed have security descriptors
    • Trying to zero them out in the shadow job made the Spooler unable to ever resume/write the data!
  • Some data is represented differently
    • For example, the Status field in the shadow job has different semantics, and contains internal statuses that are not exposed through the API
    • Or, the StartTime and UntilTime, which are 0 in the API, are actually 60 in the shadow job

We wanted to better understand how and when the shadow job data is read, and when is internal state in the Spooler used instead — just like the Service Control Manager both has its own in-memory database of services, but also backs it all up in the registry, we thought the Spooler must work in a similar way.

Spooler Forensics

Eventually, thanks to the fact that the Spooler is written in C++ (which has rich type information due to mangled function names) we understood that the Spooler keeps track of jobs in INIJOB data structures.

We started looking at the various data structures involved in keeping track of Spooler information, and came up with the following data structures, each of which has a human-readable signature which makes reverse engineering easier:

For full disclosure, it seems GitHub continues to host NT4 source code for the world to look at, and when searching for some of these types, the Spltypes.h header file repeatedly came up. We used it as an initial starting point, and then manually updated the structures based on reverse engineering.

To start with, you’ll want to find the pLocalIniSpooler pointer in Localspl.dll — this contains a pointer to INISPOOLER, which is partially shown below:

Here it is in memory:

As you can see, this key data structure points to the first INIPRINTER, the INIMONITOR, the INIENVIRONMENT, the INIPORT, the INIFORM, and the SPOOL. From here, we could start by dumping the printer, which starts with the following data structure:

In memory, for the printer the printserver PoC on GitHub creates, you’d see:

You could also choose to look at the INIPORT structures linked by the INISPOOLER earlier — or directly grab the one associated with the INIPRINTER above. Each one looks like this:

Once again, the port we created in the PoC looks like this in memory, at the time that the job is being spooled:

Finally, both the INIPORT and the INIPRINTER were pointing to the INIJOB that we created. The structure looks as such:

This should be very familiar, as it’s a different representation of much of the same data from the shadow job file as well as what EnumJob and GetJob will return. For our job, this is what it looked like in memory:

Locating and enumerating these structures gives you a good forensic overview of what the Spooler has been up to — as long as Spoolsv.exe is still running and nobody has tampered with it.

Unfortunately, as we’re about to show, that’s not something you can really depend on.

Spooling as Persistence

Since we know that the Spooler is able to print jobs even across reboots (as well as when the service exits for any reason), it stands to reason that there’s some logic present to absorb the shadow job file data and create INIJOB structures out of it.

Looking in IDA, we found he following aptly named function and associated loop, which is called during the initialization of the Local Spooler:

Essentially, this processes any shadow job file data associated with the Spooler itself (server jobs, as they’re called), and then proceeds to enumerate every INIPRINTER, get its spooler directory (typically, the default), and process its respective shadow job file data.

This is performed by ProcessShadowJobs, which mainly executes the following loop:

It’s not visible here, but the *.SHD wildcard is used as part of the FindFirstFile API, so each file matching this extension is sent to ReadShadowJob. This breaks one of our assumptions: there’s no requirement for these files to follow the naming convention we described earlier. Combining with the fact that a printer can have its own spooler directory, it means these files can be anywhere.

Looking at ReadShadowJob, it seemed that only basic validation was done of the information present in the header, and many fields were, in fact, totally optional. We constructed, by hand with a hex editor, a custom shadow job file that only had the bare minimum to associate it to a printer, and restarted the Spooler, taking a look at what we’d see in Process Monitor. We also created a matching .SPL file with the same name, where we wrote a simple string.

First, we noted the Spooler scanning for FPnnnnn SPL files, which are normally associated with EMF jobs (the FP stands for File Pool). Then, it searched for SHD files, found ours, opened the matching SPL file, and continued looking for more files. None were present, so NO MORE FILES was returned.

So, interestingly, you’ll notice how in the stack below, the DeleteOrphanFiles API is called to cleanup FP files:

But the opposite effect happens for SHD files after — the following stack shows you ProcessShadowJobs calling ReadShadowJob, as the IDA output above hypothesized.

What was the final effect of our custom placed SHD file, you ask? Well, take a look at the print queue for the printer that we created…

It’s not looking great, is it? Double-clicking on the job gives us the following, equally useless information.

Given that this job seems outright corrupt, and indicates 0 bytes of data, you’d probably expect that resuming this job will abort the operation or crash in some way. So did we! Here’s what actually happens:

The whole thing works just fine and goes off and writes the entire spool file into our printer port, actual size in the SHADOWFILE_4 be damned. What’s even crazier is that if you manually try calling ReadPrinter yourself, you won’t see any data come in, because the RPC API actually checks for this value — even though the PortThread does not!

What we’ve shown so far, is that with very subtle file system modifications, you can achieve file copy/write behavior that is not attributable to any process, especially after a reboot, unless some EDR/DFIR software somehow knew to monitor the creation of the SHD file and understood its importance. With a carefully crafted port name, you can imagine simply having the Spooler drop a PE file anywhere on disk for you (assuming you have access to the location).

But things were about to take whole different turn in our research, when we asked ourselves the question — “wait, after a reboot, how does the Spooler even manage to impersonate the original user — especially if the data in the SHD file can be NULL‘ed out?”.

Self Impersonation Privilege Escalation (SIPE)

Since Process Monitor can show impersonation tokens, we double-clicked on the CreateFile event, just as we had done at the beginning of this blog. We saw that indeed, the PortThread was impersonating… but… but…

The Spooler is impersonating… SYSTEM! It seems the code was never written to handle a situation that would arise where a user might have logged out, or rebooted, or simply the Spooler crashing, and now we can write anywhere SYSTEM can. Indeed, looking at the NT4 source code, the PrintDocumentThruPrintProcessor function just zooms through and writes into the port.

However, we’re not ones to trust 30 year old code on GitHub, so we stuck with our trusty IDA, and indeed saw the following code, which was added sometime around the Stuxnet era:

And, indeed, CanUserAccessTargetFile immediately checks if hToken is NULL, and if so, returns FALSE and sets the LastError to ERROR_ACCESS_DENIED.

Boom! Game Over! The code is safe, we checked it! Believe it or not, we’ve previously gotten this type of response to security reports (not lately!).

Clearly, something is amiss, since we saw our write go through “impersonating” SYSTEM.

This is where a very deep subtlety arises. Pay attention to this code in CreateJobEntry, which is what ultimately initializes an INIJOB, and, if needed, sets JOB_PRINT_TO_FILE.

print job is considered to be headed to a file only if the user selected the “Print to file” checkbox you see in the typical print dialog. A port, on the other hand, that’s a literal file, completely skips this check.

Well, OK then — let’s stop with this C:\Windows\Tracing\ lameness, and create a port in C:\Windows\System32\Ualapi.dll. Why this DLL? Well, you’ll see you saw in Part Two!

Hmmm, that’s not so easy:

We are caught in the act, as you can see from the following Process Monitor output:

The following stack shows how XcvData is called (an API you saw earlier) with the PortIsValid command. While you can’t see it here (it’s on the “Event” tab), the Spooler is impersonating the user at this point, and the user certainly doesn’t have write access to c:\Windows\System32!

As such, it would seem that while it’s certainly interesting that we can get the Spooler to write files to disk after a reboot / service start, without impersonation, it’s unclear how this can be useful, since a port pointing to a privileged directory must first be created. As an Administrator, it’s a great evasion and persistence trick, but you might think this is where the game stops.

While messing around with ways to abuse this behavior (and we found a few!), we also stumbled into something way, way, way, way… way simpler than the advanced techniques we were coming up with. And, it would seem, so did the folks at SafeBreach Labs, which beat us to the punch (gratz!) with CVE-2020-1048, which we’ll cover below.

Client Side Port Check Vulnerability (CVE-2020-1048)

This bug is so simple that it’s almost embarrassing once you realize all it would’ve taken is a PowerShell command.

If you scroll back up to where we showed the registry access in Spoolsv.exe as a result of Add-PrinterPort, you see a familiar XcvData stack — but going straight to XcvAddPort / DoAddPort — and not DoPortIsValid. Initially, we assumed that the registry access was being done after the file access (which we had masked out in Process Monitor), and that port validation had already occurred. But, when we enabled file system events… we never saw the CreateFile.

Using the UI, on the other hand, first showed us this stack and file system access, and then went ahead and added the port.

Yes, it was that simple. The UI dialog has a client-side check… the server, does not. And PowerShell’s WMI Print Provider Module… does not.

This isn’t because PowerShell/WMI has some special access. The code in our PoC, which uses XcvData with the AddPort command, directly gets the Spooler to add a port with zero checking.

Normally, this isn’t a big deal, because all subsequent print job operations will have the user’s token captured, and the file accesses will fail.

But not… if you reboot, or kill the Spooler in some way. While that’s not necessarily obvious for an unprivileged user, it’s not hard — especially given the complexity and age of the Spooler (and its many 3rd party drivers).

So yes, walk to any unpatched system out there — you all have Windows 7 ESUs, right? — and just write Add-PrinterPort -Name c:\windows\system32\ualapi.dllin a PowerShell window. Congratulations! You’ve just given yourself a persistent backdoor on the system. Now you just need to “print” an MZ file to a printer that you’ll install using the systems above, and you’re set.

If the system is patched, however, this won’t work. Microsoft fixed the vulnerability by now moving the PortIsValid check inside of LcmXcvDataPort. That being said, however, if a malicious port was already created, a user can still “print” to it. This is because of the behavior we explained above — the checks in CanUserAccessTargetFile do not apply to “ports pointing to files” — only when “printing to a file”.

Conclusion — Call to Action!

This bug is probably one of our favorites in Windows history, or at least one of our Top 5, due to its simplicity and age — completely broken in original versions of Windows, hardened after Stuxnet… yet still broken. When we submitted some additional related bugs (due to responsible disclosure, we don’t want to hint where these might be), we thought the underlying impersonation behavior would also be addressed, but it seems that this is meant to be by design.

Since the fix for PortIsValid does make the impersonation behavior moot for newly patched systems, but leaves them vulnerable to pre-existing ports, we really wanted to get this blog out there to warn the industry for this potentially latent threat, now that a patch is out and attackers would’ve quickly figured out the issue (load Localspl.dll in Diaphora — the two line call to PortIsValid jumps out at you as the only change in the binary).

There are two steps you should immediately take:

  1. Patch! This bug is ridiculously easy to exploit, both as an interactive user and from limited remote-local contexts as well.
  2. Scan for any file-based ports with either Get-PrinterPorts in PowerShell, or just dump HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Ports. Any ports that have a file path in them — especially ending in an extension such as .DLL or .EXE should be treated with extreme prejudice.
Read our other blog posts:

Join the Conversation


  1. Pingback: Blog ESET România

Leave a comment