0x00 前言
前面我们已经与大家分享过FaxHell相关技术细节,随着微软在周二补丁日例行发布安全公告后(包括CVE-2020-1048
的补丁),现在我们可以与大家继续分享Windows Print Spooler的技术细节,以及如何利用Print Spooler来实现权限提升、绕过EDR规则、实现持久化等。Print Spooler目前仍是最古老的Windows组件之一,从Windows NT 4以来基本没有什么变化。即使曾经被Stuxnet(震网)病毒滥用过(使用过我们即将与大家一起分析的一些API),Print Spooler仍然没有经过特别全面的审查。之前有个第三方研究团队首先分析过Print Spooler,发现了微软未发现过的利用点,最终被Stuxnet幕后团队所使用。
0x01 背景知识
虽然我们比较喜欢深入分析Windows组件的各种精妙之处,但还是想尽量保证简洁性,只重点关注这些问题的严重性,分析如何简单滥用/利用这些问题,同时也会给防御方提供一些小建议。
首先我们来简单看一下打印过程,这里我们并不会讨论监视器、服务提供程序或者处理器,只讨论最基础的打印流程。
首先,打印机必须至少具备两个元素:
1、打印机端口:之前是LPT1
,现在是USB口或者TCP/IP
端口(以及地址)。
某些人可能知道还存在
FILE:
形式,这意味着打印机可以将内容打印到文件(Windows 8及以上系统为PORTPROMPT:
)。
2、打印机驱动:之前这是一个内核模式组件,在新的V4
模型下,十多年来已改成用户模式下工作。
由于Spooler
服务(在Spoolsv.exe
中实现)以SYSTEM
权限运行,并且网络可达,因此这两个元素成功吸引了许多人的注意,尝试发起所有有趣的攻击,包括:
1、希望Spooler
能将文件打印到某个特权位置;
2、加载恶意的“打印机驱动”;
3、通过Spooler
RPC API远程投放文件;
4、从远程系统注入“打印机驱动”;
5、滥用EMF/XPS spooler文件中的文件解析bug来获得代码执行权限。
以上大多数尝试的确发现过bug,微软也针对某些bug加强了防御措施。即便如此,系统中还是存在不少逻辑问题,有些甚至是设计上的缺陷,会导致出现一些有趣的行为。
回到我们的话题,为了滥用打印机,我们首先必须加载一个打印机驱动。正常情况下,大家都会认为这需要高权限才能执行,并且有些MSDN页面还提示我们需要SeLoadDriverPrivilege
。然而从Vista开始,为了便于普通用户账户操作(并且现在会在用户模式下运行),因此实际情况会比较复杂。只要目标驱动是预先存在的内置驱动,我们不需要特权就能安装打印机驱动。
我们先来安装最简单的一个驱动:Generic / Text-Only
驱动。(以普通用户权限)打开PowerShell窗口,然后输入:
Add-PrinterDriver -Name "Generic / Text Only"
接着枚举已安装的驱动:
> 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
如果想用C语言来完成,也可以使用如下语句:
hr = InstallPrinterDriverFromPackage(NULL, NULL, L"Generic / Text Only", NULL, 0);
接下来是将新打印机与某个端口绑定。这里比较有趣的是(官方没有详细说明):端口也可以使用文件,并且这与“打印到文件”有所不同。此时是一个文件端口,这是完全不同的一个概念。我们只需要在PowerShell中使用如下一行命令就可以(这里我们用的是全局可写的一个目录):
Add-PrinterPort -Name "C:\windows\tracing\myport.txt"
获取打印机端口:
> Get-PrinterPort | ft Name
Name
----
C:\windows\tracing\myport.txt
COM1:
COM2:
COM3:
COM4:
FILE:
LPT1:
LPT2:
LPT3:
PORTPROMPT:
SHRFAX:
如果想用C来完成,我们可以有两种选择。我们可以使用AddPortW
API,弹出窗口要求用户输入端口名。我们并不需要设计GUI,可以传入NULL
作为hWnd
参数的值,当用户创建端口后,程序才会解除阻塞状态。UI如下所示:
另一种方法是使用XcvData
) API来手动复制以上窗口的逻辑,如下所示:
PWCHAR g_PortName = L"c:\\windows\\tracing\\myport.txt";
dwNeeded = ((DWORD)wcslen(g_PortName) + 1) * sizeof(WCHAR);
XcvData(hMonitor,
L"AddPort",
(LPBYTE)g_PortName,
dwNeeded,
NULL,
0,
&dwNeeded,
&dwStatus);
这里比较复杂的是获取hMonitor
,需要一些神秘知识:
PRINTER_DEFAULTS printerDefaults;
printerDefaults.pDatatype = NULL;
printerDefaults.pDevMode = NULL;
printerDefaults.DesiredAccess = SERVER_ACCESS_ADMINISTER;
OpenPrinter(L",XcvMonitor Local Port", &hMonitor, &printerDefaults);
以上代码中存在ADMINISTER
字样,因此大家可能会觉得需要Administrator
权限,但事实并非如此:任何人都可以添加端口。不过如果我们传入不具备访问权限的路径,将会得到“访问被拒绝”错误。稍后我们再讨论这一点。
代码处理完毕后,别忘了调用ClosePrinter(hMonitor)
。
现在我们已经拿到了一个端口和一个打印机驱动,我们只需要利用这两个元素就能创建并绑定一个打印机。这个过程同样不需要特权用户,只需要如下一行PowerShell命令:
Add-Printer -Name "PrintDemon" -DriverName "Generic / Text Only" -PortName "c:\windows\tracing\myport.txt"
然后使用如下语句来检查效果:
> Get-Printer | ft Name, DriverName, PortName
Name DriverName PortName
---- ---------- --------
PrintDemon Generic / Text Only C:\windows\tracing\myport.txt
对应的C代码如下:
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);
拿到打印机句柄后,我们可以开始思考该句柄的用途。此外,知道打印机名称后,我们可以使用OpenPrinter
来获取打印机句柄。
最后一步就是执行打印操作,在PowerShell中也只需要一条命令:
"Hello, Printer!" | Out-Printer -Name "PrintDemon"
然而如果我们查看文件内容,会看到一些奇怪的数据:
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 …
如果在Notepad中打开,我们能更直观地了解这些数据:PowerShell认为这是一个真实的打印机,因此会按照Letter或(A4)格式来设定边距,在顶部添加几个新行,然后在字符串左侧留出左边距。
我们可以在C语言中控制具体行为,但通常Win32应用都会以这种方式来打印,因为这些应用都认为这是真实的打印机。
那么在C语言中如何实现相同的效果呢?我们有两种选择,这里我们介绍的是更加简单也更常用的方法:使用GDI API,在内部创建一个打印任务,处理我们的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);
EndDocPrinter(hPrinter);
现在文件内容将会简单存储我们提供的字符串。
总结一下,现在我们了解了一些简单的非特权PowerShell命令以及作用相同的C代码,我们可以假装创建一个打印机,向文件系统中写入数据。下面我们可以使用Process Monitor了解幕后的具体操作。
0x02 文件写入
来看一下当运行这些命令后系统会执行哪些操作。我们跳过驱动的“安装”过程(该过程涉及一大堆PnP以及Windows服务栈操作),从添加端口开始观察:
从图中可知:打印机端口实际上是注册表值,路径为HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Ports
。显然,只有特权用户才能写入该键值,但Spooler
服务通过RPC帮我们完成该操作,调用栈如下所示:
打印机创建过程如下:
这里主要操作依然涉及到注册表。注册表中的打印机如下所示,注意其中的Port
值,对应的是我们的文件路径。
接着观察打印文档时PowerShell命令的处理过程。涉及到的文件系统完整活动如下所示(这里不再涉及到注册表),其中我们标出了比较有趣的部分操作:
启用spooling后,打印数据并没有直接发送到打印机。此时任务处于假脱机状态(spooled),最终导致系统创建一个spool文件。默认情况下,该文件位于c:\windows\system32\spool\PRINTERS
目录中,但实际上对于每个系统和每个打印机,这个值都可以自定义(这是值得深入分析的一个点)。
默认情况下,对于EMF打印操作,文件名为FPnnnnn.SPL
,对于RAW打印操作,文件名为nnnnn.SPL
。SPL
文件本质上是发送给打印机的所有数据的一个副本,换句话说,该文件中只包含Hello, printer!
字符串。
还有个更有趣的“影子作业文件”(shadow job file)。由于打印作业不一定实时处理,因此系统需要用到该文件。在手动处理或者打印机出问题时,这类文件可以被出错、被调度、被暂停。在此期间,考虑到第三方打印机驱动可能存在bug、打印作业在重启后需要保留,因此作业本身相关的信息不会只保留在Spoolsv.exe
的内存中。如下所示,Spooler
会写入该文件,文件的数据结构多年来发生了一些变化,现在采用的是SHADOWFILE_4
数据结构,详细结构可参考我们的GitHub页面。
在下文的持久化场景中,我们将详细介绍影子作业文件的作用。
随后是创建文件操作。不幸的是,Process Monitor始终显示的是主令牌,如果我们双击该事件,可以看到该操作通过模拟(impersonation)方式来完成:
这可能是Spooler
服务的一项核心安全功能:如果没有该操作,我们就可以在磁盘上的任意特权位置中创建打印机端口,然后让Spooler
帮我们将数据“打印”到该位置,从而实现任意文件系统读写原语。然而后面我们会提到,实际情况要更为复杂一些。从EDR角度来看,我们可以获取目标用户的某些信息。
最后,当完成写操作后,(默认情况下)spool文件和影子作业文件都会被删除,如下SetDisposition
调用所示:
目前我们演示了如何通过打印功能,在Spooler
服务的帮助下往磁盘上(我们能访问的)位置写入数据。此外,我们也知道文件创建操作通过模拟上下文来完成,可以获取该操作背后的原始用户。如果检查作业本身,我们也能得到用户名和主机名。基于这些信息,从数字取证角度来看,攻击者似乎很难隐藏自身踪迹。
很快我们就能打破上述结论,但首先我们来看一下利用这种行为的一种有趣方式。
0x03 IPC
Spooler
有个最有趣(也是最有用)的一个特性:可以用来作为进程间、跨用户甚至重启后(以及潜在的网络)的通信渠道。我们实际上可以将打印机看成一个安全对象(从技术角度来看,打印机作业也是安全对象,但官方并没有公开该对象),然后可以通过两种方式在其中发起读写操作:
1、使用GDI API,然后发送ReadPrinter
和WritePrinter
命令。
- 首先,我们必须成对调用
StartDocPrinter
和EndDocPrinter
,创建打印机作业和spool数据。 - 使用
SetJob
让作业一开始就处于暂停状态(JOB_CONTROL_PAUSE
),这样spool文件就会保持在文件系统中。 - 前一个API将会返回一个打印机作业ID,然后客户端可以使用特定语法,添加一个后缀(
,Job n
),将其作为打印机名参数来调用OpenPrinter
,这样就能打开打印机作业,而不是打开打印机。
Client可以使用
EnumJobs
API来枚举所有打印机作业,根据某些属性找到希望读取的作业。
2、使用打印作业raw API,在获取spool文件句柄后调用WriteFile
。
- 一旦完成写操作,调用
ScheduleJob
使打印作业可见。 - 客户端继续调用
ReadPrinter
。
那么这么操作与传统的文件I/O相比有什么优势呢?我们总结了如下几点:
1、如果完全采用GDI方法,那么整个过程不会导入明显的I/O API;
2、采用ReadPrinter
和WritePrinter
时的读写操作并没有在模拟上下文中进行,这意味着这些操作看上去似乎由以SYSTEM
运行的Spoolsv.exe
来发起。
这也可能意味着我们能够读写某个
spooler
文件,并且该文件位于我们通常不具备访问权限的某个位置。
3、我们怀疑到目前为止,没有多少安全产品检查过spooler
文件。
并且如果采用正确的API/修改注册表,我们可以将为自己的打印机定制
spooler
目录。
4、取消作业后,我们的数据会立即从服务上下文中删除。
5、恢复作业后,我们能拿到目标文件副本。目前据前文分析,该行为并没有通过模拟来完成。
我们在GitHub上发布了一个简单的printclient
以及printserver
应用,其中实现了客户端/服务端机制,可以利用前文介绍的知识在两个进程间通信。。
来试着运行服务端,如下图所示:
与预期相符,我们可以创建spool文件,并且可以在打印队列中看到我们的作业,这显然留下不少线索,可以帮防御方定位。
在客户端,我们可以运行程序,观察输出结果,如下图所示:
顶部的输出信息来自于打印机API:我们使用EnumJob
和GetJob
来获取我们希望获取的信息。这里我们将更进一步,查看存储在影子作业中的信息。研究过程中,我们找到了一些有趣的差异点:
1、即使根据MSDN的说明,该API始终会返回NULL
,但打印作业实际上具有安全描述符。
如果我们在影子作业中将其清零,导致Spooler
无法恢复/写入数据。
2、有些数据显示的值与实际值不一致。
比如,影子作业中的Status
字段具有不同的含义,并且包含未通过API公开的内部状态。
另外,API中提示StartTime
和UntilTime
的值为0
,实际上影子文件中的值为60
。
我们想更进一步澄清影子文件数据的读取方式及读取时机,以及什么时候会使用Spooler
中的内部状态。大家都知道,Service Control Manager在内存中有一个服务数据库,但也会在注册表中备份了服务信息,我们认为Spooler
同样采用了这种方式。