原文链接:Running PEs Inline Without a Console
译者:知道创宇404实验室翻译组
在阅读Octoberfest7撰写的Inline-Execute-PE时,了解到为了从执行的 PE 获取输出,作者需要分配一个控制台,而这会创建一个进程(conhost.exe),而 C2 则会通过“欺骗Windows让他认为它有一个控制台”来设法避免生成 conhost.exe 进程。读完这篇文章后,我想我可以尝试一下并尝试实现同样的目标。
Consoles 工作原理简介
简单来说,控制台就是运行CMD时看到的黑匣子,程序可以从中获取用户输入并将输出打印到其中。
在 Windows 中,控制台由名为 conhost.exe 的单独进程运行,该进程通过一系列 API 与实际可执行文件交互。但并非所有进程都有控制台,有些进程有像记事本这样的 GUI,或者像 lsass 这样在分离模式运行。如果进程想要分配一个控制台,只需调用AllocConsole,就可创建 conhost.exe 进程,初始化标准输入、输出和错误流,并将控制台的新句柄保存在PEB->ProcessParameters->ConsoleHandle
下。
MinGW 中的 Hello World
从一个简单的 C 语言“hello world”项目开始,使用 MinGW 编译器将其从 Linux 交叉编译到 Windows。
在内联加载(即在Beacon相同的进程中)之前,使用AllocConsole分配了一个控制台。这将创建一个conhost.exe进程;初始化标准输入、输出和错误流,并在 PEB 上设置新的ConsoleHandle 。此外,通过使用之前创建的匿名管道的写入句柄调用SetStdHandle来更新标准输出和错误流。
AllocConsole();
SetStdHandle(STD_OUTPUT_HANDLE, hPipeWrite);
SetStdHandle(STD_ERROR_HANDLE, hPipeWrite);
接下来,使PEB 上的 ConsoleHandle无效,以便程序无法再与控制台交互。
PEB->ProcessParameters->ConsoleHandle = 0x123;
运行程序后,注意到重定向仍然有效,这似乎表明该程序实际上并未直接与控制台交互。
知道了这一点,决定修改内存中的stdout FILE 结构,其定义如下:
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;
file属性是文件描述符,但因为我们使用的是匿名管道,所以我们只有一个句柄。为了将管道写句柄转换为文件描述符,调用了open_osfhandle 并使用生成的描述符来设置stdout->_file。
最后需要的将(_flag)设置为_IOWRT(文件描述符可写)和_IONBF(禁用缓冲)。
通过这样做,我成功地在不必分配控制台的情况下重定向了输出。
MSVC 的 Hello World
当使用 Microsoft 编译器编译“Hello World”程序时,尝试了之前的相同技巧,可以正常运营,但是当尝试运行一个更复杂的程序(使用 MSVC 编译器编译的 nanodump)时,却没有得到任何输出。
如果以这种方式编译程序,它有效:
cl.exe helloworld.c /Fe:helloworld.exe
但如果像这样编译,就不行:
cl.exe helloworld.c -c –nologo
link.exe /OUT:helloworld.exe -nologo libvcruntime.lib libcmt.lib ucrt.lib kernel32.lib /MACHINE:X64 -subsystem:console -nodefaultlib helloworld.obj
当以第二种情况编译时,二进制文件使用ucrtbase而不是传统的msvcrt新C Runtime实现。在使用Ghidra进行研究时,我意识到函数fileno的定义在msvcrt.dll和ucrtbase.dll之间是不同的。
msvcrt!fileno
…
mov eax, dword ptr [rcx+1Ch]
add rsp, 38h
ret
ucrtbase!_fileno:
…
mov eax,dword ptr [rcx+18h]
add rsp,28h
ret
msvcrt.dll中FILE结构内的file属性的偏移量是0x1C,ucrtbase.dll中是0x18,这意味着FILE结构不同。这解释了为什么以前的方法不起作用,因为我在错误的偏移处 写入_file和flag值。
对UCrtbase.dll中使用的 FILE 结构的定义进行了逆向工程:
typedef struct _UCRTBASE_FILE {
/*0x00 0x08*/ PVOID _ptr;
/*0x08 0x08*/ PVOID _base;
/*0x10 0x04*/ UINT32 _cnt;
/*0x14 0x04*/ UINT32 _flags;
/*0x18 0x04*/ UINT32 _file;
/*0x1c 0x04*/ UINT32 _bufsiz;
/*0x20 0x08*/ PVOID _charbuf;
/*0x28 0x08*/ LPSTR _tmpfname;
/*0x30 0x28*/ CRITICAL_SECTION _lock;
} UCRTBASE_FILE, * PUCRTBASE_FILE;
但仅仅更新 FILE 定义是不够的,因为标志也发生了变化。经过一些调试,我确定标志需要设置为 0x2402。另外,根据 Window 的文档,需要将_lock
结构上的LockCount属性设置为 –1。
最后,意识到我需要调用函数_open_osfhandle
,该函数在ucrtbase.dll而不是旧的msvcrt.dll中定义,以使重定向正常工作。
通过所有这些更改,我成功地将以前的技术重现为使用 MSVC 编译的二进制文件。
CMD
一个重要的二进制文件是cmd.exe,希望它支持cmd.exe创建的进程的输出。
如果分配一个控制台并将管道的写句柄设置为StandardOutput和StandardError,实际上确实获取了whoami.exe进程的输出。
AllocConsole();
SetStdHandle(STD_OUTPUT_HANDLE, hPipeWrite);
SetStdHandle(STD_ERROR_HANDLE, hPipeWrite);
这意味着cmd.exe确实能够将当前控制台和我们所需的输出句柄传递给其子进程。然而,我们希望cmd.exe在不需要分配控制台的情况下传递我们的输出句柄。为此,需要将子进程上的ConsoleHandle设置为 –1,表示没有分配控制台,仅分配输出句柄。
为了准备将传递到新进程的参数,CreateProcessW将调用BasepCreateProcessParameters。此函数将从名为ConsoleConnectionState的未记录内部结构中读取一些值,该结构由AllocConsole填充。对其字段进行了逆向工程:
typedef struct _CONSOLE_CONNECTION_STATE {
/*0x00 0x01*/ BYTE Flags;
/*0x08 0x08*/ HANDLE ConsoleHandle;
/*0x10 0x08*/ HANDLE ConsoleReference;
/*0x18 0x08*/ HANDLE StandardInput;
/*0x20 0x08*/ HANDLE StandardOutput;
/*0x28 0x08*/ HANDLE StandardError;
/*0x30 0x01*/ BYTE IsConnected;
} CONSOLE_CONNECTION_STATE, * PCONSOLE_CONNECTION_STATE;
现在,让我们看一下BasepCreateProcessParameters中的说明性代码片段,其中设置了子进程的 ConsoleHandle :
ChildProcParams->ConsoleHandle = ConsoleConnectionState.ConsoleReference;
if (ChildProcParams->ConsoleHandle == NULL) {
ChildProcParams->ConsoleHandle = PEB->ProcessParameters->ConsoleHandle;
}
ConsoleHandle 设置为ConsoleConnectionState.ConsoleReference,如果为 NULL,则将其设置为当前进程的 ConsoleHandle。
我们希望ConsoleReference为 NULL,我们自己的 ConsoleHandle 为 –1。换句话说,将ConsoleReference设置为 –1。问题是,我们怎样才能得到这个内部结构的基址呢?
为了找到它,依赖于一个名为 BaseGetConsoleReference 的函数,该函数将返回 ConsoleReference,如下所示:
HANDLE BaseGetConsoleReference(void)
{
return ConsoleConnectionState.ConsoleReference;
}
但我们不关心ConsoleReference的值,我们关心ConsoleConnectionState结构的地址。所以,我简单地解析了此函数的汇编代码,如下:
48 8b 05 f9 94 19 00 mov rax,QWORD PTR [rip+0x1994f9]
c3 ret
为了获得它的地址,我们需要提取mov指令使用的偏移量,在本例中是0x1994f9。然后添加 ret 指令的地址(这是因为该函数使用 RIP 相对寻址)。结果将是 ConsoleReference 的地址,因此为了获得整个结构的基址,只需减去该字段的偏移量,即 0x10 字节。
一旦我们知道这个结构在哪里,我们将 ConsoleReference 设置为–1,将 StandardOutput 和 StandardError 设置为管道的写入句柄,现在可以获取通过cmd.exe运行的命令的输出。
PowerShell
在没有控制台的情况下运行PowerShell是这个项目中最复杂的部分之一,因为PowerShell进程与控制台密切相关,难以解耦。
首先,我分配了一个控制台,并将ConsoleHandle无效化。
AllocConsole();
SetStdHandle(STD_OUTPUT_HANDLE, hWrite);
SetStdHandle(STD_ERROR_HANDLE, hWrite);
PEB->ProcessParameters->ConsoleHandle = 0x123;
执行此操作后,没有得到任何输出,这意味着 PowerShell 进程确实需要控制台有效,否则我们不会得到任何输出。
为了准确了解句柄的使用位置,我在 WinDbg 上配置了一个硬件断点,该断点将在对 ConsoleHandle 进行读取访问时中断。ConsoleHandle存储在ProcessParameters结构中,该结构由 /_RTL_USER_PROCESS_PARAMETERS
PEB 引用,因此我获取了它的地址(在我的例子中为 0xF1F40)并配置了硬件断点,如下所示:
0:005> ba r 8 0xF1F40 "k;g"
每次函数读取控制台句柄时,都会在屏幕上打印出堆栈跟踪,然后继续执行。
几秒钟后,得到了读取 ConsoleHandle 的函数的完整列表。经过一番清理后,我得到了以下列表:
- SetThreadUILanguage
- SetThreadPreferredUILanguages2
- GetConsoleCP
- GetCurrentConsoleFontEx
- GetConsoleMode
- GetConsoleScreenBufferInfo
- GetConsoleScreenBufferInfo
- GetConsoleMode
- SetConsoleMode
- GetConsoleMode
- GetConsoleMode
一旦我知道哪些函数使用了 ConsoleHandle,就在内存中对它们进行了补丁,并用我的虚拟实现替换它们,该实现什么都不做并成功返回。
为了确保我的修改不会破坏 PowerShell 的内部工作原理,运行了以下测试:
AllocConsole();
SetStdHandle(STD_OUTPUT_HANDLE, hWrite);
SetStdHandle(STD_ERROR_HANDLE, hWrite);
patchKernelbase();
经过一些错误修复后,设法获得了 PowerShell 输出,这意味着我的虚拟实现可以正常工作。然后重新运行之前的测试,但这次我使ConsoleHandle失效:
AllocConsole();
SetStdHandle(STD_OUTPUT_HANDLE, hWrite);
SetStdHandle(STD_ERROR_HANDLE, hWrite);
patchKernelbase();
PEB->ProcessParameters->ConsoleHandle = 0x123;
但是这次失败了,我以为我一定是漏掉了一些API,因此使用之前的硬件断点重新运行测试,命中率为零,这意味着没有 API 正在读取ConsoleHandle。修改无人读取的内存地址怎么会破坏输出重定向呢?我觉得WinDbg在某种程度上漏掉了一个读取,决定继续测试。
我决定不是在运行PowerShell之前使ConsoleHandle失效,而是在我的虚拟函数中执行,这意味着该句柄将在 PowerShell 执行期间而不是之前失效。
在所有虚拟函数上一一尝试了这一点,逐一测试,意识到一些函数允许我使句柄失效(意味着我成功地重定向了输出),而有些则不允许。
经过一些清理后,我最终得到了以下列表:
- X SetThreadUILanguage
- X SetThreadPreferredUILanguages2
- X GetConsoleCP
- ✓GetCurrentConsoleFontEx
- ✓GetConsoleMode
- ✓GetConsoleScreenBufferInfo
- ✓GetConsoleScreenBufferInfo
- ✓GetConsoleMode
- ✓SetConsoleMode
- ✓GetConsoleTitleW
- ✓GetConsoleMode
- ✓GetConsoleTitleW
- ✓GetConsoleMode
- ✓SetTEBLangID
- ✓SetConsoleTitleW
- ✓GetConsoleMode
- ✓SetThreadUILanguage
- ✓GetConsoleOutputCP
- ✓GetConsoleScreenBufferInfo
- ✓GetConsoleOutputCP
- ✓SetThreadUILanguage
- ✓GetConsoleOutputC
从上面的输出来看,很明显GetConsoleCP和GetCurrentConsoleFontEx之间有一些联系。如果我们分析这两个函数调用的堆栈跟踪,就会发现它们是由同一个函数调用的:
# Child-SP RetAddr Call Site
00 KERNELBASE!GetConsoleCP
01 Microsoft_PowerShell_ConsoleHost_ni+0x71563
02 Microsoft_PowerShell_ConsoleHost_ni!Microsoft.PowerShell.ConsoleControl.UpdateLocaleSpecificFont+0x24
...
# Child-SP RetAddr Call Site
00 KERNELBASE!GetCurrentConsoleFontEx
01 Microsoft_PowerShell_ConsoleHost_ni+0x73912
02 Microsoft_PowerShell_ConsoleHost_ni!Microsoft.PowerShell.ConsoleControl.GetConsoleFontInfo+0x78
03 Microsoft_PowerShell_ConsoleHost_ni!Microsoft.PowerShell.ConsoleControl.UpdateLocaleSpecificFont+0x5a
...
有趣的是, UpdateLocaleSpecificFont的代码是公开的,可以在此处找到 。相关代码片段是:
在开始时,我们可以看到对GetConsoleCP 的调用,在最后,我们可以看到对GetCurrentConsoleFontEx 的调用。这意味着问题在于GetActiveScreenBufferHandle正在执行的操作。
在检查它的代码之后,了解到它在 这里调用这个函数:
CreateFile的文档如下:
CONOUT$ 获取活动屏幕缓冲区的句柄,即使SetStdHandle重定向标准输出句柄也是如此。
这实际上很有道理。PowerShell 使用 CONOUT$ 调用CreateFile,这最终将调用NtCreateFile,这将由 Windows 内核处理。显然,内核读取调用进程的 ConsoleHandle来为该调用提供服务,这解释了为什么硬件断点没有被触发——它是从内核域读取的。
如果ConsoleHandle无效,CreateFile将失败并返回 INVALID_HANDLE,这将使GetActiveScreenBufferHandle抛出“HostException”,而UpdateLocaleSpecificFont将无法捕获该异常。这意味着如果我们希望能够重定向 PowerShell 的输出, CreateFile 的调用必须成功。
现在我们知道哪些函数很重要,让我们讨论一下在不分配控制台的情况下如何重定向PowerShell输出的选项。
内存补丁
我们已经知道,直接修改那些想要伪装的函数的指令是有效的,但这意味着像Moneta这样的内存扫描器将能够非常容易地检测到加载程序。
因此,我们将这种方法留作最后的资源。
IAT Hooking
一个可行的替代方案是 IAT hooking,这意味着在加载 PowerShell 二进制文件时,无法正确解析相关 API 的地址。相反,将它们的地址设置为我们自己的实现,只需简单地模仿真实的实现。
然而,PowerShell并没有直接导入我们需要hook的函数,这意味着传统的IAT hooking将不起作用。尽管如此,我还是决定深入挖掘并尝试了解 PowerShell 中函数地址解析的工作原理。
我随机选择了一个使用ConsoleHandle (SetConsoleMode)的函数,并在其上配置了一个软件断点。一旦它被命中,退出当前函数来检查调用它的函数。
0:008> bp KERNELBASE!SetConsoleMode
0:008> g
Breakpoint 1 hit
KERNELBASE!SetConsoleMode:
00007ffc`f5477640 4053 push rbx
0:008> gu
Microsoft_PowerShell_ConsoleHost_ni+0x72d6e
经过一番检查,确定了该函数如何调用SetConsoleMode。这是通过以下指令完成的:
00007ffc`c6e82d08 4c8955c0 mov qword ptr [rbp-40h], r10
…
00007ffc`c6e82d44 488b4dc0 mov rcx, qword ptr [rbp-40h]
00007ffc`c6e82d48 488b4920 mov rcx, qword ptr [rcx+20h]
00007ffc`c6e82d4c 488b01 mov rax, qword ptr [rcx]
…
00007ffc`c6e82d6c ffd0 call rax <-- calls SetConsoleMode
寄存器 r10 包含一个指向存储SetConsoleMode地址的某个未知结构的指针。可以在WinDbg上复制这个来找到SetConsoleMode指针在内存中存储的地址。
0:007> dq rbp-40h L 1
00000000`00cfe310 00007ffc`c6e236a0 <-- start of unknown struct
0:007> dq 00007ffc`c6e236a0+20h L 1
00007ffc`c6e236c0 00007ffc`c6e28490 <-- pointer stored at offset 0x20
0:007> dq 00007ffc`c6e28490 L 1
00007ffc`c6e28490 00007ffc`f68356b0 <-- address of SetConsoleMode
0:007> u 00007ffc`f68356b0
KERNEL32!SetConsoleMode:
00007ffc`f68356b0 ff2532b50500 jmp qword ptr [KERNEL32!_imp_SetConsoleMode (00007ffc`f6890be8)]
好的,现在知道0x7ffcc6e28490存储SetConsoleMode的地址。但这个内存地址是谁设置的呢?为了找出答案,我再次使用了硬件断点,当有人写入该地址时会触发该断点。
在设置了硬件断点并重新运行后,我得到了一个结果:
0:007> ba w 8 00007ffc`c6e28490
0:007> g
Breakpoint 1 hit
clr!NDirectMethodDesc::SetNDirectTarget+0x3c
这意味着 CLR(而不是 PowerShell)上的 NDirectMethodDesc 函数是解析 SetConsoleMode 地址的函数。解析的确切过程并不是非常重要,因此我将只解释其背后的一般思想。
CLR 调用 clr!NDirect::NDirectLink
,后者通过调用 clr!NDirectMethodDesc::FindEntryPoint
获取 API 的地址,并通过调用 clr!NDirectMethodDesc::SetNDirectTarget
将其保存在上述结构中。函数clr!NDirectMethodDesc::FindEntryPoint
通过调用 KERNEL32!GetProcAddressForCaller
来工作。
可以通过在 GetProcAddressForCaller 上设置断点并在每次命中时将第二个参数打印为字符串来观察 CLR 如何实时解析所有相关函数:
0:013> bp kernelbase!GetProcAddressForCaller "da rdx;g"
0:013> g
00007ffa`7229dff3 "GetConsoleTitle"
00000000`1ca7eb71 "GetConsoleTitleW"
00007ffa`7229e0ad "SetConsoleCtrlHandler"
00000000`03d3dbb1 "SetConsoleCtrlHandlerW"
00007ffa`75ce2af5 "GetStdHandle"
00007ffa`767c56ce "GetConsoleMode"
00007ffa`7229e003 "SetConsoleTitle"
00000000`1ca7eb81 "SetConsoleTitleW"
...
现在对解析这些函数的地址的过程有了很好的了解,对此可以以某种方式进行滥用。因为当 CLR 解析SetConsoleMode(或任何其他函数)的地址时,它将指针存储在 RW(可读可写)的内存区域中,这意味着我们可以搜索这些指针并将其替换为我们自己的指针。
然而,这种方法并不简单,因为我们需要在 PowerShell 运行时查找和修改这些指针,但仅在它们被解析后和被使用前。
硬件断点
使用硬件断点可以让我们重定向任何函数的执行,而无需修补其内存(因此内存扫描器不会成为问题),但这种方法的主要问题是每个线程只有 4 个用于硬件断点的插槽,而我们需要挂钩 10 多个函数。
鉴于每次调用函数的顺序似乎都是相同的,我们可以简单地在第一个函数中设置一个硬件断点,一旦它被调用,解除它并在第二个函数中设置它,依此类推。
我们需要挂钩的所有函数(除了CreateFile )都只是名为NtDeviceIoControlFile的较低级别 API 的包装器。因此,我们不必担心十多个函数,而只需要担心NtDeviceIoControlFile和CreateFile。
第二个问题是我们只能在主线程上设置硬件断点。PowerShell 将创建其他没有设置任何硬件断点的线程。
此外,PowerShell 创建的线程确实会读取 ConsoleHandle,如下所示(注意,调用堆栈不是从 Beacon 的无支持内存开始):
# Child-SP RetAddr Call Site
00 KERNELBASE!GetConsoleTitleInternal+0x67 <-- function that reads the ConsoleHandle
01 KERNELBASE!GetConsoleTitleW+0x20
02 Microsoft_PowerShell_ConsoleHost_ni+0x72008
03 Microsoft_PowerShell_ConsoleHost_ni+0x5a9e8
04 Microsoft_PowerShell_ConsoleHost_ni+0x6d752
05 mscorlib_ni+0x588c87
06 mscorlib_ni+0x55fbe8
07 mscorlib_ni+0x55fad5
08 mscorlib_ni+0x589d01
09 mscorlib_ni+0x588dd1
0a mscorlib_ni+0x59ae56
0b clr!CallDescrWorkerInternal+0x83
0c clr!CallDescrWorkerWithHandler+0x47
0d clr!MethodDescCallSite::CallTargetWorker+0xfa
0e clr!QueueUserWorkItemManagedCallback+0x2a
0f clr!ManagedThreadBase_DispatchInner+0x33
10 clr!ManagedThreadBase_DispatchMiddle+0x83
11 clr!ManagedThreadBase_DispatchOuter+0x87
12 clr!ManagedThreadBase_FullTransitionWithAD+0x2f
13 clr!ManagedPerAppDomainTPCount::DispatchWorkItem+0x9a
14 clr!ThreadpoolMgr::ExecuteWorkRequest+0x51
15 clr!ThreadpoolMgr::WorkerThreadStart+0xe9
16 clr!Thread::intermediateThreadProc+0x8a
17 KERNEL32!BaseThreadInitThunk+0x14
18 ntdll!RtlUserThreadStart+0x21
为了解决这个问题,我们可以在CreateThread上设置一个硬件断点,以便我们每次在调用该函数时配置新线 程。幸运的是,事实证明这是不必要的,因为这些线程根本不需要工作来恢复输出。我们可以让它们安全地失败,但仍然可以恢复输出。
因此,只需要在主线程上的NtDeviceIoControlFile和CreateFile中设置硬件断点即可成功重定向 PowerShell 的输出。
当我检测到对CreateFile 的调用时,检查第一个参数。如果是“CONOUT$”,立即返回一个不是 –1 之外的值。如果不是,则继续执行。
如果调用NtDeviceIoControFile ,检查第一个参数。如果它是(假)ConsoleHandle ,在控制台存在时模仿NtDeviceIoControFile的行为。如果没有,则让执行继续进行。
经过所有这些步骤,我成功地欺骗了PowerShell,并重定向了其输出。
修改ConsoleHandle
另一个想要分享的想法是将 ConsoleHandle 设置为我自己的加载程序拥有的句柄。理论上,每次NtDeviceIoControleFile被调用时,都会收到消息并以与控制台相同的方式回答它。
然而,根据微软的文档,这个函数的作用是:
构建提供的缓冲区的描述符,并将未类型化的数据传递给与文件句柄关联的设备驱动程序。
这意味着这个函数允许进程与可以与文件、USB或在本例中是控制台关联的设备驱动程序进行交互。
换句话说,NtDeviceIoControFile不是为进程间通信而设计的,系统调用需要设备驱动程序的句柄,而不是管道或套接字之类的东西,因此此选项不可行。
多次运行 PowerShell
在开发这个工具的过程中,我发现一个有趣的细节,虽然 PowerShell 的重定向第一次工作得很好,但所有后续调用都失败了。
事实证明,因为我在每次运行时都创建了一个新的匿名管道,并在清理时将其关闭。PowerShell 会缓存它用于标准输出的第一个句柄,当它关闭时,输出重定向就会中断。
为了解决这个问题,我只在第一次创建匿名管道,并在所有后续运行中重复使用它。为了执行“记住”管道句柄,我使用了 Cobalt Strike 4.9 版本中推出的新键/值存储功能。
最后, mimikatz 运行的屏幕截图如下:
结论
本文中展示了对Windows内部机制有更深入理解如何帮助我们改进我们的技能。虽然这个加载器可被检测,但它能让其他人在此基础上进行构建。
每次我们涉及到未记录的Windows结构和函数时,都会面临 Windows 版本崩溃的风险,因为它们可能会在没有警告的情况下发生变化。但需要注意:在 Beacon 上运行此工具或任何其他工具之前,请始终在客户端网络上运行任何内容之前进行本地测试,点击这里可查看相关工具。
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3074/
暂无评论