18.1 原生套接字抓包

原生套接字抓包的实现原理依赖于Windows系统中提供的ioctlsocket函数,该函数可将指定的网卡设置为混杂模式,网卡混杂模式(Promiscuous Mode)是常用于计算机网络抓包的一种模式,也称为监听模式。在混杂模式下,网卡可以收到经过主机的所有数据包,而非只接收它所对应的MAC地址的数据包。

一般情况下,网卡会根据MAC地址过滤数据包,只有MAC地址与网卡所对应的设备的通信数据包才会被接收和处理,其他数据包则会被忽略。但在混杂模式下,网卡会接收经过它所连接的网络中所有的数据包,这些数据包可以是面向其他设备的通信数据包、广播数据包或多播数据包等。

混杂模式可以通过软件驱动程序或网卡硬件实现。启用混杂模式的主要用途之一是网络抓包分析,使用混杂模式可以捕获网络中所有的数据包,且不仅仅是它所连接的设备的通信数据包。因此,可以完整获取网络中的通信内容,便于进行网络监控、安全风险感知、漏洞检测等操作。

Windows系统下,开启混杂模式可以使用ioctlsocket()函数,该函数原型定义如下:

int ioctlsocket (
SOCKET s, //要操作的套接字
long cmd, //操作代码
u_long *argp //指向操作参数的指针
);

其中,参数说明如下:

  • s: 要执行I/O控制操作的套接字。
  • cmd: 操作代码,用于控制对套接字的特定操作。
  • argp: 与特定请求代码相关联的参数指针。此参数的具体含义取决于请求代码。

在该函数中,参数cmd指定了I/O控制操作代码,是一个整数值,用于控制对套接字的特定操作。argp是一个指向特定请求代码相关联的参数的指针,它的具体含义将取决于请求代码。函数返回值为int类型,表示函数执行结果的状态码,若函数执行成功,则其返回值为0,否则返回一个错误代码,并将错误原因存入errno变量中。

要实现抓包前提是需要先选中绑定到那个网卡,如下InitAndSelectNetworkRawSocket函数则是实现绑定套接字到特定网卡的实现流程,在代码中首先初始化并使用gethostname函数获取到当前主机的主机名,主机IP地址等基本信息,接着通过循环的方式将自身网卡信息追加到g_HostIp全局结构体内进行存储,通过使用一个交互式选择菜单让用户可以选中需要绑定的网卡名称,当用户选中后则下一步是绑定套接字,并通过调用ioctlsocket函数将网卡设置为混杂模式,至此网卡的绑定工作就算结束了,当读者需要操作时只需要对全局变量进行操作即可,而选择函数仅仅只是获取到网卡信息而已并没有实际的作用。

#include <iostream>
#include <WinSock2.h>
#include <ws2tcpip.h>
#include <mstcpip.h>

#pragma comment(lib, "ws2_32.lib")

// 全局结构
typedef struct
{
int iLen;
char szIPArray[10][50];
}HOSTIP;

// 全局变量
SOCKET g_RawSocket = 0;
HOSTIP g_HostIp;

// -------------------------------------------------------
// 初始化与选择套接字
// -------------------------------------------------------
BOOL InitAndSelectNetworkRawSocket()
{
// 设置套接字版本
WSADATA wsaData = { 0 };
if (0 != WSAStartup(MAKEWORD(2, 2), &wsaData))
{
return FALSE;
}
// 创建原始套接字
// Windows无法抓取RawSocket MAC层的数据包,只能抓到IP层及以上的数据包
g_RawSocket = socket(AF_INET, SOCK_RAW, IPPROTO_IP);
// g_RawSocket = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (INVALID_SOCKET == g_RawSocket)
{
WSACleanup();
return FALSE;
}

// 绑定到接口 获取本机名
char szHostName[MAX_PATH] = { 0 };
if (SOCKET_ERROR == ::gethostname(szHostName, MAX_PATH))
{
closesocket(g_RawSocket);
WSACleanup();
return FALSE;
}

// 根据本机名获取本机IP地址
hostent* lpHostent = ::gethostbyname(szHostName);
if (NULL == lpHostent)
{
closesocket(g_RawSocket);
WSACleanup();
return FALSE;
}

// IP地址转换并保存IP地址
g_HostIp.iLen = 0;
strcpy(g_HostIp.szIPArray[g_HostIp.iLen], "127.0.0.1");
g_HostIp.iLen++;
char* lpszHostIP = NULL;

while (NULL != (lpHostent->h_addr_list[(g_HostIp.iLen - 1)]))
{
lpszHostIP = inet_ntoa(*(in_addr*)lpHostent->h_addr_list[(g_HostIp.iLen - 1)]);
strcpy(g_HostIp.szIPArray[g_HostIp.iLen], lpszHostIP);
g_HostIp.iLen++;
}

// 选择IP地址对应的网卡来嗅探
printf("选择侦听网卡 \n\n");
for (int i = 0; i < g_HostIp.iLen; i++)
{
printf("\t [*] 序号: %d \t IP地址: %s \n", i, g_HostIp.szIPArray[i]);
}

printf("\n 选择网卡序号: ");
int iChoose = 0;
scanf("%d", &iChoose);

// 如果选择超出范围则直接终止
if ((0 > iChoose) || (iChoose >= g_HostIp.iLen))
{
exit(0);
}
if ((0 <= iChoose) && (iChoose < g_HostIp.iLen))
{
lpszHostIP = g_HostIp.szIPArray[iChoose];
}

// 构造地址结构
sockaddr_in SockAddr = { 0 };
RtlZeroMemory(&SockAddr, sizeof(sockaddr_in));
SockAddr.sin_addr.S_un.S_addr = inet_addr(lpszHostIP);
SockAddr.sin_family = AF_INET;
SockAddr.sin_port = htons(0);

// 绑定套接字
if (SOCKET_ERROR == bind(g_RawSocket, (sockaddr*)(&SockAddr), sizeof(sockaddr_in)))
{
closesocket(g_RawSocket);
WSACleanup();
return FALSE;
}

// 设置混杂模式 抓取所有经过网卡的数据包
DWORD dwSetVal = 1;
if (SOCKET_ERROR == ioctlsocket(g_RawSocket, SIO_RCVALL, &dwSetVal))
{
closesocket(g_RawSocket);
WSACleanup();
return FALSE;
}
return TRUE;
}

int main(int argc, char *argv[])
{
// 选择网卡并设置网络为非阻塞模式
BOOL SelectFlag = InitAndSelectNetworkRawSocket();
if (SelectFlag == TRUE)
{
printf("[*] 网卡已被选中 套接字ID = %d | 套接字IP = %s \n", g_RawSocket,g_HostIp.szIPArray);
}

system("pause");
return 0;
}

读者可自行编译并以管理员身份运行上述代码片段,当读者运行后会看到如下图所示的代码片段,此处笔者就选择三号网卡进行绑定操作,当绑定后此时套接字ID对应的则是特定的网卡,后续的操作均可针对此套接字ID进行,如下图所示;

当读者有了设置混杂模式的功能则下一步就是抓包了,抓包的实现很简单,只需要在开启了非阻塞混杂模式的网卡上使用recvfrom函数循环进行监听即可,当有数据包产生时则直接输出iRecvBytes中所存储的数据即可,这段代码的实现如下所示;

int main(int argc, char *argv[])
{
// 选择网卡并设置网络为非阻塞模式
BOOL init_flag = InitAndSelectNetworkRawSocket();
if (init_flag == FALSE)
{
return 0;
}

sockaddr_in RecvAddr = { 0 };
int iRecvBytes = 0;
int iRecvAddrLen = sizeof(sockaddr_in);

// 定义缓冲区长度
DWORD dwBufSize = 12000;
BYTE* lpRecvBuf = new BYTE[dwBufSize];

// 循环接收接收
while (1)
{
RtlZeroMemory(&RecvAddr, iRecvAddrLen);
iRecvBytes = recvfrom(g_RawSocket, (char*)lpRecvBuf, dwBufSize, 0, (sockaddr*)(&RecvAddr), &iRecvAddrLen);
if (0 < iRecvBytes)
{
// 接收数据包并输出
printf("[接收数据包] %s \n", lpRecvBuf);
}
}

// 释放内存
delete[]lpRecvBuf;
lpRecvBuf = NULL;

// 关闭套接字
Sleep(500);
closesocket(g_RawSocket);
WSACleanup();
return 0;
}

当读者选择网卡后即可看到如下所示的输出结果,这些数据则是经过网卡192.168.9.125的所有数据,由于此处没有解码和区分数据包类型所以显示出的字符串并没有任何意义,如下图所示;

接下来我们就需要根据不同的数据包类型对这些数据进行解包操作,在解包之前我们需要先来定义几个关键的数据包结构体,如下代码中ether_header代表的是以太网包头结构,该结构占用14个字节的存储空间,arp_header则是ARP结构体,该结构体占用28个字节,ARK结构中还存在一个ARK报文结构,该结构占用42字节的内存长度,接着分别顶一个ipv4_headeripv6_headertcp_headerudp_header等结构体,这些结构体的完整定义如下所示;

#pragma pack(1)

/*以太网帧头格式结构体 14个字节*/
typedef struct ether_header
{
unsigned char ether_dhost[6]; // 目的MAC地址
unsigned char ether_shost[6]; // 源MAC地址
unsigned short ether_type; // eh_type 的值需要考察上一层的协议,如果为ip则为0x0800
}ETHERHEADER, * PETHERHEADER;

/*以ARP字段结构体 28个字节*/
typedef struct arp_header
{
unsigned short arp_hrd;
unsigned short arp_pro;
unsigned char arp_hln;
unsigned char arp_pln;
unsigned short arp_op;
unsigned char arp_sourha[6];
unsigned long arp_sourpa;
unsigned char arp_destha[6];
unsigned long arp_destpa;
}ARPHEADER, * PARPHEADER;

/*ARP报文结构体 42个字节*/
typedef struct arp_packet
{
ETHERHEADER etherHeader;
ARPHEADER arpHeader;
}ARPPACKET, * PARPPACKET;

/*IPv4报头结构体 20个字节*/
typedef struct ipv4_header
{
unsigned char ipv4_ver_hl; // Version(4 bits) + Internet Header Length(4 bits)长度按4字节对齐
unsigned char ipv4_stype; // 服务类型
unsigned short ipv4_plen; // 总长度(包含IP数据头,TCP数据头以及数据)
unsigned short ipv4_pidentify; // ID定义单独IP
unsigned short ipv4_flag_offset; // 标志位偏移量
unsigned char ipv4_ttl; // 生存时间
unsigned char ipv4_pro; // 协议类型
unsigned short ipv4_crc; // 校验和
unsigned long ipv4_sourpa; // 源IP地址
unsigned long ipv4_destpa; // 目的IP地址
}IPV4HEADER, * PIPV4HEADER;

/*IPv6报头结构体 40个字节*/
typedef struct ipv6_header
{
unsigned char ipv6_ver_hl;
unsigned char ipv6_priority;
unsigned short ipv6_lable;
unsigned short ipv6_plen;
unsigned char ipv6_nextheader;
unsigned char ipv6_limits;
unsigned char ipv6_sourpa[16];
unsigned char ipv6_destpa[16];
}IPV6HEADER, * PIPV6HEADER;

/*TCP报头结构体 20个字节*/
typedef struct tcp_header
{
unsigned short tcp_sourport; // 源端口
unsigned short tcp_destport; // 目的端口
unsigned long tcp_seqnu; // 序列号
unsigned long tcp_acknu; // 确认号
unsigned char tcp_hlen; // 4位首部长度
unsigned char tcp_reserved; // 标志位
unsigned short tcp_window; // 窗口大小
unsigned short tcp_chksum; // 检验和
unsigned short tcp_urgpoint; // 紧急指针
}TCPHEADER, * PTCPHEADER;

/*UDP报头结构体 8个字节*/
typedef struct udp_header
{
unsigned short udp_sourport; // 源端口
unsigned short udp_destport; // 目的端口
unsigned short udp_hlen; // 长度
unsigned short udp_crc; // 校验和
}UDPHEADER, * PUDPHEADER;
#pragma pack()

当有了结构体的定义部分,则实现对数据包的解析只需要判断数据包的类型并使用不同的结构体对数据包进行解包打印即可,如下是实现数据包解析的完整代码,在代码中分别实现了几个核心函数,其中printData函数可以实现对特定内存数据的十六进制格式输出方便检查输出效果,函数AnalyseRecvPacket_All用于解析除去TCP/UDP格式的其他数据包,AnalyseRecvPacket_TCP用于解析TCP数据,AnalyseRecvPacket_UDP用于解析UDP数据,在主函数中通过使用ip->ipv4_pro判断数据包的具体类型,并根据类型的不同依次调用不同的函数实现数据包解析。

// 输出数据包
void PrintData(BYTE* lpBuf, int iLen, int iPrintType)
{
// 16进制
if (0 == iPrintType)
{
for (int i = 0; i < iLen; i++)
{
if ((0 == (i % 8)) && (0 != i))
{
printf(" ");
}
if ((0 == (i % 16)) && (0 != i))
{
printf("\n");
}
printf("%02x ", lpBuf[i]);

}
printf("\n");
}
// ASCII编码
else if (1 == iPrintType)
{
for (int i = 0; i < iLen; i++)
{
printf("%c", lpBuf[i]);
}
printf("\n");
}
}

// 解析所有其他数据包
void AnalyseRecvPacket_All(BYTE* lpBuf)
{
struct sockaddr_in saddr, daddr;
PIPV4HEADER ip = (PIPV4HEADER)lpBuf;
saddr.sin_addr.s_addr = ip->ipv4_sourpa;
daddr.sin_addr.s_addr = ip->ipv4_destpa;

printf("From:%s --> ", inet_ntoa(saddr.sin_addr));
printf("To:%s\n", inet_ntoa(daddr.sin_addr));
}

// 解析TCP数据包
void AnalyseRecvPacket_TCP(BYTE* lpBuf)
{
struct sockaddr_in saddr, daddr;
PIPV4HEADER ip = (PIPV4HEADER)lpBuf;
PTCPHEADER tcp = (PTCPHEADER)(lpBuf + (ip->ipv4_ver_hl & 0x0F) * 4);
int hlen = (ip->ipv4_ver_hl & 0x0F) * 4 + tcp->tcp_hlen * 4;

// 这里要将网络字节序转换为本地字节序
int dlen = ntohs(ip->ipv4_plen) - hlen;
saddr.sin_addr.s_addr = ip->ipv4_sourpa;
daddr.sin_addr.s_addr = ip->ipv4_destpa;

printf("From:%s:%d --> ", inet_ntoa(saddr.sin_addr), ntohs(tcp->tcp_sourport));
printf("To:%s:%d ", inet_ntoa(daddr.sin_addr), ntohs(tcp->tcp_destport));
printf("ack:%u syn:%u length=%d\n", tcp->tcp_acknu, tcp->tcp_seqnu, dlen);

PrintData((lpBuf + hlen), dlen, 0);
}

// 解析UDP数据包
void AnalyseRecvPacket_UDP(BYTE* lpBuf)
{
struct sockaddr_in saddr, daddr;
PIPV4HEADER ip = (PIPV4HEADER)lpBuf;
PUDPHEADER udp = (PUDPHEADER)(lpBuf + (ip->ipv4_ver_hl & 0x0F) * 4);
int hlen = (int)((ip->ipv4_ver_hl & 0x0F) * 4 + sizeof(UDPHEADER));
int dlen = (int)(ntohs(udp->udp_hlen) - 8);

// int dlen = (int)(udp->udp_hlen - 8);
saddr.sin_addr.s_addr = ip->ipv4_sourpa;
daddr.sin_addr.s_addr = ip->ipv4_destpa;
printf("Protocol:UDP ");
printf("From:%s:%d -->", inet_ntoa(saddr.sin_addr), ntohs(udp->udp_sourport));
printf("To:%s:%d\n", inet_ntoa(daddr.sin_addr), ntohs(udp->udp_destport));

PrintData((lpBuf + hlen), dlen, 0);
}

int main(int argc, char* argv[])
{
// 选择网卡,并设置网络为非阻塞模式
InitAndSelectNetworkRawSocket();

sockaddr_in RecvAddr = { 0 };
int iRecvBytes = 0;
int iRecvAddrLen = sizeof(sockaddr_in);
DWORD dwBufSize = 12000;
BYTE* lpRecvBuf = new BYTE[dwBufSize];

// 循环接收接收
while (1)
{
RtlZeroMemory(&RecvAddr, iRecvAddrLen);
iRecvBytes = recvfrom(g_RawSocket, (char*)lpRecvBuf, dwBufSize, 0, (sockaddr*)(&RecvAddr), &iRecvAddrLen);
if (0 < iRecvBytes)
{
// 接收数据包解码输出
// 分析IP包的协议类型
PIPV4HEADER ip = (PIPV4HEADER)lpRecvBuf;
switch (ip->ipv4_pro)
{
case IPPROTO_ICMP:
{
// 分析ICMP
printf("[ICMP]\n");
AnalyseRecvPacket_All(lpRecvBuf);
break;
}
case IPPROTO_IGMP:
{
// 分析IGMP
printf("[IGMP]\n");
AnalyseRecvPacket_All(lpRecvBuf);
break;
}
case IPPROTO_TCP:
{
// 分析tcp协议
printf("[TCP]\n");
AnalyseRecvPacket_TCP(lpRecvBuf);
break;
}
case IPPROTO_UDP:
{
// 分析udp协议
printf("[UDP]\n");
AnalyseRecvPacket_UDP(lpRecvBuf);
break;
}
default:
{
// 其他数据包
printf("[OTHER IP]\n");
AnalyseRecvPacket_All(lpRecvBuf);
break;
}
}
}
}

// 释放内存
delete[]lpRecvBuf;
lpRecvBuf = NULL;

// 关闭套接字
Sleep(500);
closesocket(g_RawSocket);
WSACleanup();
return 0;
}

读者可自行编译并运行上述代码片段,当程序运行后可自行选择希望监控的网卡,当程序中检测到TCP数据包后会输出如下图所示的提示信息,在图中我们可以清晰的看出数据包的流向信息,以及数据包长度数据包内的数据等;

当读者通过使用Ping命令探测目标主机时,此时同样可以抓取到ICMP相关的数据流,只是在数据解析时并没有太规范导致只能看到简单的流向,当然读者也可以自行完善这段代码,让其能够解析更多参数。