Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASS

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASS

In the previous part, I showed how a technique called “Bring Your Own Vulnerable DLL” (BYOVDLL) could be used to reintroduce known vulnerabilities in LSASS, even when it’s protected. In this second part, I’m going to discuss the strategies I considered and explored to improve my proof-of-concept, and hopefully achieve arbitrary code execution.
在上一部分中,我展示了如何使用一种称为“自带易受攻击的 DLL”(BYOVDLL) 的技术在 LSASS 中重新引入已知漏洞,即使它受到保护也是如此。在第二部分中,我将讨论我考虑和探索的策略,以改进我的概念验证,并希望实现任意代码执行。

The User-After-Free (UAF) Bug
释放后用户 (UAF) Bug

Before going down the rabbit hole, I want to kick things off by discussing the use-after-free bug (identified as CVE-2023-2822) in more detail, as it’s the cornerstone of the exploit chain. For an extended explanation, I can only recommend reading the original blog post Isolate me from sandbox – Explore elevation of privilege of CNG Key Isolation by k0shl, who deserves all credit for the discovery of this vulnerability.
在深入兔子洞之前,我想通过更详细地讨论释放后使用错误(被确定为 CVE-2023-2822)来开始事情,因为它是漏洞利用链的基石。为了进行详细的解释,我只能建议阅读 k0shl 的原始博客文章 Isolate me from sandbox – Explore elevation of privilege of CNG Key Isolation,他应该为发现此漏洞而受到所有赞誉。

The problem lies in the RPC procedure SrvCryptFreeKey of the KeyIso service. When the reference count of the input object reaches 1, after being decremented, a Key object is freed by calling the internal SrvFreeKey function. A few instructions later, it is used again, and if the same reference count is 1 after being decremented again, we reach a CALL instruction with controllable inputs. How can the reference count be 1 in both cases if it is decremented twice, you might wonder. This is the tricky part, it can’t!
问题出在 KeyIso 服务的 RPC 过程 SrvCryptFreeKey 中。当输入对象的引用计数达到 1 时,在递减后,通过调用内部 SrvFreeKey 函数释放 Key 对象。几条指令后,它再次被使用,如果相同的引用计数在再次递减后为 1,我们将得到一个具有可控输入的 CALL 指令。你可能会想,如果两者都递减两次,引用计数怎么会是 1。这是棘手的部分,它不能!

Between the time the Key object is freed, and the time it is reused (use-after-free), there is a very narrow time window during which a concurrent thread could allocate memory of a similar size in this unoccupied space. Now, consider that we fully control this allocated buffer; if our timing is perfect, we can satisfy the second condition, and hit the CALL instruction to jump to an arbitrary address.
在释放 Key 对象的时间和重用它的时间(释放后使用)之间,有一个非常狭窄的时间窗口,在此期间,并发线程可以在这个未占用的空间中分配类似大小的内存。现在,考虑到我们完全控制这个分配的缓冲区;如果我们的时机完美,我们可以满足第二个条件,并点击 CALL 指令跳转到任意地址。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSIDA – Pseudo-source code showing CVE-2023-28229
IDA – 显示 CVE-2023-28229 的伪源代码

As you may imagine, such timing is almost impossible to achieve in one shot. That’s why the author (@Y3A) of the proof-of-concept exploit used several threads to constantly allocate and free fake Key objects, in the hope of winning the race at some point. If you do win the race, this is the set of instructions you eventually reach.
正如您可能想象的那样,这样的时机几乎不可能一次实现。这就是为什么概念验证漏洞的作者(@Y3A)使用多个线程来不断分配和释放虚假的 Key 对象,以期在某个时候赢得比赛。如果你赢得了比赛,这就是你最终达成的一套指示。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSIDA – Graph view showing the CALL instruction
IDA – 显示 CALL 指令的图形视图

Please note that this is an overly simplified explanation. The purpose of this introductory part is just to provide some context, not to cover all the intricacies of the bug and its exploit. The only thing you need to keep in mind for the rest of this article is that we have full control over the values of RAX and RCX when the CALL instruction is hit.
请注意,这是一个过于简化的解释。这个介绍性部分的目的只是提供一些背景信息,而不是涵盖该错误及其利用的所有复杂性。在本文的其余部分,您唯一需要记住的是,当命中 CALL 指令时,我们可以完全控制 RAX 和 RCX 的值。

Exploit Strategies 漏洞利用策略

The main constraint for the exploit is the race condition. It is hard to win reliably, and every time we try, we increase the risk of causing an illegal memory access within LSASS, which would eventually lead to a process crash, and a system reboot. So, ideally, we need some sort of “One Gadget”.
该漏洞利用的主要约束是竞争条件。要可靠地取胜是很困难的,而且每次我们尝试时,我们都会增加在 LSASS 中导致非法内存访问的风险,这最终会导致进程崩溃和系统重启。因此,理想情况下,我们需要某种“一个小工具”。

Another major constraint is Control Flow Guard (CFG), as it won’t let us jump to arbitrary sections of code. However, we should be fine if we stick to APIs imported by modules loaded in the process.
另一个主要约束是控制流防护 (CFG),因为它不允许我们跳转到任意代码部分。但是,如果我们坚持使用进程中加载的模块导入的 API,我们应该没问题。

Even with these constraints, it would still be quite easy to write an Object Directory handle to the global variable LdrpKnownDllDirectoryHandle, so that we can later load unsigned DLLs, as I did in my previous PPLmedic exploit. (Un)fortunately, this is no longer possible because this variable was moved to the Mutable Read Only Heap Section (.mrdata), which cannot be modified once the process is fully initialized. To work around this protection, the access rights of the memory area would have to be updated first.
即使有这些约束,将对象目录句柄写入全局变量 LdrpKnownDllDirectoryHandle 仍然非常容易,这样我们以后就可以加载未签名的 DLL,就像我在之前的 PPLmedic 漏洞中所做的那样。幸运的是,这不再可能,因为此变量已移至可变只读堆部分 (.mrdata),一旦进程完全初始化,就无法修改该部分。要解决此保护问题,必须首先更新内存区域的访问权限。

Using PowerShell, and the script Get-PEHeader.ps1, I automated the parsing of all the modules loaded by LSASS, and found a total of 5225 unique imported APIs.
使用 PowerShell 和脚本 Get-PEHeader.ps1,我自动分析了 LSASS 加载的所有模块,并找到了总共 5225 个唯一导入的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Import Get-PEHeader PowerShell module
IEX (New-Object Net.WebClient).DownloadString("https://raw.githubusercontent.com/mattifestation/PIC_Bindshell/master/PIC_Bindshell/Get-PEHeader.ps1")

# List all APIs imported by modules loaded in LSASS
$AllImports = @(); foreach ($m in (Get-Content .\lsass_loaded_modules.txt)) {
    if ($m -notlike "*.dll") { continue }
    $Header = Get-PEHeader "C:\Windows\System32\$m"
    $Header.Imports | % {
        $AllImports += "$($_.ModuleName):$($_.FunctionName)"
    };
}

# List unique functions and save the result to a file
$AllImports | Sort-Object -Unique | Out-File .\lsass_loaded_modules_functions.txt

# List all APIs imported by modules loaded in LSASS
# Result: MODULE,MODULE_IMPORT,FUNCTION_IMPORT
foreach ($m in (Get-Content .\lsass_loaded_modules.txt)) {
    if ($m -notlike "*.dll") { continue }
    $Header = Get-PEHeader "C:\Windows\System32\$m"
    $Header.Imports | % {
        "$($m),$($_.ModuleName),$($_.FunctionName)" | Out-File .\lsass_loaded_modules_functions.txt -Append
    }
}

# List all imported APIs
Get-Content .\lsass_loaded_modules_functions.txt | ConvertFrom-Csv -Delimiter "," -Header "Module","ModuleImport","FunctionImport" | select -ExpandProperty FunctionImport | Sort-Object -Unique | Out-File .\lsass_loaded_modules_functions_uniq.txt

Among those APIs, I considered the two listed below as potential “One Gadgets”.
在这些 API 中,我认为下面列出的两个是潜在的“一个小工具”。

WER Report Silent Process Exit
WER 报告静默进程退出

If this technique works, it’s a quick win because it only requires a process handle to be passed as the first parameter, the second parameter (i.e. the process exit code) being irrelevant.
如果这种技术有效,它就会迅速获胜,因为它只需要将进程句柄作为第一个参数传递,第二个参数(即进程退出代码)无关紧要。

1
2
3
4
NTSTATUS NTAPI RtlReportSilentProcessExit(
    In HANDLE ProcessHandle,
    In NTSTATUS ExitStatus
);

However, since the process is protected, I expected the dump to be performed by WerFaultSecure.exe, in which case it would be encrypted. Anyway, this theory was easy to test, so I decided to give it a shot anyway.
但是,由于该过程受到保护,因此我希望转储由 WerFaultSecure.exe 执行,在这种情况下,它将被加密。无论如何,这个理论很容易测试,所以我决定无论如何都试一试。

To do so, we just need to configure a couple of registry keys, replace the address of OutputDebugStringW with the address of RtlReportSilentProcessExit, and set the value of the first parameter to (HANDLE)-1 (pseudo-handle of the current process).
为此,我们只需要配置几个注册表键,将 OutputDebugStringW 的地址替换为 RtlReportSilentProcessExit 的地址,并将第一个参数的值设置为 (HANDLE)-1(当前进程的伪句柄)。

1
2
3
4
5
6
7
REM Configure Image File Execution Options
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\LSASS.exe" /v "GlobalFlag" /t REG_DWORD /d 512 /f
REM Configure SilentProcessExit options
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\lsass.exe"
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\lsass.exe" /v "ReportingMode" /t REG_DWORD /d 2 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\lsass.exe" /v "LocalDumpFolder" /t REG_SZ /d "C:\Temp" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\lsass.exe" /v "DumpType" /t REG_DWORD /d 2 /f

Unfortunately, but unsurprisingly, this technique didn’t work. Using WinDbg, I observed that the API failed with the status code 0xc0000001 (STATUS_UNSUCCESSFUL). Further investigation of the server-side code, in CWerService::SvcReportSilentProcessExit, revealed that OpenProcess was called from the internal function wersvc!SilentProcessExitReport, with the following parameters.
不幸的是,但不出所料,这种技术没有奏效。使用 WinDbg,我观察到 API 失败,状态代码为 0xc0000001 (STATUS_UNSUCCESSFUL)。在 CWerService::SvcReportSilentProcessExit 中对服务器端代码的进一步调查显示,OpenProcess 是从内部函数 wersvc!SilentProcessExitReport,具有以下参数。

1
2
3
4
// TARGET_PID = LSASS PID here
hTargetProcess = OpenProcess(
    PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE, FALSE, TARGET_PID
);

With this API call, the Windows Error Reporting (WER) service tries to open the target process with “Query information” and “Duplicate handles”, which is not allowed because LSASS runs as a PPL, but this service doesn’t. Back to the drawing board!
通过此 API 调用,Windows 错误报告 (WER) 服务会尝试使用“查询信息”和“重复句柄”打开目标进程,这是不允许的,因为 LSASS 作为 PPL 运行,但此服务不会。从头再来!

Getting a Process Handle on LSASS
在 LSASS 上获取进程句柄

My second idea was to invoke DuplicateHandle from within LSASS so that it duplicates its process handle into a process I own. This function has 7 arguments, but we control only the first one with the UAF. We will see how we can work around this problem in the next part. There is another problem to solve before that, a valid target process handle must first be opened in LSASS.
我的第二个想法是从 LSASS 内部调用 DuplicateHandle,以便它将其进程句柄复制到我拥有的进程中。这个函数有 7 个参数,但我们只用 UAF 控制第一个参数。我们将在下一部分中看到如何解决这个问题。在此之前还有另一个问题需要解决,首先必须在 LSASS 中打开有效的目标进程句柄。

1
2
3
4
5
6
7
8
9
BOOL DuplicateHandle(
  [in]  HANDLE   hSourceProcessHandle, // (HANDLE)-1
  [in]  HANDLE   hSourceHandle,        // (HANDLE)-1
  [in]  HANDLE   hTargetProcessHandle, // Target process handle
  [out] LPHANDLE lpTargetHandle,       // NULL
  [in]  DWORD    dwDesiredAccess,      // e.g. PROCESS_ALL_ACCESS
  [in]  BOOL     bInheritHandle,
  [in]  DWORD    dwOptions
);

Thanks to System Informer, we can see that it contains a lot of process handles associated to services, with varying access rights, depending on their protection level.
多亏了 System Informer,我们可以看到它包含许多与服务关联的进程句柄,根据它们的保护级别,它们具有不同的访问权限。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSSystem Informer – List of service processes opened by LSASS
System Informer – LSASS打开的服务进程列表

What’s more interesting though is that it also has handles associated to user processes such as msedge.exe or RpcView.exe, as can be seen on the screenshot below.
更有趣的是,它还具有与用户进程(例如msedge.exeRpcView.exe)关联的句柄,如下面的屏幕截图所示。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSSystem Informer – List of user processes opened by LSASS
System Informer – LSASS 打开的用户进程列表

This is not the case with every user process, but I was able to reproduce this behavior reliably by starting powershell.exe.
并非每个用户进程都是如此,但我能够通过启动 powershell.exe可靠地重现此行为。

This is interesting because it means that there is a way to coerce LSASS to open our process, without executing code within it. To find out how this works, I used API Monitor to identify calls to OpenProcess or NtOpenProcess in lsass.exe.
这很有趣,因为这意味着有一种方法可以强制 LSASS 打开我们的进程,而无需在其中执行代码。为了弄清楚这是如何工作的,我使用 API Monitor 来识别 lsass.exe 中对 OpenProcess 或 NtOpenProcess 的调用。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSAPI Monitor showing a call to NtOpenProcess within LSASS
API 监视器显示在 LSASS 中对 NtOpenProcess 的调用

The set of access rights passed in the second argument of the selected candidate (screenshot above) is equivalent to the value 0x1478, which is consistent with the information previously given by System Informer in the “Granted access” column.
在被选中的候选者的第二个参数中传递的访问权限集(上面的截图)等同于值 0x1478,这与 System Informer 之前在 “Granted access” 列中给出的信息一致。

1
2
3
4
5
6
7
NtOpenProcess(
    0x0000004b88f7e7b8, // Pointer to output Process handle
    PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
        PROCESS_VM_READ | PROCESS_VM_WRITE,
    0x0000004b88f7e750, // Pointer to OBJECT_ATTRIBUTES structure (all fields are NULL)
    0x0000004b88f7e740  // Pointer to CLIENT_ID structure to specify target PID
);

The next screenshot shows the call stack leading to this syscall. It should be noted that the offsets are calculated relative to the address of the nearest known symbol. Since the PDB files were not imported, this does not necessarily reflect the actual function names. This is similar to the output of Process Monitor before you configure it to resolve all public symbols properly.
下一个屏幕截图显示了导致此系统调用的调用堆栈。应该注意的是,偏移量是相对于最近的已知符号的地址计算的。由于未导入 PDB 文件,因此这并不一定反映实际的函数名称。这类似于在配置进程监视器以正确解析所有公共符号之前的进程监视器的输出。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSCall stack leading to call to NtOpenProcess
导致调用 NtOpenProcess 的调用堆栈

For example, the first entry in the call stack is lsasrv!LsaIModifyPerformanceCounter+0x132e. Ghidra maps this function at the address 0x18001a8c0, which yields the absolute address 0x18001a8c0 + 0x132e = 0x18001bbee.
例如,调用堆栈中的第一个条目是 lsasrv!LsaIModifyPerformanceCounter+0x132e。Ghidra 在地址 0x18001a8c0映射此函数,从而产生绝对地址 0x18001a8c0 + 0x132e = 0x18001bbee

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSGhidra – Call to NtOpenProcess in lsasrv.dll
Ghidra – 在 lsasrv.dll 中调用 NtOpenProcess

Note that RIP always contains the address of the next instruction to execute, hence why you see the CALL instruction at 0x18001bbe7, and not 0x18001bbee.
请注意,RIP 始终包含要执行的下一条指令的地址,因此您在 0x18001bbe7 时看到 CALL 指令,而不是0x18001bbee

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSGhidra – NtOpenProcess invoked by LsapOpenCaller
Ghidra – LsapOpenCaller 调用的 NtOpenProcess

Repeating this process with the 3 other entries in the call stack, I found that the call to NtOpenProcess originates from the RPC procedure SspirConnectRpc, in sspisrv.dll.
对调用堆栈中的其他 3 个条目重复此过程,我发现对 NtOpenProcess 的调用源自 sspisrv.dll 中的 RPC 过程 SspirConnectRpc

[4] sspisrv!SspirConnectRpc(param_1, param_2, ...);
 |__ [3] (**(code **)(gLsapSspiExtension + 0x18))(param_2, param_3, ...); // lsasrv!SspiExConnectRpc
      |__ [2] lsasrv!CreateSession((_CLIENT_ID *)&local_188, 1, local_148, ...);
           |__ [1] lsasrv!LsapOpenCaller(_Session *param_1);
                |__ [0] ntdll!NtOpenProcess(&local_res10, iVar4, ...);

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSRpcView – SSPI RPC interface
RpcView – SSPI RPC 接口

So, it seems that when a client invokes the procedure SspirConnectRpc, the Security Support Provider Interface (SSPI) server opens the client process with the extended access rights “Duplicate Handles”, “VM read”, and “VM write”.
因此,当客户端调用过程 SspirConnectRpc 时,安全支持提供程序接口 (SSPI) 服务器会使用扩展访问权限“重复句柄”、“VM 读取”和“VM 写入”打开客户端进程。

To make sure my analysis was correct, I created a quick proof-of-concept. First, an RPC binding handle needs to be initialized using the protocol ncalrpc and the endpoint lsasspirpc.
为了确保我的分析是正确的,我创建了一个快速的概念验证。首先,需要使用协议 ncalrpc 和端点 lsasspirpc 初始化 RPC 绑定句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RPC_STATUS status;
RPC_WSTR sb;
RPC_BINDING_HANDLE binding = NULL;

status = RpcStringBindingComposeW(
            NULL,                       // No need to specify interface ID
            (RPC_WSTR)L"ncalrpc",       // "ncalrpc" protocol sequence
            NULL,                       // "ncalrpc" so network address not required
            (RPC_WSTR)L"lsasspirpc",    // Endpoint is "lsasspirpc"
            NULL,                       // Network options not required
            &sb                         // Output string binding
         );

status = RpcBindingFromStringBindingW(
            sb,                         // String binding
            &binding                    // Output binding handle
         );

Then, the binding handle can be used to invoke the procedure SspirConnectRpc. Note that the values of Arg1 and Arg2 were obtained by inspecting the content of the buffer referenced in the RPC_MESSAGE passed to NdrServerCallAll with API Monitor.
然后,可以使用绑定句柄来调用过程 SspirConnectRpc。请注意,Arg1 和 Arg2 的值是通过检查使用 API 监视器传递给 NdrServerCallAll 的RPC_MESSAGE中引用的缓冲区内容来获取的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long arg3 = 0, arg4 = 0;
void* ctx = 0;

status = SspirConnectRpc(
            binding,                    // Arg0: Explicit binding handle
            0,                          // Arg1: 00 00 00 00 00 00 00 00
            2,                          // Arg2: 02 00 00 00 
            &arg3,                      // Arg3: Unknown output value
            &arg4,                      // Arg4: Unknown output value
            &ctx                        // Arg5: Output context handle (LSA_SSPI_HANDLE)
         );

status = SspirDisconnectRpc(
            &ctx                        // Arg0: Context handle (LSA_SSPI_HANDLE)
         );

Below is a short demo that shows the expected behavior. After invoking SspirConnectRpc, a new handle to our process is opened in LSASS, and is closed when invoking SspirDisconnectRpc.
下面是一个简短的演示,展示了预期的行为。调用 SspirConnectRpc 后,将在 LSASS 中打开进程的新句柄,并在调用 SspirDisconnectRpc 时关闭。

This trick provides a reliable way to coerce LSASS to open our process. In addition, the system allows the enumeration of handles for any process, even when they are protected. Although we cannot know exactly what object is referenced by a handle without the ability to duplicate it, we do know what type of object it represents (e.g. Process, Thread, File, etc.). Therefore, by comparing the lists of process handles in LSASS before and after the call to SspirConnectRpc, it is possible to find the one associated to the client process.
这个技巧提供了一种可靠的方法来胁迫 LSASS 打开我们的进程。此外,该系统允许枚举任何进程的句柄,即使它们受到保护。虽然我们无法确切知道句柄引用了哪个对象,但如果没有复制它的能力,我们确实知道它代表什么类型的对象(例如进程、线程、文件等)。因此,通过比较调用 SspirConnectRpc 之前和之后 LSASS 中的进程句柄列表,可以找到与客户端进程关联的进程。

A Clever but Tedious CFG Bypass
一个聪明但乏味的CFG旁路

In the previous part, I mentioned that DuplicateHandle has 7 arguments, and therefore cannot be called directly when exploiting the UAF vulnerability, because we control only the first argument. This blog post explains how we can work around this issue, and also bypass Control Flow Guard, by leveraging the API rpcrt4!NdrServerCall2 of the RPC runtime.
在上一部分中,我提到 DuplicateHandle 有 7 个参数,因此在利用 UAF 漏洞时不能直接调用,因为我们只控制第一个参数。这篇博文解释了我们如何利用 API rpcrt4 来解决此问题,并绕过控制流守卫!RPC 运行时的 NdrServerCall2

1
2
void NdrServerCall2( PRPC_MESSAGE pRpcMsg );    // x86
void NdrServerCallAll( PRPC_MESSAGE pRpcMsg );  // x86_64

The reason why this API is great in our case is that it takes only one argument, a pointer to an RPC_MESSAGE. In this “message”, we can represent any function call we want, with any given number of arguments, including complex structures. However this comes at a cost, as we will see shortly.
这个 API 在我们的例子中之所以很棒,是因为它只需要一个参数,一个指向RPC_MESSAGE的指针。在这个“消息”中,我们可以用任何给定数量的参数(包括复杂结构)来表示我们想要的任何函数调用。然而,正如我们稍后将看到的那样,这是有代价的。

It took me a week of trial and error, and a lot of debugging, to determine all the structures and parameters that are required to call NdrCallServerAll without causing a crash, or triggering an exception in the RPC runtime. To do so, I implemented a simple RPC client/server application, to let the MIDL compiler generate all the information I needed, especially the Network Data Representation (NDR) part, and I dynamically analyzed the structures and parameters with WinDbg.
我花了一周的时间反复试验和大量调试,以确定调用 NdrCallServerAll 所需的所有结构和参数,而不会导致崩溃,也不会在 RPC 运行时触发异常。为此,我实现了一个简单的 RPC 客户端/服务器应用程序,让 MIDL 编译器生成我需要的所有信息,尤其是网络数据表示 (NDR) 部分,并使用 WinDbg 动态分析结构和参数。

The graph below provides a visual synthesis of this work. Each line represents 8 bytes, and blank spaces represent unused or irrelevant data, except for NDR_CALL_STRUCT, for which the content was just stripped for conciseness.
下图提供了这项工作的视觉综合。每行代表 8 个字节,空格代表未使用或不相关的数据,但 NDR_CALL_STRUCT 除外,为了简洁起见,其内容只是被剥离了。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSStructures and data required by NdrServerCallAll
NdrServerCallAll 所需的结构和数据

The base structure is RPC_MESSAGE, the first and only parameter of NdrServerCallAll. This structure holds 3 important pieces of information: a handle (i.e. a pointer) to a MESSAGE_OBJECT, a pointer to a buffer that contains serialized data, and a pointer to an RPC_SERVER_INTERFACE structure.
基本结构为 RPC_MESSAGE,这是 NdrServerCallAll 的第一个也是唯一一个参数。此结构包含 3 个重要信息:指向MESSAGE_OBJECT的句柄(即指针)、指向包含序列化数据的缓冲区的指针以及指向RPC_SERVER_INTERFACE结构的指针。

The first value of MESSAGE_OBJECT must be a valid VTable pointer. As suggested in the original blog post, we can use the one of the object rpcrt4!OSF_SCALL. However, it doesn’t tell us how we can find this value. By analyzing cross-references, I found that it was instantiated when calling I_RpcTransServerNewConnection. After doing that, we can locate the object on the heap by searching for the magic ID 0x89abcdef and the OSF SCALL type value 0x00000040. Once the object is located, we eventually get the value of its VTable. You can refer to the details of MESSAGE_OBJECT on the diagram above for a better understanding.
MESSAGE_OBJECT 的第一个值必须是有效的 VTable 指针。正如原始博客文章中所建议的,我们可以使用对象 rpcrt4 之一!OSF_SCALL.但是,它并没有告诉我们如何找到这个值。通过分析交叉引用,我发现它在调用 I_RpcTransServerNewConnection时被实例化了。执行此操作后,我们可以通过搜索魔术 ID 0x89abcdef和 OSF SCALL 类型值 0x00000040 在堆上找到对象。一旦找到对象,我们最终就会得到其 VTable 的值。您可以参考上图中MESSAGE_OBJECT的详细信息,以便更好地理解。

As for the structure RPC_SERVER_INTERFACE, things get a bit more complicated. The only relevant information contained in this structure is a reference to a MIDL_SERVER_INFO structure, which contains a pointer to a MIDL_STUB_DESC, a pointer to an array of SYNTAX_INFO, and most importantly, a pointer to an array of SERVER_ROUTINE. This last array contains a list of RPC procedures that are supposed to be implemented by the server, their index being determined by the ProcNum specified in the RPC_MESSAGE. This is where we can specify the address of the target function we want to call (i.e. DuplicateHandle in this scenario).
至于结构RPC_SERVER_INTERFACE,事情变得有点复杂。此结构中包含的唯一相关信息是对MIDL_SERVER_INFO结构的引用,该结构包含指向MIDL_STUB_DESC的指针、指向SYNTAX_INFO数组的指针,最重要的是,指向SERVER_ROUTINE数组的指针。最后一个数组包含应由服务器实现的 RPC 过程列表,其索引由RPC_MESSAGE中指定的 ProcNum 确定。在这里,我们可以指定要调用的目标函数的地址(即本场景中的 DuplicateHandle)。

As an initial proof-of-concept, I used this trick to call OutputDebugStringW with a hardcoded string because it takes only one argument, which makes things easier to fiddle with and debug.
作为初步的概念验证,我使用此技巧通过硬编码字符串调用 OutputDebugStringW,因为它只需要一个参数,这使得摆弄和调试事情更容易。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSCalling OutputDebugStringW through NdrServerCallAll
通过 NdrServerCallAll 调用 OutputDebugStringW

With a bit more work, I was then able to make a second proof-of-concept that invokes DuplicateHandle instead of OutputDebugStringW.
通过更多的工作,我然后能够制作第二个概念验证,该验证调用 DuplicateHandle 而不是 OutputDebugStringW

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSCalling DuplicateHandle through NdrServerCallAll
通过 NdrServerCallAll 调用 DuplicateHandle

The Final Exploit 最后的漏洞利用

This is all well and good but, in these conditions, this technique requires approximately 1 KB of memory space to store all the required structures, and we control only 352 bytes of contiguous memory space with the UAF exploit.
这一切都很好,但是,在这些条件下,这种技术需要大约 1 KB 的内存空间来存储所有必需的结构,而我们使用 UAF 漏洞只能控制 352 字节的连续内存空间。

Still, there is a way to make it work! The previous diagram makes it clear that there is a lot of wasted space, only a few fields are used in each structure. So, my idea was to consider these structures as jigsaw pieces, and try to combine them in the most efficient way, so that everything can fit in less than 352 bytes.
不过,有一种方法可以让它发挥作用!上图清楚地表明,浪费的空间很多,每个结构中只使用了几个字段。因此,我的想法是将这些结构视为拼图,并尝试以最有效的方式将它们组合在一起,以便所有内容都可以容纳在不到 352 个字节的字节内。

That was not enough though, as some structures took way too much space, especially NDR_CALL_STRUCT, and the buffer containing the serialized data. For each additional parameter in the target function, a “fragment” must be defined to describe how it is serialized, which takes 16 bytes, plus 1 byte for the format type. Therefore, one way to reduce the overall size taken is to strip arguments that are not strictly mandatory.
但这还不够,因为一些结构占用了太多空间,尤其是NDR_CALL_STRUCT,以及包含序列化数据的缓冲区。对于目标函数中的每个附加参数,必须定义一个“片段”来描述它是如何序列化的,这需要 16 个字节,加上 1 个字节的格式类型。因此,减小总体规模的一种方法是去除不是严格强制性的论点。

1
2
3
4
5
6
7
8
9
BOOL DuplicateHandle(
  [in]  HANDLE   hSourceProcessHandle, // Mandatory: (HANDLE)-1
  [in]  HANDLE   hSourceHandle,        // Mandatory: (HANDLE)-1
  [in]  HANDLE   hTargetProcessHandle, // Mandatory: Target process handle
  [out] LPHANDLE lpTargetHandle,       // NULL
  [in]  DWORD    dwDesiredAccess,      // e.g. PROCESS_ALL_ACCESS
  [in]  BOOL     bInheritHandle,       // Not strictly required, can be stripped
  [in]  DWORD    dwOptions             // Not strictly required, can be stripped
);

For example, by omitting the last two arguments of DuplicateHandle (bInheritHandle and dwOptions), I was able to reduce the size of the NDR call structure from 136 bytes to 104 bytes. I also reduced the size of the buffer containing the serialized parameters to only 24 bytes by truncating the target process handle (HANDLE -> DWORD), the target handle (HANDLE -> WORD), and the desired access (DWORD -> WORD). The diagram below shows the final layout of the Key object used in the exploit.
例如,通过省略 DuplicateHandle 的最后两个参数(bInheritHandle 和 dwOptions),我能够将 NDR 调用结构的大小从 136 个字节减小到 104 个字节。我还通过截断目标进程句柄 (HANDLE -> DWORD)、目标句柄 (HANDLE -> WORD) 和所需访问 (DWORD -> WORD) ,将包含序列化参数的缓冲区的大小减小到仅 24 个字节。下图显示了漏洞利用中使用的 Key 对象的最终布局。

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSRPC and NDR structures packed in a fake Key Provider object
打包在虚假密钥提供程序对象中的 RPC 和 NDR 结构

After thoroughly testing this strategy separately, I integrated it to my proof-of-concept exploit, and tested it to confirm that this trick would also work in the exploit chain, and it did!
在单独彻底测试了这个策略之后,我将其集成到我的概念验证漏洞中,并对其进行了测试,以确认这个技巧在漏洞利用链中也有效,它确实有效!

Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASSDuplicateHandle called through NdrServerCallAll within LSASS
通过 LSASS 中的 NdrServerCallAll 调用的 DuplicateHandle

For this PoC, I chose to duplicate the “current process handle”, represented by the value (HANDLE)-1, with LSASS as the target process (handle 0x784 here), for simplicity. As shown on the output of System Informer, this worked, a new process handle was opened with the value 0xfbb1d8, and the access rights 0x3dff.
对于此 PoC,为简单起见,我选择复制由值 (HANDLE)-1 表示的“当前进程句柄”,并将 LSASS 作为目标进程(此处0x784句柄)。如 System Informer 的输出所示,这奏效了,打开了一个值为 0xfbb1d8 的新进程句柄,并且访问权限0x3dff

At this stage, the only thing left to do was to combine this with the RPC SSPI trick, so that the handle is duplicated into a target process we control, instead of LSASS, or so I thought…
在这个阶段,唯一要做的就是将其与 RPC SSPI 技巧相结合,以便将句柄复制到我们控制的目标进程中,而不是 LSASS,或者我是这么认为的……

After updating my exploit code, I tested it several times, but I couldn’t see any handle being created in my process. So, I set a breakpoint on DuplicateHandle in LSASS. Once hit, I stepped over it, printed the last error code, and saw the following.
更新了我的漏洞利用代码后,我测试了几次,但我看不到在我的进程中创建了任何句柄。因此,我在 LSASS 的 DuplicateHandle 上设置了一个断点。一旦被击中,我就跨过它,打印最后一个错误代码,然后看到以下内容。

0:006> gu
RPCRT4!Invoke+0x73:
00007ff8`381c7863 488b7528        mov     rsi,qword ptr [rbp+28h] ss:0000007b`6e67e418=0000007b6e67e840

0:006> !gle
LastErrorValue: (Win32) 0x5 (5) - Access is denied.
LastStatusValue: (NTSTATUS) 0xc0000022 - {Access Denied}  A process has requested access to an object, but has not been granted those access rights.

The operation failed with an “access denied” error. It turns out the system will not allow a handle of a protected process to be duplicated into a non-protected process, unless limited access rights are requested, such as PROCESS_QUERY_LIMITED_INFORMATION. This would just be equivalent to calling OpenProcess directly, without going to so much trouble…
操作失败,并出现“拒绝访问”错误。事实证明,除非请求有限的访问权限(例如 PROCESS_QUERY_LIMITED_INFORMATION),否则系统不允许将受保护进程的句柄复制到不受保护的进程中。这相当于直接调用 OpenProcess,而不会遇到太多麻烦……

What’s Next? 下一步是什么?

The last failure was a huge and unexpected setback, especially given the time and effort invested in the development of this exploit. Nevertheless, the silver lining is that it was a great opportunity to experiment with a cool and advanced exploitation technique, that could come in handy in other situations.
最后一次失败是一次巨大而意想不到的挫折,特别是考虑到在开发此漏洞时投入的时间和精力。尽管如此,一线希望是,这是一个很好的机会,可以尝试一种酷炫而先进的开发技术,这种技术可能会在其他情况下派上用场。

In the third and final part of this series, I will discuss the strategy I finally chose and implemented, along with some original tricks I found to make it all work.
在本系列的第三部分也是最后一部分中,我将讨论我最终选择和实施的策略,以及我发现的一些使这一切奏效的原始技巧。

原文始发于 itm4n:Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASS

版权声明:admin 发表于 2024年8月20日 上午9:59。
转载请注明:Ghost in the PPL Part 2: From BYOVDLL to Arbitrary Code Execution in LSASS | CTF导航

相关文章