16.1 Socket 端口扫描技术

端口扫描是一种网络安全测试技术,该技术可用于确定对端主机中开放的服务,从而在渗透中实现信息搜集,其主要原理是通过发送一系列的网络请求来探测特定主机上开放的TCP/IP端口。具体来说,端口扫描程序将从指定的起始端口开始,向目标主机发送一条TCPUDP消息(这取决于端口的协议类型)。如果目标主机正在监听该端口,则它将返回一个确认消息,这表明该端口是开放的。如果没有响应,则说明该端口是关闭的或被过滤。

首先我们来了解一下阻塞与非阻塞模式:

  • 阻塞模式是指当I/O操作无法立即完成时,应用程序会阻塞并等待操作完成。例如,在使用阻塞套接字接收数据时,如果没有数据可用,则调用函数将一直阻塞,直到有数据可用为止。在这种模式下,I/O操作将会一直阻塞应用程序的进程,因此无法执行其他任务。

  • 非阻塞模式是指当I/O操作无法立即完成时,应用程序会立即返回并继续执行其他任务。例如,在使用非阻塞套接字接收数据时,如果没有数据可用,则调用函数将立即返回,并指示操作正在进行中,同时应用程序可以执行其他任务。在这种模式下,应用程序必须反复调用I/O操作以检查其完译状态,这通常是通过轮询或事件通知机制实现的。非阻塞模式允许应用程序同时执行多个任务,但每个I/O操作都需要增加一定的额外开销。

要实现端口探测我们可以通过connect()这个函数来实现,利用connect函数实现端口开放检查的原理是通过TCP协议的三次握手过程来探测目标主机是否开放目标端口。

TCP协议的三次握手过程中,客户端向服务器发送一个SYN标志位的TCP数据包。如果目标主机开放了目标端口并且正在监听连接请求,则服务器会返回一个带有SYNACK标志位的TCP数据包,表示确认连接请求并请求客户端确认。此时客户端回应一个ACK标志位的TCP数据包,表示确认连接请求,并建立了一个到服务器端口的连接。此时客户端和服务器端之间建立了一个TCP连接,可以进行数据传输。

如果目标主机没有开放目标端口或者目标端口已经被占用,则服务器不会响应客户端的TCP数据包,客户端会在一定时间后收到一个超时错误,表示连接失败。

因此,通过调用connect函数,可以向目标主机发送一个SYN标志位的TCP数据包并等待服务器响应,从而判断目标端口是否开放。如果connect函数返回0,则表示连接成功,目标端口开放;否则,连接失败,目标端口未开放或目标主机不可达。

c 复制代码
// 探测网络端口开放情况
BOOL PortScan(char *Addr, int Port)
{
  WSADATA wsd;
  SOCKET sHost;
  SOCKADDR_IN servAddr;

  // 初始化套接字库
  if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
  {
    return FALSE;
  }

  // 创建套接字
  sHost = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  if (INVALID_SOCKET == sHost)
  {
    return FALSE;
  }

  // 设置连接地址和端口
  servAddr.sin_family = AF_INET;
  servAddr.sin_addr.S_un.S_addr = inet_addr(Addr);
  servAddr.sin_port = htons(Port);

  // 连接测试
  int retval = connect(sHost, (LPSOCKADDR)&servAddr, sizeof(servAddr));
  if (retval != SOCKET_ERROR)
  {
    return TRUE;
  }

  WSACleanup();
  closesocket(sHost);
  return FALSE;
}

int main(int argc, char* argv[])
{
  int port_list[] = { 80, 443, 445, 135, 139, 445 };
  int port_size = sizeof(port_list) / sizeof(int);

  for (int x = 0; x < port_size; x++)
  {
    int ret = PortScan("8.141.58.64", port_list[x]);
    printf("循环次数: %d 端口: %d 状态: %d \n", x + 1, port_list[x], ret);
  }

  system("pause");
  return 0;
}

上述代码片段则是一个简单的端口探测案例,当运行后程序会调用connect函数向目标主机发送一个SYN标志位的TCP数据包,探测目标端口是否开放。如果目标主机响应带有SYNACK标志位的TCP数据包,则表示连接请求成功并请求确认,操作系统在自动发送带ACK标志位的TCP数据包进行确认,建立TCP连接;

如果目标主机没有响应或者响应带有RST标志位的TCP数据包,则表示连接请求失败,目标端口为未开放状态。通过此方式,程序可以快速检测多个端口是否开放,该程序运行后输出效果如下图所示;

上述代码虽然可以实现端口扫描,但是读者应该会发现此方法扫描很慢,这是因为扫描器每次只能链接一个主机上的端口只有当connect函数返回后才会执行下一次探测任务,而如果需要提高扫描效率那么最好的方法是采用非阻塞的扫描模式,使用非阻塞模式我们可以在不使用多线程的情况下提高扫描速度。

非阻塞模式所依赖的核心函数为select()函数是一种用于多路I/O复用的系统调用,在Windows中提供了对该系统调用的支持。select()函数可以同时监听多个文件或套接字(socket)的可读、可写和出错状态,并返回有状态变化的文件或套接字的数量,在使用该函数时读者应率先调用ioctlsocket()函数,并设置FIONBIO套接字为非阻塞模式。

select 函数的基本语法如下:

c 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数解释:

  • nfds:需要监听的文件或套接字最大编号加1
  • readfds:可读文件或套接字集合
  • writefds:可写文件或套接字集合
  • exceptfds:出错文件或套接字集合
  • timeout:超时时间,如果为NULL,则表示一直等待直到有事件发生

select 函数会阻塞进程,直到在需要监听的文件或套接字中有一个或多个文件或套接字发送了需要监听的事件,或者超时时间到达。当select()函数返回时,可以通过fd_set集合来查询有状态变化的文件或套接字。

select 函数的原理是将调用进程的文件或套接字加入内核监测队列,等待事件发生。当某个文件或套接字有事件发生时,内核会将其添加到内核缓冲区中,同时在返回时告诉进程有哪些套接字可以进行I/O操作,进程再根据文件或套接字的状态进行相应的处理。使用select()函数可以大大提高I/O操作的效率,减少资源占用。

如下代码实现的是一段简单的端口扫描程序,用于检查目标主机的一段端口范围内是否有端口处于开放状态。该函数中通过设置fd_set类型的掩码(mask)并加入套接字,使用select()函数查询该套接字的可写状态,并设置超时时间为1毫秒,如果返回值为0,则目标端口未开放,继续下一个端口的扫描。如果返回值为正数,则目标端口已成功连接(开放),输出扫描结果并继续下一个端口的扫描。

该代码中使用了非阻塞套接字和select()函数的组合来实现非阻塞IO。非阻塞套接字可以使程序不会在等待数据到来时一直阻塞,而是可以在等待数据到来的同时进行其他操作,从而提高程序的效率。select()函数则可以同时等待多个套接字的数据到来,从而使程序更加高效地进行I/O操作。

c 复制代码
// 非阻塞端口探测
void PortScan(char *address, int StartPort, int EndPort)
{
  SOCKADDR_IN ServAddr;
  TIMEVAL TimeOut;
  FD_SET mask;

  TimeOut.tv_sec = 0;

  // 设置超时时间为500毫秒
  TimeOut.tv_usec = 1000;
  // 指定模式
  unsigned long mode = 1;

  // 循环扫描端口
  for (int port = StartPort; port <= EndPort; port++)
  {
    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    ServAddr.sin_family = AF_INET;
    ServAddr.sin_addr.S_un.S_addr = inet_addr(address);
    ServAddr.sin_port = htons(port);

    FD_ZERO(&mask);
    FD_SET(sock, &mask);

    // 设置为非阻塞模式
    ioctlsocket(sock, FIONBIO, &mode);
    connect(sock, (struct sockaddr *)&ServAddr, sizeof(ServAddr));

    // 查询可写入状态 如果不为0则说明这个端口是开放的
    int ret = select(0, 0, &mask, 0, &TimeOut);
    if (ret != 0 && ret != -1)
    {
      printf("扫描地址: %-13s --> 端口: %-5d --> 状态: [Open] \n", address, port);
    }
    else
    {
      printf("扫描地址: %-13s --> 端口: %-5d --> 状态: [Close] \n", address, port);
    }
  }
}

int main(int argc, char *argv[])
{
  char *Addr[2] = { "192.168.1.1", "192.168.1.10" };

  WSADATA wsa;
  if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
  {
    exit(0);
  }

  for (int x = 0; x < 2; x++)
  {
    PortScan(Addr[x], 1, 255);
  }

  WSACleanup();

  system("pause");
  return 0;
}

读者可自行编译并运行上述代码片段,默认会扫描Addr[2]数组内的两个IP地址的1-255端口范围开放情况,读者可感觉到效率上变得快了许多,输出效果如下图所示;

上述代码虽然增加的扫描速度但是还可以进一步优化,我们可以通过增加信号机制,通过使用信号可以很好的控制扫描并发连接数,增加了线程控制将会使扫描器更加稳定,同时我们还引用了多线程模式,通过两者的结合可以极大的提高扫描质量和效率。

基于信号的端口扫描,也称为异步IO端口扫描,是一种高效的端口扫描技术,可以利用操作系统的信号机制提高网络I/O的效率。基于信号的端口扫描具有非阻塞和异步的特性,可以最大限度地提高网络I/O效率,同时在大并发量下表现出更好的性能。但是,使用时需要小心处理信号的相关问题,避免死锁和数据不一致。

c 复制代码
#include <stdio.h>
#include <winsock2.h>

#pragma comment (lib, "ws2_32")

typedef struct _THREAD_PARAM
{
  char *HostAddr;             // 扫描主机
  DWORD dwStartPort;          // 端口号
  HANDLE hEvent;              // 事件句柄
  HANDLE hSemaphore;          // 信号量句柄
}THREAD_PARAM;

// 最大线程数,用于控制信号量数量
#define MAX_THREAD 10

// 线程扫描函数
DWORD WINAPI ScanThread(LPVOID lpParam)
{
  // 拷贝传递来的扫描参数
  THREAD_PARAM ScanParam = { 0 };
  MoveMemory(&ScanParam, lpParam, sizeof(THREAD_PARAM));

  // 设置信号
  SetEvent(ScanParam.hEvent);

  WSADATA wsa;
  WSAStartup(MAKEWORD(2, 2), &wsa);

  // 初始化套接字
  SOCKET s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
  sockaddr_in sockaddr;

  // 填充扫描地址与端口
  sockaddr.sin_family = AF_INET;
  sockaddr.sin_addr.S_un.S_addr = inet_addr(ScanParam.HostAddr);
  sockaddr.sin_port = htons(ScanParam.dwStartPort);

  // 开始连接
  if (connect(s, (SOCKADDR*)&sockaddr, sizeof(SOCKADDR)) == 0)
  {
    printf("地址: %-16s --> 端口: %-5d --> 信号量: %-5d 状态: [Open] \n",
      ScanParam.HostAddr, ScanParam.dwStartPort, ScanParam.hSemaphore);
  }
  else
  {
    printf("地址: %-16s --> 端口: %-5d --> 信号量: %-5d 状态: [Close] \n",
      ScanParam.HostAddr, ScanParam.dwStartPort, ScanParam.hSemaphore);
  }

  closesocket(s);
  WSACleanup();

  // 释放一个信号量
  ReleaseSemaphore(ScanParam.hSemaphore, 1, NULL);
  return 0;
}

int main(int argc, char *argv[])
{
  // 线程参数传递
  THREAD_PARAM ThreadParam = { 0 };

  // 设置线程信号
  SetEvent(ThreadParam.hEvent);

  // 创建事件
  HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

  // 创建信号
  HANDLE hSemaphore = CreateSemaphore(NULL, MAX_THREAD, MAX_THREAD, NULL);

  ThreadParam.hEvent = hEvent;
  ThreadParam.hSemaphore = hSemaphore;
  ThreadParam.HostAddr = "59.110.117.109";

  for (DWORD port = 1; port < 4096; port++)
  {
    // 判断信号量
    DWORD dwWaitRet = WaitForSingleObject(hSemaphore, 200);
    if (dwWaitRet == WAIT_OBJECT_0)
    {
      ThreadParam.dwStartPort = port;

      // 启动扫描线程
      HANDLE hThread = CreateThread(NULL, 0, ScanThread, (LPVOID)&ThreadParam, 0, NULL);

      // 等待事件
      WaitForSingleObject(hEvent, INFINITE);

      // 重置信号
      ResetEvent(hEvent);
    }
    else if (dwWaitRet == WAIT_TIMEOUT)
    {
      continue;
    }
  }

  system("pause");
  return 0;
}

读者可自行编译并运行上述代码,将对特定IP地址进行端口探测,每次启用10个线程,即实现了控制线程并发,又实现了端口多线程扫描效果,如下图所示;

相关推荐
嘉伟桑3 小时前
Python调用电价API返回JSON示例:分时电价、现货电价和字段解析
后端
武子康3 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅4 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries4 小时前
文件上传系统
后端
止语Lab5 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活5 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困5 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn5 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530145 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908405 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端