本文章将讲解如何使用 Golang 来实现恶意的 dll 劫持转发
dll 转发概述
dll 转发: 攻击者使用恶意dll替换原始dll,重命名原始dll并通过恶意dll将原先的功能转发至原始dll。
该恶意dll一般用来专门执行攻击者希望拦截或修改的功能,同时将所有其他功能转发至原始dll
一般可与 dll 劫持共同使用。
dll 搜索顺序
首先我们来看一下 Windows 系统中 dll 的搜索顺序
上图中攻击者可以控制的就是标准搜索顺序中的步骤,根据情况的不同我们可以选择不同的方式来进行 dll 劫持
步骤
要实现 dll 转发,一般需要以下一些步骤
- 解析原始 dll 的导出表
- 收集出要拦截修改的函数
- 在恶意 dll 中实现拦截功能
- 将所有其他函数转发至原始 dll 上
- 重命名原始 dll
- 使用原始 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了
- -shared表示我们要编译一个共享库(非静态)
- -o指定可执行文件的输出文件名
- add.dll是我们想给我们的恶意 dll 起的名字
- 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
感兴趣的可以查看。