TLS(线程局部存储)的概念不再赘述。Shellcode是一段精心构造的代码,其与地址无关,可以直接执行。此处使用的Shellcode目的不是控制流转移,而是解混淆上文中混淆的导入表项。
《基于TLS回调的PE文件导入表项混淆》项目的主体思路不再赘述。此处Shellcode的构造可以细分为两个部分:
- 使用C/C++编写的代码,用于解混淆导入表项。
 
- 去除地址有关性,构造Shellcode。
 
通常来说Shellcode用汇编编写,但这是低效的,故在此项目我们必须使用C/C++编写Shellcode。此处编写的Shellcode是Windows下的x86 Shellcode。
编写代码,解除混淆
先整理思路。上文提及我们混淆的方案是张冠李戴,故解混淆要做的就是“李戴李冠、张戴张冠”。
1 2 3
   | FARPROC newFunc = (FARPROC)fn_MessageBoxA; char targetFuncName[] = { 'G','e','t','P','a','r','e','n','t',0 }; ...
   | 
 
如上则是将GetParent函数解混淆为MessageBoxA。这里的fn_MessageBoxA是一个函数指针,指向MessageBoxA函数。targetFuncName是GetParent函数的名称。它必须依照这样的书写方式定义,因为这句C代码将会被编译为地址无关的Shellcode。如果使用字符串格式(如"GetParent"),对于编译器来说有概率将其存储到数据段中,并使用地址引用,这样的Shellcode是不可行的。
要将本进程的导入表修改,最直观的想法是用过Win32 API获取自身句柄,遍历PE文件的导入表,找到目标函数,修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
   | void ModifyIAT(HMODULE module, const char* targetFuncName, FARPROC newFunc) {     PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)module;     PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((BYTE*)module + dosHeader->e_lfanew);          if (ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress == 0) {         printf("No import table found.\n");         return;     }          PIMAGE_IMPORT_DESCRIPTOR importDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)module + ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);          while (importDescriptor->Name) {                  PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)((BYTE*)module + importDescriptor->OriginalFirstThunk);         PIMAGE_THUNK_DATA thunkIAT = (PIMAGE_THUNK_DATA)((BYTE*)module + importDescriptor->FirstThunk);         while (thunk->u1.AddressOfData) {                          PIMAGE_IMPORT_BY_NAME importByName = (PIMAGE_IMPORT_BY_NAME)((BYTE*)module + thunk->u1.AddressOfData);             if (strcmp(importByName->Name, targetFuncName) == 0) {                                  DWORD oldProtect;                 VirtualProtect(&thunkIAT->u1.Function, sizeof(FARPROC), PAGE_READWRITE, &oldProtect);                 thunkIAT->u1.Function = (ULONG_PTR)newFunc;                 VirtualProtect(&thunkIAT->u1.Function, sizeof(FARPROC), oldProtect, &oldProtect);                 return;             }             thunk++;             thunkIAT++;         }         importDescriptor++;     }     printf("Function not found in the import table.\n"); }
  | 
 
结合注释,这段代码只是简单粗暴的遍历导入表,找到目标函数,修改IAT,以达到解混淆的目的。编译运行不难发现,这段代码是可行的。
去除地址有关性,构造Shellcode
本例使用Visual Studio 2022编写代码,编译器为MSVC。工欲善其事必先利其器,下为项目属性配置。



如果选择Debug配置,编译器会在编译时插入调试信息,这样的Shellcode是不可行的。故选择Release配置。
安全检查在此处是会影响正常运行的,故关闭。
VS默认项目开启优化。由于编译器优化会导致奇奇怪怪的问题(踩坑点),可选择关闭。实测打开优化Shellcode会减小许多。
我们知道Shellcode要求地址无关,但我们上文编写的代码显然用到了大量的系统函数。这些函数的地址是无法预知的,我们难以硬编码进入Shellcode。故我们需要动态获取这些函数的地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
   | #include <windows.h>
  FARPROC  getProcAddress(HMODULE hModuleBase); DWORD getKernel32();
  int EntryMain() {          typedef FARPROC(WINAPI* FN_GetProcAddress)(         _In_ HMODULE hModule,         _In_ LPCSTR lpProcName         );     FN_GetProcAddress fn_GetProcAddress = (FN_GetProcAddress)getProcAddress((HMODULE)getKernel32());
           typedef HMODULE(WINAPI* FN_LoadLibraryW)(         _In_ LPCWSTR lpLibFileName         );     char xyLoadLibraryW[] = { 'L','o','a','d','L','i','b','r','a','r','y','W',0 };     FN_LoadLibraryW fn_LoadLibraryW = (FN_LoadLibraryW)fn_GetProcAddress((HMODULE)getKernel32(), xyLoadLibraryW);
                
 
      return 0; }
 
  __declspec(naked) DWORD getKernel32() {     __asm     {         mov eax, fs: [30h]         mov eax, [eax + 0ch]         mov eax, [eax + 14h]         mov eax, [eax]         mov eax, [eax]         mov eax, [eax + 10h]         ret     } }
 
  FARPROC getProcAddress(HMODULE hModuleBase) {     PIMAGE_DOS_HEADER lpDosHeader = (PIMAGE_DOS_HEADER)hModuleBase;     PIMAGE_NT_HEADERS32 lpNtHeader = (PIMAGE_NT_HEADERS)((DWORD)hModuleBase + lpDosHeader->e_lfanew);     if (!lpNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size) {         return NULL;     }     if (!lpNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress) {         return NULL;     }     PIMAGE_EXPORT_DIRECTORY lpExports = (PIMAGE_EXPORT_DIRECTORY)((DWORD)hModuleBase + (DWORD)lpNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);     PDWORD lpdwFunName = (PDWORD)((DWORD)hModuleBase + (DWORD)lpExports->AddressOfNames);     PWORD lpword = (PWORD)((DWORD)hModuleBase + (DWORD)lpExports->AddressOfNameOrdinals);     PDWORD lpdwFunAddr = (PDWORD)((DWORD)hModuleBase + (DWORD)lpExports->AddressOfFunctions);
      DWORD dwLoop = 0;     FARPROC pRet = NULL;     for (; dwLoop <= lpExports->NumberOfNames - 1; dwLoop++) {         char* pFunName = (char*)(lpdwFunName[dwLoop] + (DWORD)hModuleBase);
          if (pFunName[0] == 'G' &&             pFunName[1] == 'e' &&             pFunName[2] == 't' &&             pFunName[3] == 'P' &&             pFunName[4] == 'r' &&             pFunName[5] == 'o' &&             pFunName[6] == 'c' &&             pFunName[7] == 'A' &&             pFunName[8] == 'd' &&             pFunName[9] == 'd' &&             pFunName[10] == 'r' &&             pFunName[11] == 'e' &&             pFunName[12] == 's' &&             pFunName[13] == 's')         {             pRet = (FARPROC)(lpdwFunAddr[lpword[dwLoop]] + (DWORD)hModuleBase);             break;         }     }     return pRet; } 
   | 
 
使用了GetProcAddress函数,我们可以动态获取其他函数的地址。这样的Shellcode是可行的。调用函数前只需要保证库已经加载即可。
要调用系统的函数,我们用上获取到的函数地址即可。这里以MessageBoxA为例。
1 2 3 4 5 6 7 8 9 10 11 12
   |  typedef int (WINAPI* FN_MessageBoxA)(     _In_opt_ HWND hWnd,     _In_opt_ LPCWSTR lpText,     _In_opt_ LPCWSTR lpCaption,     _In_ UINT uType); wchar_t xy_user32[] = { 'u','s','e','r','3','2','.','d','l','l',0 }; char xy_MessageBoxA[] = { 'M','e','s','s','a','g','e','B','o','x','A',0 }; FN_MessageBoxA fn_MessageBoxA = (FN_MessageBoxA)fn_GetProcAddress(fn_LoadLibraryW(xy_user32), xy_MessageBoxA);
  char xyHello[] = { 'H','e','l','l','o',' ','W','o','r','l','d',0 }; fn_MessageBoxA(NULL, xyHello, xyHello, 0);
 
  | 
 
编译后,我们只要从其中提取机器码即可。首先获取代码起始地址,然后计算代码长度,最后提取机器码。这里直接手工用010 Editor提取。


自动化的实现也很简单,其实获取到start后,让C300作为结束标记即可(通常情况下)。C3即ret指令,00是段填充。不再展开。
这份Shellcode到底能不能正常运行?注入到PE文件后我们动调看看。前文我已讲述开启编译器优化会导致奇奇怪怪的问题。不妨看一下。

踩坑点:不出所料的报错了,提示0x0非法地址。
分析发现v18值为0,对照源代码,究其原因是编译器优化莫名其妙的将一个字符串赋值优化了,导致不能找到这个函数名的地址。这是一个典型的编译器优化问题。解决方法是关闭编译器优化。

对于Oier来说编译器优化简直就是天赐好物,但在此简直是灾难。所谓甲之蜜糖乙之砒霜,其此之谓乎!
结合前文,开始测试。步骤:
- 混淆PE文件。
 
- 编写Shellcode。
 
- 注入Shellcode。
 
- IDA查看成功否。
 
混淆PE文件

编写Shellcode

注入Shellcode

IDA查看函数名确实被替换为了不正确的GetParent

运行发现正常弹窗。

可见该项目成功混淆了导入表项,且成功解混淆。对逆向工程师来说这种混淆是一种挑战。
参考
d35ha/CallObfuscator(C++)
xiongsp/TLScallback2Any(Python)
C/C++ 实现ShellCode编写与提取