详解AppLocker(Part 2)

 

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如何通过访问检查来验证策略,后面我们再来揭晓谜底。

(完)