在学习fscan的源码时,偶然间查看到了这篇文章:技术幻影-揭开fscan免杀的面纱,才知道有CGO这个技术,于是学习了一下,并且想尝试一下反射式DLL注入能不能应用在CGo编译的DLL上,于是有了本项目FscanLoader,具体效果演示如下
0x01 CGO简介
顾名思义,CGO就是C和Go的结合,是一种允许C和Go代码混合编写的技术,它提供了强大的互操作性,可以在Go代码中直接写C的代码,也可以在C代码中调用Go的函数,让C和Go进行无缝衔接。 一个简单的例子:
package main
/*
#include <stdio.h>
extern void sayGo();
static void sayC() {
sayGo();
printf("hello from Cn");
}
*/
import "C"
import "fmt"
//export sayGo
func sayGo() {
fmt.Print("hello from gon")
}
func main() {
C.sayC()
}
可以看到,我们既能在Go代码中通过C包调用C的函数,也能将Go的代码导出给C进行调用
并且最关键的是,Go原生的编译器只允许将Go代码编译成可执行文件,不能编译成动态链接库,但是CGO允许将Go代码编译成动态链接库,前提是需要配置相应的Go环境。 可以通过执行 go env 命令来查看
其中比较关键的几个环境变量:
- AR: CGO中用于指定静态库的归档工具, 默认是GCC使用到的ar,用于生成.a格式的静态库文件
- CC: CGO中用于C代码的编译器,默认为gcc
- CGO_CFLAGS: CGO中用于传递给C编译器的标志,也就是参数
- CGO_LDFLAGS: CGO中用于传递给链接器的标志
- CGO_ENABLED: 用于表示是否启用CGO,为1表示启用,只有在启用状态下才能使用CGO进行编译
- CXX: CGO中用于指定C++代码的编译器,默认为g++
- CGO_CXXFLAGS: CGO中用于传递给C++编译器的标志
执行命令将上面示例代码编译成dll
go build -buildmode=c-shared -ldflags="-w -s" -o demo.dll main.go
在Windows下是不直接支持使用GCC环境进行编译的,所以如果要使用CGO的话,需要额外安装环境,一般是安装MinGW环境,然后使用gcc来编译(CGO貌似不支持MSVC编译环境,也有可能是我理解不够,欢迎各位师傅指导帮助)
0x02 反射式DLL注入简介
在Windows上,如果需要将一个dll加载,通常是通过调用LoadLibrary由操作系统来进行加载,要调用这个API的话,dll必须是以文件形式存储,并且操作系统加载dll时会触发各种回调函数,也容易被hook。 反射式DLL注入就是通过自己实现一个加载函数去模拟LoadLibrary的加载过程,在内存中将一个PE文件修复成一个映像文件,从而避免了dll文件落地和调用LoadLibrary 反射式DLL注入需要对PE文件的结构有一定的了解,可以参考PE文件结构详解,反射式dll加载相关介绍也有较多介绍,可以参考反射DLL注入原理解析,这里就不再赘述了,这里说下反射式注入中几个需要注意的点:
0x03 编译器优化问题
反射式加载函数要用到的变量必须位于栈上,其它文章将所需的字符串写在加载函数中,例如:
WCHAR kernel32[] = { L'K', L'e', L'r', L'n', L'e', L'l', L'3', L'2', L'.', L'd', L'l', L'l', L'�' };
WCHAR ntdll[] = { L'n', L't', L'd', L'l', L'l', L'.', L'd', L'l', L'l', L'�' };
WCHAR user32[] = { L'U', L's', L'e', L'r', L'3', L'2', L'.', L'd', L'l', L'l', L'�' };
CHAR virtualAlloc[] = { 'V', 'i', 'r', 't', 'u', 'a', 'l', 'A', 'l', 'l', 'o', 'c', '�' };
CHAR virtualProtect[] = { 'V', 'i', 'r', 't', 'u', 'a', 'l', 'P', 'r', 'o', 't', 'e', 'c', 't', '�' };
CHAR rtladdFunctionTable[] = { 'R', 't', 'l', 'A', 'd', 'd', 'F', 'u', 'n', 'c', 't', 'i', 'o', 'n', 'T', 'a', 'b', 'l', 'e', '�' };
CHAR ntFlushInstructionCache[] = { 'N', 't', 'F', 'l', 'u', 's', 'h', 'I', 'n', 's', 't', 'r', 'u', 'c', 't', 'i', 'o', 'n', 'C', 'a', 'c', 'h', 'e', '�' };
CHAR loadLibraryA[] = { 'L', 'o', 'a', 'd', 'L', 'i', 'b', 'r', 'a', 'r', 'y', 'A', '�' };
如果此时开启了编译器优化的话,调试程序会发现有些字符串并不是直接写在栈空间,而是通过取固定偏移量地址中的值给xmm0寄存器,然后再放入栈中
这个固定偏移量已经是加载后的,如果在加载前使用的话就会因为内存访问异常而崩溃,即使不崩溃也不能取到正确的值,所以说需要禁止编译器对代码进行优化
0x04 获取PE基址问题
反射式加载时需要确定当前PE文件的基址,然后通过PE基址去进行后续的解析和修复。 获取PE基址的一般思路就是先获取eip/rip寄存器的值,然后向前遍历,直到遇到PE文件的DOS魔数和PE魔数。 在64位下,获取当前rip寄存器的值,可以直接通过当前函数地址来获取,例如:
int main() {
ULONG_PTR address = (ULONG_PTR)main;
cout << "address: " << address << endl;
return 0;
}
取函数地址对应的汇编代码为:
48 8D 15 E6 FF FF FF lea rdx,
也就是取rip前26个字节的地址,刚好就是main函数的入口地址,这是因为64位下的lea指令支持相对寻址,所以64位下能直接通过函数名称获取当前函数的地址。 而这段程序在32位下的汇编代码为:
取函数地址对应的汇编代码为:
C7 45 FC 00 10 F6 00 mov dword ptr
可以看到是直接将一个硬编码的地址进行赋值,这个硬编码的地址是加载之后才正确的值,原因是32位下lea指令不支持相对寻址,所以32位下反射式注入不能直接通过函数名来获取eip寄存器的值,而是通过以下汇编代码来获取eip的值:
ULONG_PTR address;
__asm {
call $ + 5
pop eax
mov address, eax
}
简单解释下这段汇编代码:
- call $ + 5 : $表示当前指令的地址(eip),$ + 5表示eip+5, 由于call指令本身占5字节,所以相当于什么都不做,但是会把下一条指令的地址压入到栈中
- pop eax: 从栈中弹出值给eax,此时eax中存储的是eip寄存器的值
- mov address, eax: 将eax的值赋值给address变量,此时address变量存储的就是eip的值了
eip/rip是特殊寄存器,不能直接访问,所以需要通过这种方式间接获取
由于fscan编译的dll体积比较大,所以说不能简单的通过DOS魔数和PE魔数来寻找PE基址,需要加入一些其它的自定义特征来避免找错PE。 在PE文件中,IMAGEDOSHEADER结构体中除了emagic和elfanew以外的成员都是无关紧要的,可以将其更改成自定义特征值
也可以修改IMAGEDOSSTUB结构体,它里面所有成员都是可以随意修改的,但是IMAGEDOSSTUB结构体的大小并不是固定的
0x05 加载器
用于加载fscan.dll的加载器参考了CS Shellcode的实现逻辑,具体可参考CobaltStrike Shellcode分析,解析TEB结构体动态查找所需的API地址,然后调用API将fscan.dll写入内存,然后查找反射式加载函数,完成fscan.dll的加载
0x06 后话
Go程序由Go Runtime来管理,这次尝试将Go编译的dll进行反射式加载,发现也能成功加载,并不会道导致Go Runtime的异常。 loader不是只能用来加载fscan.dll,稍作变更也可用于加载其他反射式dll,同样fscan.dll也不一定要通过该loader来加载,其他的例如白加黑也能加载。
原文始发于微信公众号(安全的黑魔法):一个用于绕过杀软加载fscan的加载器
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论