前言

一种规避杀软检测的技术就是内存加密技术。由于杀软并不是一直扫描内存,而是间隙性的扫描敏感内存,因此可以在cs的shellcode调用sleep休眠将可执行内存区域加密,在休眠结束时再将内存解密来规避杀软内存扫描达到免杀的目的。

基于以上原理,结合我自己的想法进行了实现,成功实现动态免杀火绒、360、defender、卡巴等,其中卡巴不能使用cmd启动,不然会立马被杀,卡巴对cmd的审查太过严格了,导致shellcode没来的及执行sleep加密内存就被杀了,使用powershell或其它方式可以正常启动,但在运行一个小时后被杀,即使cs使用了profile配置文件的流量特征还是太过明显了,建议试试其它更冷门的c2。

下面进行讲解,包括以下四个方面:

  • 32位inline hook
  • 64位inline hook
  • 32位内存加密
  • 64位内存加密

其中32位内存加密免杀实现比较简单,64位则更为复杂,不能通过简单的hook进行实现,这里借鉴了ShellcodeFluctuation的代码。

最后,在实现内存加密的过程中,也发现了其中不足并提出改进的方法。

注意:本文面向新人,因此篇幅比较长。

效果图

下面是32位内存加密免杀的效果图,64位和这差不多就不放了:

hook

Windows API Hook是一种实现Windows平台下类似于中断的机制。它允许应用程序拦截并处理Windows消息或指定事件,当指定的消息发出后,hook程序就可以在消息到达目标窗口之前将其捕获,从而得到对消息的控制权,进而可以对该消息进行处理或修改,加入我们所需的功能。

Hook技术被广泛应用于安全的多个领域,比如杀毒软件的主动防御功能,涉及到对一些敏感API的监控,就需要对这些API进行hook;窃取密码的木马病毒,为了接收键盘的输入,需要hook键盘消息;甚至是Windows系统及一些应用程序,在打补丁时也需要使用hook技术。

API hook大致有以下几种方式:

  • Inline hook,一种应用层的注入hook,通过在内存中找到想要hook函数地址,并在其之前加入自己构造的跳转指令,当被hook位置执行时,便可以跳转到hook使用者自己编写的执行代码,在其执行完毕后,还原被修改的字节,接着执行正常流程。
  • IAT hook,一种导入表hook,通过修改导入表中某函数的地址到自己的补丁函数来实现。
  • SSDT hook,一种内核层的hook技术,通过修改系统服务表中某个服务函数的地址到自己的补丁函数来实现。
  • IRP hook,一种内核层的hook技术,通过修改IRP结构体中某个成员变量指向自己的补丁函数来实现。

需要注意的是,由于CS的shellcode获取Windows API地址的方式是通过遍历PEB结构和PE文件导出表并根据导出函数的hash值查找需要的模块和API函数,因此IAT hook方式对cs的shellcode无效,这里主要使用inline hook。

1. 前置知识

这里用到的两个函数ReadProcessMemory、WriteProcessMemory:

// 将指定地址范围中的数据从指定进程的地址空间复制到当前进程的指定缓冲区。BOOL ReadProcessMemory(  [in]  HANDLE  hProcess, // 正在读取内存的进程句柄。  [in]  LPCVOID lpBaseAddress, // 指向要从中读取的指定进程中基址的指针。  [out] LPVOID  lpBuffer, // 指向从指定进程的地址空间接收内容的缓冲区的指针。  [in]  SIZE_T  nSize, // 要从指定进程读取的字节数。  [out] SIZE_T  *lpNumberOfBytesRead // 指向变量的指针,该变量接收传输到指定进程的字节数。);// 将数据写入指定进程中的内存区域。BOOL WriteProcessMemory(  [in]  HANDLE  hProcess, // 要修改的进程内存的句柄。  [in]  LPVOID  lpBaseAddress, // 指向指定进程中写入数据的基址的指针。  [in]  LPCVOID lpBuffer, // 要写入写入的数据的缓冲区指针  [in]  SIZE_T  nSize, // 要写入指定进程的字节数。  [out] SIZE_T  *lpNumberOfBytesWritten );

对内存的读取和写入需要有对应的权限,否则无法修改,使用ReadProcessMemory、WriteProcessMemory函数进行读写内存时会自动获取对应的权限,因此可以不使用VirtualProtect修改权限。

修改函数代码跳转到我们的函数可以使用的几种汇编跳转方式,注释后面是其机器码:

// 方式一,使用jmp相对地址跳转jmp <相对地址>   ; E9 <相对地址>
// 方式二,使用寄存器jmp绝对地址跳转mov eax, <绝对地址>   ; B8 <绝对地址>jmp eax             ; FF E0
// 方式三,使用push ret绝对地址跳转push <绝对地址>   ; 68 <绝对地址>ret             ; C3

其中,相对地址计算方式如下:

相对地址 = 要跳转的函数地址 - jmp指令的地址 - jmp跳转指令的总长度。

在32位系统中函数地址长为4字节,如果要修改MessageBox函数跳转到HookedMessageBox函数,MessageBox函数地址位12340000h,HookedMessageBox地址为12345678h,指令长度为jmp指令1字节+函数地址4字节=5,那么相对地址=12345678h-12340000h-5=00005678h

所以jmp跳转指令为:

jmp 00005678h ; E9 78 56 00 00

由于jmp后面只能跟不超过4字节长度的地址,因此jmp在32位中可以跳转到任意的地址,在64位,地址长度为8字节,如果jmp指令地址长度与要跳转的地址相差超过4字节则不能使用jmp相对地址跳转。

2. 32位inline hook

32位的inline hook方式实现比较简单,实现过程如下:

  1. 获取需要挂钩的函数地址
  2. 直接修改函数代码跳转到我们自己写的新函数,即设置hook
  3. 在新函数中恢复原函数,即恢复hook
  4. 调用恢复的原函数
  5. 重新设置hook

下面将以MessageBox函数为例,使用inline hook方式挂钩MessageBox跳转到HookedMessageBox函数。

首先进入setHook函数,该函数用于设置挂钩,oldAddress保存了MessageBox函数的地址:

使用ReadProcessMemory函数从内存中读取原始MessageBox函数的前6个字节,需要在解绑定时还原。

这里使用方式三push ret绝对地址跳转,使用memcpy_s写入要跳转的HookedMessageBox函数地址到挂钩机器码数组中。如果使用方式一进行jmp相对地址跳转则修改为下面这样:

void setHook() {    SIZE_T bytesRead = 0;    // 保存原始MessageBoxA函数的前6个字节,需要在解绑定时还原    ReadProcessMemory(GetCurrentProcess(), oldAddress, messageBoxOriginalBytes, 6, &bytesRead);
    // 计算相对地址    DWORD_PTR offsetAddress = (DWORD_PTR)HookedMessageBox - (DWORD_PTR)oldAddress - 5;    char patch[6] = { 0xE9, 0, 0, 0, 0 };    memcpy_s(patch + 1, 4, &offsetAddress, 4);
    // 将挂钩写入MessageBoxA内存    WriteProcessMemory(GetCurrentProcess(), (LPVOID)oldAddress, patch, sizeof(patch), &bytesWritten);}

最后使用WriteProcessMemory将挂钩机器码数组其写入内存。

然后看下要跳转的HookedMessageBox函数,HookedMessageBox函数除了名字不同其它的参数、返回值、调用类型等应该与原MessageBox函数相同:

当从MessageBox跳转到HookedMessageBox函数时就会打印函数的执行参数,然后解除挂钩MessageBoxA,再调用原来的MessageBoxA并保存结果,然后重新设置挂钩。

进入主函数,我们先调用原有的MessageBox函数,然后通过GetProcAddress动态获取MessageBox函数的地址,然后调用setHook函数设置挂钩,再显示挂钩后的弹窗,并在setHook处打上断点:

执行程序,弹出弹窗:

按确定到断点中断执行,然后在旁边的反汇编窗口中输入oldAddress回车,查看MessageBoxA函数的汇编代码,这是没有挂钩之前的函数代码,注意看前6个机器码(8B FF 55 8B EC 83):

然后单步执行,执行setHook()函数,到挂钩后的MessageBoxA:

重新查看oldAddress函数地址,可以看到前6个机器码已经被修改成了跳转到我们自己设置的函数:

继续执行,弹出被挂钩后的弹窗:

然后可以看到控制台中截取到的函数调用参数,说明挂钩成功:

完整代码如下:

#include #include using namespace std;
FARPROC oldAddress = NULL;SIZE_T bytesWritten = 0;char messageBoxOriginalBytes[6] = {};
int __stdcall HookedMessageBox(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
void setHook() {  SIZE_T bytesRead = 0;  // 保存原始MessageBoxA函数的前6个字节,需要在解绑定时还原  ReadProcessMemory(GetCurrentProcess(), oldAddress, messageBoxOriginalBytes, 6, &bytesRead);
  void* hookedAddress = HookedMessageBox;  char patch[6] = { 0x68, 0, 0, 0, 0, 0xC3 };  memcpy_s(patch + 1, 4, &hookedAddress, 4);
  // 将挂钩写入MessageBoxA内存  WriteProcessMemory(GetCurrentProcess(), (LPVOID)oldAddress, patch, sizeof(patch), &bytesWritten);}
int __stdcall HookedMessageBox(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {  // 打印从MessageBoxA函数截取的值  cout << "Ohai from the hooked function" << endl;  cout << "Text: " << lpText << endl;  cout << "Caption: " << lpCaption << endl;
  // 解除挂钩MessageBoxA  WriteProcessMemory(GetCurrentProcess(), (LPVOID)oldAddress, messageBoxOriginalBytes, sizeof(messageBoxOriginalBytes), &bytesWritten);
  // 调用原来的MessageBoxA  int r = MessageBoxA(NULL, lpText, lpCaption, uType);
  // 重新设置挂钩,以便下次监听  setHook();  return r;}
int main(){  // 显示挂钩之前的messagebox  MessageBoxA(NULL, "hi", "hi", MB_OK);
  HINSTANCE library = LoadLibraryA("user32.dll");
  // 获取在内存中MessageBox函数的地址  oldAddress = GetProcAddress(library, "MessageBoxA");
  /*  * 创建挂钩:  * push 
  * ret  */  setHook();
  // 显示挂钩后消息框  MessageBoxA(NULL, "hi", "hi", MB_OK);  MessageBoxA(NULL, "hi", "hi", MB_OK);  return 0;}

3. 64位inline hook

由于jmp和push后面都只能跟4字节长度的地址,面对64位的8字节地址是远远不够的,因此只能借助寄存器进行绝对地址跳转:

// 方式二,使用寄存器jmp绝对地址跳转mov r12, <绝对地址>   ; 49 BC <绝对地址>jmp r12             ; 41 FF E4
// 方式三,使用push ret绝对地址跳转mov r12, <绝对地址>   ; 49 BC <绝对地址>push r12             ; 41 54 <绝对地址>ret                 ; C3

在32位系统中的eax、ebx等寄存器再64位中相应的变成rax、rbx等寄存器,且多出r8-r15等8个寄存器。

然后在setHook函数中做一下相应的修改即可:

char messageBoxOriginalBytes[13] = {};
void setHook() {    SIZE_T bytesRead = 0;    // 保存原始MessageBoxA函数的前12个字节,需要在解绑定时还原    ReadProcessMemory(GetCurrentProcess(), oldAddress, messageBoxOriginalBytes, 13, &bytesRead);
    void* hookedAddress = HookedMessageBox;    char patch[13] = { 0x49, 0xBC, 0, 0, 0, 0, 0, 0, 0, 0, 0x41, 0x54, 0xC3 };    memcpy_s(patch + 2, 8, &hookedAddress, 8);
    // 将挂钩写入MessageBoxA内存    WriteProcessMemory(GetCurrentProcess(), (LPVOID)oldAddress, patch, sizeof(patch), &bytesWritten);}

然后运行,在64位下能够正常挂钩MessageBoxA函数:

值得注意的是,在64位下并不是所有函数都能够使用inline hook进行挂钩,这也是为什么32位内存加密与64位内存加密实现方式略有不同的原因。

内存加密

使用开头提到了内存加密技术——由于杀软并不是一直扫描内存,而是间隙性的扫描敏感内存,因此可以在cs的shellcode调用sleep休眠将可执行内存区域加密,在休眠结束时再将内存解密来规避杀软内存扫描达到免杀的目的。进行加密内存时,需要注意的一点,需要加密的并不是我们为shellcode申请的内存,而是shellcode自己使用VirtualAlloc函数申请的内存:

我们需要对shellcode自己使用VirtualAlloc函数申请的内存2进行加密,这就需要挂钩sleep函数到我们自定义的HookSleep函数:

  1. 在进入HookSleep函数时使用自定义加密函数对内存2进行加密并使用VirtualProtect更改内存2权限为PAGE_NOACCESS,使其不可访问。
  2. 恢复原来的Sleep函数并调用原函数进行休眠。
  3. 在退出HookSleep函数时对内存2进行解密并使用VirtualProtect更改内存2权限为可执行权限PAGE_EXECUTE。

那么问题来了,要加密内存2,如何获取内存2的地址?

在32位中,我们可以直接挂钩VirtualAlloc函数截取返回地址。在64位中,如果还使用32的办法挂钩VirtualAlloc函数是行不通的,原因上面也有提到,在64位下并不是所有函数都能够使用inline hook进行挂钩。

对比一下32位下的VirtualAlloc函数内存与64位下的VirtualAlloc函数内存:

可以发现64位下VirtualAlloc函数内存只有一句jmp跳转指令,对于这种只有一句jmp跳转指令的函数进行挂钩时可能会出现错误,这种错误不一定会发生,当64位下挂钩VirtualAlloc时,我们自己调用没有问题,可以正常挂钩,但是cs的shellcode进行调用时就会发生错误,因此64位下不能挂钩VirtualAlloc函数,那么64位下如何获取获取内存2的地址呢?详情请看下面的64位内存加密。

加密了内存2,内存1也要进行一些处理,可以使用VirtualFree释放内存1,也可以像内存2一样加密。

1. 32位内存加密

先挂钩VirtualAlloc函数:

在HookedVirtualAlloc函数中保存申请的内存2的地址和大小,HookVirtualAlloc用于设置VirtualAlloc挂钩。

然后挂钩Sleep函数:

在HookedSleep函数中首先释放了内存1,然后VirtualProtect修改上一步获取的内存2地址为可读写,然后加密内存2再修改内存2地址为不可访问。之后调用原来的Sleep函数,在Sleep函数结束后解密内存。

然后在main函数中设置Sleep和VirtualAlloc的挂钩,然后分配内存执行shellcode:

这里并没有用什么花销的回调加载,仅使用最简单的指针加载。

执行后可以看到调用了3次VirtualAlloc函数:

第一次是我们分配shellcode内存时调用的,后面两次是shellcode自己调用的,查看shellcode第一次调用VirtualAlloc申请的内存,可以发现已经被释放了,cs shellcode是执行完一段代码就释放一段内存

再看下shellcode第二次调用VirtualAlloc申请的内存,这是cs真正执行的内存:

至此,32位内存加密完成。

2. 64位内存加密

64位实现内存加密要复制一些,不能挂钩VirtualAlloc,而是使用VirtualQueryEx函数:

// 检索有关指定进程的虚拟地址空间中的页面范围的信息。SIZE_T VirtualQueryEx(  [in]           HANDLE                    hProcess, // 查询其内存信息的进程的句柄。  [in, optional] LPCVOID                   lpAddress, // 指向要查询的页面区域的基址的指针。  [out]          PMEMORY_BASIC_INFORMATION lpBuffer, // 指向返回指定页面范围信息的 MEMORY_BASIC_INFORMATION 结构的指针。  [in]           SIZE_T                    dwLength // lpBuffer 参数指向的缓冲区的大小);

通过不断的增加lpAddress的大小,就可以遍历虚拟地址空间中的所有页面范围,通过第三个参数返回页面范围信息的 MEMORY_BASIC_INFORMATION 结构的指针:

typedef struct _MEMORY_BASIC_INFORMATION {  PVOID  BaseAddress; // 指向页面区域的基址的指针  PVOID  AllocationBase;   DWORD  AllocationProtect;  WORD   PartitionId;  SIZE_T RegionSize; // 区域大小,即内存大小  DWORD  State; // 区域中页面的状态  DWORD  Protect; // 页面权限  DWORD  Type; // 区域中页面的状态} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;

其中,如果MEMORY_BASIC_INFORMATION结构体的Type等于MEM_PRIVATE,则代表这块内存是属于动态申请的,那么不是我们申请的就是shellcode申请的。

在HookedSleep函数中调用内置函数_ReturnAddress()函数获取函数的调用地址callerAddress,然后通过VirtualQueryEx遍历所有内存页信息,然后与前面获取的所有内存页范围进行比较,如果函数的调用地址在这一块内存页范围则表明这是shellcode申请的地址,即内存2,这样就成功获取到内存2地址:

void initVirtualAllocSet(PVOID callerAddress) {  SYSTEM_INFO si;  GetSystemInfo(&si);  DWORD PageSize = si.dwPageSize;  DWORD_PTR dwMin = (DWORD_PTR)si.lpMinimumApplicationAddress;  DWORD_PTR dwMax = (DWORD_PTR)si.lpMaximumApplicationAddress;  MEMORY_BASIC_INFORMATION mbi = { 0 };  while (dwMin < dwMax)  {    VirtualQueryEx(GetCurrentProcess(), (LPCVOID)dwMin, &mbi, sizeof(mbi));    if (mbi.Type == MEM_PRIVATE && (mbi.Protect == PAGE_EXECUTE_READWRITE || mbi.Protect == PAGE_EXECUTE_READ || mbi.Protect == PAGE_READWRITE))    {      if (callerAddress >= mbi.BaseAddress && (DWORD_PTR)callerAddress <= (DWORD_PTR)mbi.BaseAddress + mbi.RegionSize) {        currentMbi = mbi;        cout << "Address: " << mbi.BaseAddress << "\tSize: " << mbi.RegionSize << "\tstate: " << hex << mbi.State << "\ttype: " << hex << mbi.Type << endl;      }    }    dwMin += mbi.RegionSize;  }}

来到HookedSleep函数:

前面通过initVirtualAllocSet函数获取内存2的地址,然后加密内存,这里采用内存1与内存2一起加密的方式,但是后面并没有解密内存的代码,这样执行完HookedSleep函数后就会因为没有解密内存而导致报出0xc0000005错误,即没有权限访问,这里利用VEH机制来解密。

Windows中主要的异常处理机制有VEH(向量异常处理)、SEH(结构化异常处理)、C++EH等,SEH就是__try、__finally、__try、__except,C++EH就是C++提供的异常处理方式,它们的异常处理顺序流程如下:

可以看到VEH更接近底层,因此能处理更多的错误。

我们定义一个错误处理函数PvectoredExceptionHandler,使用VEH处理前面报出的0xc0000005错误:

通过ExceptionInfo->ContextRecord->Rip可以获取出现错误的地址,然后比较该地址是否在内存2范围中,如果在则进行解密。然后我们需要在主函数地址中注册该函数为VEH处理函数:

AddVectoredExceptionHandler(1, &PvectoredExceptionHandler);

使用64位inline hook方式挂钩Sleep:

其它方面与32位内存加密相同,至此64位内存加密完成,执行效果:

缺点与改进

使用该内存加密的不足:

  • 需要挂钩Sleep函数(虽然可以利用VEH机制来解决),对于能够检测挂钩的杀软可能会失效。
  • 只适应于cs shellcode,其它不使用Sleep来休眠的shellcode不能使用。
  • 在刚启动和不休眠时处于明文状态,对于内存检测更强杀软来说,这一段时间就能查杀(比如用cmd启动被卡巴查杀)

改进:

可以将shellcode划分成多个片段,只有正在执行的片段处于解密状态,其它部分处于加密片段,当执行到加密片段时再利用VEH机制解密片段,shellcode划分得越小免杀效果越好,难点在于如何划分shellcode使其正好划分在一句汇编的结束位置。

源码仅限【深情种聚集地】小密圈下载,在文章左下角“阅读阅文”获得。