0x00 前言
在前一篇文章中,我简单讨论了AppLocker(AL)架构以及如何在Windows 10 1909企业版基础上设置一个基本的测试系统。这里我们将开始深入研究具体细节,分析AL如何阻止策略不允许的进程创建操作。这里我还想重复提一下,我们的研究内容基于Windows 10 1909,在其他版本系统上细节可能会有所不同。
0x01 如何阻止进程创建
当APPID驱动启动时,会使用PsSetCreateProcessNotifyRoutineEx API注册进程通知回调。进程通知回调可以设置PS_CREATE_NOTIFY_INFO结构的CreationStatus
字段,返回错误代码来阻止进程创建。如果内核探测到回调设置了错误代码,那么就会调用PsTerminateProcess
来立即结束进程。
这里有趣的一点是,当进程对象被创建时,系统并不会调用进程通知回调,只有当第一个线程插入进程中时才会调用该回调。回调被调用时位于创建新线程的线程上下文中,该线程通常是创建进程的线程。如果查看内核中的PspInsertThread
函数,可以看到如下类似代码:
if (++Process->ActiveThreads == 1)
CurrentFlags |= FLAG_FIRST_THREAD;
// ...
if (CurrentFlags & FLAG_FIRST_THREAD) {
if (!Process->Flags3.Minimal || Process->PicoContext)
PspCallProcessNotifyRoutines(Process);
}
代码首先增加进程的活跃线程数,如果当前计数值为1,则设置一个标志,以便后续使用。随后代码会调用PspCallProcessNotifyRoutines
来调用已注册的回调,这里就会调用APPID回调。
之所以回调看上去似乎会在进程创建时调用,是因为大多数进程都采用NtCreateUserProcess
创建,后者会将进程及初始线程创建当成一个操作来执行。从理论上讲,我们可能通过调用NtCreateProcessEx
来成功创建一个新的进程,然而实际上我们永远不能在不触发通知的情况下将线程插入其中。这里可能存在竞争条件,我们无法让ActiveThreadCount
的值为1,因此这里可能有一个进程锁,阻止我们执行该操作。
WDAC以及AL之间最关键的差别在于是否会在进程创建之后阻止进程创建过程。如果不满足已定义的策略要求,WDAC就会阻止任何可执行代码的创建操作,因此如果我们尝试使用不满足策略的可执行文件来创建进程,那么在进程创建初期就会失败。然而,AL允许我们创建进程、执行许多初始化任务,只有当线程被插入进程后,才阻止后续操作。
使用进程通知回调的确存在一个缺点,那就是不适用于WSL(Windows Subsystem for Linux)进程。这里的“不适用”指的是APPID回调永远不会被调用,并且由于系统需要调用回调来阻止进程创建,这意味着任何WSL进程都可以不受干扰正常运行。
这与上面代码中对Minimal
/PicoContext
字段值的检查没有关系(看上去似乎与Alex Ionescu在关于WSL的某次演讲上提到的映象格式有关),实际上与APPID驱动对通知回调的启用方式有关。APPID会调用PsSetCreateProcessNotifyRoutineEx
方法,然而这种方式并不会为WSL进程生成回调。相反,APPID需要使用PsSetCreateProcessNotifyRoutineEx2来获得WSL进程的回调。微软可能觉得专门为WSL进程提供AL支持没有必要,但我比较惊讶的是微软竟然没有提供相关配置选项,直接允许运行。
0x02 为何阻止进程创建
现在我们已经知道AL如何阻止进程创建,但不知道AL根据什么因素来判断是否阻止目标进程。这里我们已经配置好规则,这些规则会通过某种方式生效。每条规则都包含如下3个部分:
1、规则是否允许目标进程创建;
2、规则所适用的用户或者组;
3、规则检查的属性,其中包括可执行文件路径、可执行文件哈希或者发布者证书及版本信息。比如,简单的路径为%WINDIR%\*
,这代表只要可执行文件位于Windows
目录中,就可以执行。
下面让我们深入分析APPID进程通知回调(即AiProcessNotifyRoutine
),澄清内部工作原理。简化版的代码如下所示:
void AiProcessNotifyRoutine(PEPROCESS Process,
HANDLE ProcessId,
PPS_CREATE_NOTIFY_INFO CreateInfo) {
PUNICODE_STRING ImageFileName;
if (CreateInfo->FileOpenNameAvailable)
ImageFileName = CreateInfo->ImageFileName;
else
SeLocateProcessImageName(Process,
&ImageFileName);
CreateInfo->CreationStatus = AipCreateProcessNotifyRoutine(
ProcessId, ImageFileName,
CreateInfo->FileObject,
Process, CreateInfo);
}
该回调首先会提取待检查进程的可执行文件映象路径。如果设置了FileOpenNameAvailable
标志,那么传递给回调的PS_CREATE_NOTIFY_INFO
结构中就会包含映象文件路径。然而在某些情况下(比如WSL),该标志并没有设置,此时代码就会使用SeLocateProcessImageName
来获取对应的路径。我们知道提取完整映象路径非常关键,因为这正是AL规则集主要评判的一个标准。
随后代码会调用一个内部函数:AipCreateProcessNotifyRoutine
。该函数返回的状态值会赋值到CreationStatus
,因此如果该函数返回失败,那么进程就会被终止。该函数内部执行了许多操作,我会尽量简化处理,在澄清内部基本逻辑的情况下,兼顾到某些功能(比如对AppX的支持及Smart Locker,后续文章中可能还会介绍这些内容)。基于这个原则,经过处理后的内部代码如下所示:
NTSTATUS AipCreateProcessNotifyRoutine(
HANDLE ProcessId,
PUNICODE_STRING ImageFileName,
PFILE_OBJECT ImageFileObject,
PVOID Process,
PPS_CREATE_NOTIFY_INFO CreateInfo) {
POLICY* policy = SrpGetPolicy();
if (!policy)
return STATUS_ACCESS_DISABLED_BY_POLICY_OTHER;
HANDLE ProcessToken;
HANDLE AccessCheckToken;
AiGetTokens(ProcessId, &ProcessToken, &AccessCheckToken);
if (AiIsTokenSandBoxed(ProcessToken))
return STATUS_SUCCESS;
BOOLEAN ServiceToken = SrpIsTokenService(ProcessToken);
if (SrpServiceBypass(Policy, ServiceToken, 0, TRUE))
return STATUS_SUCCESS;
HANDLE FileHandle;
AiOpenImageFile(ImageFileName,
ImageFileObject,
&FileHandle);
AiSetAttributesExe(Policy, FileHandle,
ProcessToken, AccessCheckToken);
NTSTATUS result = SrppAccessCheck(
AccessCheckToken,
Policy);
if (!NT_SUCCESS(result)) {
AiLogFileAndStatusEvent(...);
if (Policy->AuditOnly)
result = STATUS_SUCCESS;
}
return result;
}
这里的知识点比较多,我们可以从头开始梳理。首先代码会请求当前的全局策略对象,如果不存在已配置好的策略,那么就会返回STATUS_ACCESS_DISABLED_BY_POLICY_OTHER
状态代码。当进程被阻止时,我们会多次看到这个状态码。正常情况下,即便AL没有启用,也会存在一个策略对象,默认情况下不会阻止任何操作。可以想象的是,如果因为某些原因,系统中不存在全局策略,那么每个进程创建操作都会失败,这显然不是喜闻乐见的情况。
接下来进入核心检查逻辑,首先代码会调用AiGetTokens
函数。该函数会根据目标进程的PID,打开目标进程访问令牌的一个句柄(我不知道为什么代码不使用PS_CREATE_NOTIFY_INFO
结构中的Process
对象,可能是因为遗留代码的原因)。该函数也会返回第二个令牌(即访问检查令牌)的句柄,这一点比较关键,后面我们会介绍。
随后,代码会根据进程令牌执行两项检查。在第一项检查中,代码会检查令牌是否为AiIsTokenSandBoxed
。不幸的是这种命名方式并不友好,从名称中我们无法判断是否与受限令牌有关(比如在Web浏览器沙箱中使用的令牌)。这里实际上检查的是令牌中是否设置了“Sandbox Inert”标志。设置该标志的一种方法就是调用CreateRestrictedToken函数,传入SANDBOX_INERT
标志。根据官方文档,从Windows 8开始(或者打上KB2532445补丁的系统),“调用方必须以LocalSystem
或者TrustedInstalller
身份运行,否则系统会忽略掉该标志”。然而如果从这一点来考虑,这个文档并不是完全正确。如果我们查看NtFilterToken
的内部实现,会发现如果我们具备SERVICE SID
(基本上所有服务都具备该SID),同样可以设置该标志。在这种检查机制下,如果进程令牌设置了“Sandbox Inert”标志,那么就会返回成功状态码,AL会放行新创建的这个进程。
在第二项检查中,代码会判断令牌是否为服务令牌。代码首先调用SrpIsTokenService
获得一个BOOLEAN
值,然后调用SrpServiceBypass
来判断当前策略是否允许服务令牌绕过策略。如果SrpServiceBypass
返回真,那么回调也会返回成功代码,绕过AL。经过测试后,我发现似乎没办法配置AL,使其对服务进程执行进程检查,然而我找了很久,还是没有找到相关文档。这种设置可能比较危险,无法让普通管理员使用。
如何判断令牌是否属于服务上下文?这与使用CreateRestrictedToken
来设置的“Sandbox Inert”标志非常类似。如果进程令牌中具备包含如下某个组,则会被系统当成服务:
NT AUTHORITY\SYSTEM
NT AUTHORITY\SERVICE
NT AUTHORITY\RESTRICTED
NT AUTHORITY\WRITE RESTRICTED
最后两个组只用来允许服务以受限或者写入受限模式运行,如果没有包含这些组,那么将通不过服务状态检测,最终AL可能不会按照预期模式工作,异常结束本不应终止的进程。
了解这些内容后,我们现在开始分析AL如何检测进程。首先,代码会打开主执行文件对象的一个句柄。如果规则中包含哈希值或者发布者证书等信息,那么就需要访问目标文件。随后,代码会调用AiSetAttributesExe
,访问令牌句柄、策略以及文件句柄作为参数传入该函数。这里面有一些名堂,但这里我们先将悬念保留,后面再解开谜题。最后代码会调用SrppAccessCheck
,从函数名中我们可知,该函数会再次检查策略,看该进程是否可以创建。要注意的是,这里只传入了访问检查令牌,并没有传入进程令牌。
考虑到规则的具体结构,系统会根据访问令牌(Access Token)验证安全描述符(Security Descriptor)的操作也非常自然。这里的允许及拒绝规则与针对特定组SID的允许或拒绝ACE(Access Control Entry,访问控制项)能够完美对应。至于具体规则(例如路径限制)的执行方式我们尚不明确,后面我们再详细讨论这一点。
检测完毕后,AipCreateProcessNotifyRoutine
返回的状态码会赋值到通知结构中的CreationStatus
字段,系统会根据该字段值来判断是否结束进程。可以猜测的是,返回结果应该为成功或者类似STATUS_ACCESS_DISABLED_BY_POLICY_OTHER
之类的错误代码。
最后一步,如果访问检查失败,则代码会生成一个事件。如果访问检查的结果为错误值,但当前配置的策略处于“Audit Only”(仅审核)模式(即不强制禁止进程创建),那么代码会生成日志条目,但会重置状态码为成功状态,这样内核就不会结束该进程。
0x03 测试系统行为
在进行具体测试之前,我们可以创建不匹配已配置策略的一个进程(只要进程中没有线程即可)。这并不是非常有意义的一种行为,但我们可以用来测试基于逆向工程代码的猜想是否正确。
为了进行测试,我们需要安装我开发的NtObjectManager
PowerShell模块,后面我们还将多次使用该模块。我们需要在前面设置的VM环境中执行如下操作:
1、在管理员PowerShell控制台中,运行Install-Module NtObjectManager
命令。以管理员身份运行该命令可以让我们将模块安装到Program Files
目录中,这是Part 1示例规则中Everyone
可以使用的一个位置。
2、在同一个Powershell窗口中,使用Set-ExecutionPolicy -ExecutionPolicy Unrestricted
命令执行规则,允许任何用户可以执行未经签名的脚本。
3、以非管理员用户身份登录。
4、启动PowerShell控制台,运行Import-Module NtObjectManager
命令,加载NtObjectManager
模块。
在Part 1中,我们在桌面目录中存放了一个可执行文件(如果桌面上没有任何可执行文件,我们可以简单拷贝一个NOTEPAD.EXE
),运行时会被策略所阻拦。
现在在PowerShell窗口中运行如下3条命令。其中我们可能需要根据具体情况,调整已拷贝的可执行文件的路径(不要忘了路径中存在\??
前缀)。
$path = "\??\C:\Users\$env:USERNAME\Desktop\notepad.exe"
$sect = New-NtSectionImage -Path $path
$p = [NtApiDotNet.NtProcess]::CreateProcessEx($sect)
Get-NtStatus $p.ExitStatus
调用Get-NtStatus
,打印该进程的当前退出代码,这里的退出代码应该为STATUS_PENDING
。这表明该进程依然存活,但此时我们并没有在其中运行任何代码。现在使用如下命令在进程中创建一个新的线程:
[NtApiDotNet.NtThread]::Create($p, 0, 0, "Suspended", 4096)
Get-NtStatus $p.ExitStatus
调用NtThread::Create
后,我们应该能看到一个非常醒目的红色异常错误,然后可以调用Get-NtStatus
显示进程返回的错误,如下图所示:
这篇文章暂时介绍这些内容,现在我们还有些谜题未解答,比如为何AiGetTokens
会返回两个令牌句柄、AiSetAttributesExe
的处理逻辑以及SrppAccessCheck
如何通过访问检查来验证策略,后面我们再来揭晓谜底。