【安全扫描器原理】TCP/IP协议编程
- 1.概述
- 2.Windows Socket结构
- 3.Windows socket转换类函数
- 4.Windows Socket通信类函数
1.概述
TCP/IP协议是目前网络中使用最广泛的协议,Socket称为“套接口”,最早出现在Berkeley Unix中,最初只支持TCP/IP协议族和Unix协议,现在它已支持很多协议,是最重要的网络编程接口
1、端口(Port)和套接口
端口正是我们要扫描的对象,具有“开”和“关”两种状态,利用它的开或关状态就可以初步判断一台主机是否提供了某种服务
和端口所在主机的IP地址结合起来,所形成的一个二元组(IP地址,端口地址)就组成了一个套接口。一个五元组(本地IP、本地端口、使用协议、远程IP、远程端口)组成了一个通信过程
一个IPv4的基本数据结构主要有in_addr和sockaddr_in两个,前者表示32位的IP地址,后者是通用的套接口地址结构
// in_addr 是用来表示一个 IPv4 地址(32 位) 的结构体
// s_addr:一个 32 位的整数,表示 IP 地址
struct in_addr {
in_addr_t s_addr;
};
struct sockaddr_in {
short sin_family; // 地址族,必须是 AF_INET(IPv4)
unsigned short sin_port; // 端口号(使用网络字节序)
struct in_addr sin_addr; // IPv4 地址
char sin_zero[8]; // 填充字段,保持结构体大小一致(通常置为0)
};
2、地址表示顺序
而网络传输中,数据存储顺序不一定和系统存储顺序一样,因此为保证系统正确性和可移植性,需要利用系统的转换函数进行转换。以IPv4的地址为例,一个IP地址的四个字节“192.168.1.100”,在PC架构的计算机中,数据的表示是低位优先,由前至后是100、1、168、192;而在网络Socket协议所表示的网络传输中,则是高位优先,由前至后是192、168、1、100,这需要在处理时通过函数转化
3、面向连接和面向非连接
面向连接(即TCP)的通信的双方,发起连接的为客户端,接收连接的一方称为服务器端。双方的通信一般分三步:建立连接、数据传送、释放连接。在传送过程中数据按顺序传送
面向非连接(即UDP)的通信中,没有客户端和服务器端之分,或者称为互为客户端和服务器端。双方中的任何一方都可以随时向对方发送数据或接收对方的数据
面向连接和面向非连接区别:
- 面向连接的情况下,函数会明确告诉发送成功,但对方未接到;而面向非连接的情况下,函数只是告诉发送成功,不会告诉对方是否接到
- 两台电脑之间连接了一个小时未通信,在面向连接的方式下,这两台电脑之间会不停地发送确认信息以确定链路是否连通;而面向非连接的方式则没有这些维护信息
4、原始套接字
如果不使用原始套接字,则无论是发送和接收,系统都会自动处理IP包头、TCP/UDP包头的数据,这时用户只需要关心发送和接收的数据本身即可。这种自动处理虽然方便,但也使系统失去了灵活性。而当使用原始套接字时,如果发送数据,系统会将要发送的数据包的前面若干字节数据IP头、TCP/UDP头;如果接收数据,系统会将接收到的数据包前面加上数据IP头、TCP/UDP头
2.Windows Socket结构
1、sockaddr结构
该结构用于保存一个IP地址,但这个结构不包含具体协议字段,因此通常不直接使用,而是作为接口参数来传递实际结构(如 sockaddr_in)
struct sockaddr {
unsigned short sa_family; // 地址族(如 AF_INET)
char sa_data[14]; // 存放端口号和地址
};
2、sockaddr_in结构
这个结构是IPv4专用的结构,包含了 IP 地址和端口号的具体信息
struct sockaddr_in {
short sin_family; // 地址族,一般是 AF_INET
unsigned short sin_port; // 端口号(需要用 htons() 转换为网络字节序)
struct in_addr sin_addr; // IP 地址
char sin_zero[8]; // 补齐用,不使用,需填0
};
其中真正存储IP结构的sin_addr变量又是一个结构,内部是个联合体,有三种访问方式:
struct in_addr {
union {
struct {
unsigned char s_b1, s_b2, s_b3, s_b4;
} S_un_b; // 按字节访问IP
struct {
unsigned short s_w1, s_w2;
} S_un_w; // 按双字节访问IP
unsigned long S_addr; // 一般使用这个字段(网络字节序)
} S_un;
};
在实际使用中,可以这样访问和赋值:
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
有的服务器有多个网卡,此时会有多个IP地址,或是一个网卡配置多个IP地址,而当前的程序并不想只绑定某一个IP地址,这时可以设置S_addr为htonl(INADDR_ANY):
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
3、hostent结构
主要用于存储主机的解析信息,比如域名解析成 IP 后的结果
struct hostent {
char *h_name; // 官方主机名(如:www.example.com)
char **h_aliases; // 主机别名(一个以NULL结尾的字符串数组)
short h_addrtype; // 地址类型(如 AF_INET 表示 IPv4)
short h_length; // 地址长度(IPv4是4字节)
char **h_addr_list; // 地址列表(每个都是一个 IP 地址的指针)
};
4、servent结构
通常是用于 服务(Service)名到端口号的解析
struct servent {
char *s_name; // 服务名称(如 "http")
char **s_aliases; // 服务别名(以 NULL 结尾的字符串数组)
short s_port; // 端口号(网络字节序)
char *s_proto; // 协议名称(如 "tcp" 或 "udp")
};
3.Windows socket转换类函数
1、htons函数
htons函数将计算机存储的USHORT格式转换为网络存储的USHORT格式,16位,一般同于传输端口
网络传输要求所有多字节数值统一为 大端格式(高位在前),以保证跨平台通信一致性。因此,在将端口号等数值用于网络传输前必须调用 htons()
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(80); // 将主机字节序的端口80转换为网络字节序
server_addr.sin_addr.s_addr = inet_addr("192.168.1.1");
2、ntohs函数
ntohs函数将网络存储的USHORT格式转换为计算机存储的USHORT格式
3、htonl函数
htonl() 主要用于 IP 地址等需要使用 32 位整数传输的场景。将计算机存储的ULONG格式转换为网络存储ULONG格式
4、ntohl函数
ntohl函数将网络存储的ULONG格式转换为计算机存储的ULONG格式,是32位的
5、inet_ntoa函数
inet_ntoa函数将由in_addr结构所表示的网络地址,转换成由字符串表示的IP地址
6、inet_addr函数
inet_addr函数将字符串组成的IP地址串转换成一个ULONG的整数,该整数可用于in_addr结构中,是按网络格式存储的
7、gethostbyname函数
gethostbyname函数根据主机名读取主机的信息(主要是IP地址)
8、gethostbyaddr函数
gethostbyaddr函数通过网络地址读取主机信息
9、gethostname函数
gethostname函数读取本地主机的主机名
10、getservbyname函数
getservbyname函数根据服务名和协议读取服务信息
11、getservbyport函数
getservbyport函数根据端口和协议读取服务信息
4.Windows Socket通信类函数
在使用 Windows Socket 通信类函数时,需要注意以下几点:
- 版本要求:这些函数至少基于 Winsock 1.1 版本或更高版本(如 Winsock 2.2)。在编程时通常使用的是 Winsock 2.x
- 头文件引入:在源文件中需要包含头文件 Winsock2.h
- 链接库设置:在链接阶段需要链接 Ws2_32.lib 静态库,否则编译时会出现未解析的外部符号错误
#include <Winsock2.h>
#pragma comment(lib, "Ws2_32.lib")
1、WSAStartup函数
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
WSAStartup 用于初始化 Windows Sockets 2(WinSock2)库,它会检查操作系统是否支持所请求的 Socket 版本,并建立底层通信机制。必须最先调用该函数,否则之后的所有 Socket API 都不能使用
程序启动
↓
调用 WSAStartup()
↓
判断版本支持 & 初始化成功?
↓ ↘
是 → 继续使用 Socket 否 → 返回错误码(不能使用 WSAGetLastError)
代码案例:
#include <Winsock2.h>
#pragma comment(lib, "Ws2_32.lib")
int main() {
WSADATA wsaData;
int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (result != 0) {
printf("WSAStartup failed: %d\\n", result);
return 1;
}
// 后续 Socket 操作…
WSACleanup(); // 使用完成后清理
return 0;
}
2、WSACleanup函数
int WSACleanup(void);
WSACleanup函数完成与socket库绑定的解除,并释放socket库所占用的系统资源。该函数应该作为某次socket操作的最后一个函数,否则之后任何socket操作都会导致出错
3、socket函数
SOCKET socket(int af, int type, int protocol);
socket 函数用于创建一个套接字(Socket),这是网络通信的起点。创建后返回一个套接字描述符(也就是一个“句柄”),用于后续的绑定、监听、发送、接收等操作
参数说明:
代码案例:
SOCKET s = socket(AF_INET, SOCK_STREAM, 0); // 创建一个TCP套接字
if (s == INVALID_SOCKET) {
printf("Socket creation failed: %d\\n", WSAGetLastError());
}
4、closesocket函数
closesocket关闭之前打开的socket套接字。在进行关闭之前,一般要通过shutdown函数通知对方自己要关闭套接字
5、setsockopt函数
setsockopt 是一个用于设置 socket 参数选项的函数,简单来说,它就是用来“调节” socket 行为的,比如:是否允许端口复用、是否开启 TCP 保活、缓冲区多大等
int setsockopt(
SOCKET s, // 要设置的 socket 描述符
int level, // 设置的协议级别(SOL_SOCKET/IPPROTO_TCP 等)
int optname, // 选项名(SO_REUSEADDR 等)
const char *optval, // 选项值的指针
int optlen // 选项值的长度
);
调用时机图:
程序启动
↓
创建套接字(socket)
↓
┌────────────────────────────────────┐
│ 是否需要更改socket默认参数? │
└────────────────────────────────────┘
↓ 是 ↓ 否
调用 setsockopt 设置参数 继续
↓
┌────────────────────────────────────┐
│ 设置参数是否与 bind 阶段相关? │
└────────────────────────────────────┘
↓ 是(如SO_REUSEADDR)
↓
注意调用时机:
必须在 bind 之前设置
否则 bind 可能失败
↓
调用 bind 绑定地址和端口
↓
后续 listen / connect 操作
代码案例:使用 setsockopt 设置端口复用(SO_REUSEADDR)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h> // Windows 下 socket 头文件
#pragma comment(lib, "ws2_32.lib") // 链接 ws2_32 库
int main() {
WSADATA wsaData;
SOCKET listenSocket;
struct sockaddr_in serverAddr;
int opt = 1; // 要设置的选项值
int ret;
// 初始化 Winsock 库
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
printf("WSAStartup failed: %d\\n", WSAGetLastError());
return 1;
}
// 创建 socket
listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listenSocket == INVALID_SOCKET) {
printf("socket failed: %d\\n", WSAGetLastError());
WSACleanup();
return 1;
}
// 设置 socket 选项:允许地址复用(端口复用)
ret = setsockopt(listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt));
if (ret == SOCKET_ERROR) {
printf("setsockopt failed: %d\\n", WSAGetLastError());
closesocket(listenSocket);
WSACleanup();
return 1;
}
// 准备绑定地址
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本地任意 IP
serverAddr.sin_port = htons(8888); // 绑定端口 8888
// 绑定 socket
ret = bind(listenSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
if (ret == SOCKET_ERROR) {
printf("bind failed: %d\\n", WSAGetLastError());
closesocket(listenSocket);
WSACleanup();
return 1;
}
// 开始监听连接
ret = listen(listenSocket, SOMAXCONN);
if (ret == SOCKET_ERROR) {
printf("listen failed: %d\\n", WSAGetLastError());
closesocket(listenSocket);
WSACleanup();
return 1;
}
printf("Server is listening on port 8888…\\n");
// 清理资源(这里只是简单示例,实际应加入 accept 处理逻辑)
closesocket(listenSocket);
WSACleanup();
return 0;
}
6、select函数
select 用来检测一组 socket 是否就绪(可读、可写或异常),从而避免阻塞或轮询所有 socket,提高程序效率
比如,你写了个服务器,要同时监听100个客户端连接,但你又不想一个个轮询问:“有数据吗?”,这个时候就用 select 让系统帮你监视,哪个 socket 有事发生(有数据可读),就去处理它
int select(
int nfds, // Linux 中使用;Windows 忽略
fd_set *readfds, // 可读 socket 集合
fd_set *writefds, // 可写 socket 集合
fd_set *exceptfds, // 异常 socket 集合
const struct timeval *timeout // 超时等待时间
);
参数解释:
代码案例,使用 select 等待 socket 可读:
#include <stdio.h>
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")
int main() {
WSADATA wsaData;
SOCKET sock;
struct sockaddr_in server;
fd_set readfds;
struct timeval timeout;
int ret;
WSAStartup(MAKEWORD(2, 2), &wsaData);
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
server.sin_family = AF_INET;
server.sin_port = htons(80);
server.sin_addr.s_addr = inet_addr("93.184.216.34"); // example.com
connect(sock, (struct sockaddr*)&server, sizeof(server));
// 初始化 fd_set
FD_ZERO(&readfds);
FD_SET(sock, &readfds);
// 设置超时时间为5秒
timeout.tv_sec = 5;
timeout.tv_usec = 0;
ret = select(0, &readfds, NULL, NULL, &timeout);
if (ret == 0) {
printf("Timeout, no data received.\\n");
} else if (ret < 0) {
printf("select error: %d\\n", WSAGetLastError());
} else {
if (FD_ISSET(sock, &readfds)) {
printf("Data is available to read.\\n");
}
}
closesocket(sock);
WSACleanup();
return 0;
}
7、bind函数
bind 函数的作用是将 socket 与本地的 IP 地址和端口号绑定起来,这样系统才知道这个 socket 是用哪个本地地址和端口通信的。该函数既可用于面向连接的TCP通信中,也可以用于面向非连接的UDP通信中
int bind(
SOCKET s, // 已创建的 socket 描述符
const struct sockaddr *name, // 绑定的地址信息
int namelen // 地址结构长度
);
案例:
#include <stdio.h>
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib") // 链接 Winsock 库
int main() {
WSADATA wsaData;
SOCKET serverSocket;
struct sockaddr_in serverAddr;
// 初始化 Winsock
WSAStartup(MAKEWORD(2, 2), &wsaData);
// 创建 TCP socket
serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (serverSocket == INVALID_SOCKET) {
printf("Socket creation failed: %d\\n", WSAGetLastError());
return 1;
}
// 设置地址信息
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地 IP
serverAddr.sin_port = htons(8080); // 监听端口 8080
// 绑定 socket 和地址
if (bind(serverSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
printf("Bind failed: %d\\n", WSAGetLastError());
closesocket(serverSocket);
WSACleanup();
return 1;
}
printf("Bind successful on port 8080.\\n");
// 开始监听连接
listen(serverSocket, SOMAXCONN);
printf("Listening…\\n");
WSACleanup();
return 0;
}
8、listen函数
listen函数使用socket状态监听状态,并等待其他socket的连接。该函数仅用于面向连接的TCP通信中,UDP通信是不需要listen函数的
int listen(SOCKET s, int backlog);
参数解释:
9、accept函数
accept函数允许和接收一个远端的连接,该函数仅用于面向连接的TCP通信中,并且只用于服务器端,用于接收客户端通过connect函数发来的连接申请;面向非连接的UDP通信是不需要处理此函数的
可以把 listen 比喻成“门卫准备好了”,而 accept 就是“打开门迎客”那一刻
SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);
参数解释:
该函数看似简单,其实比较复杂,也是多线程处理效果的关键,首先调用此函数之前,应该已成功地调用了listen函数。然后在调用该函数时,如果调用成功,则返回一个新的socket,所以如果后面服务端的处理很简单,可以在当前线程中用这个新创建的socket进行处理,俗称“短连接”;
如果处理很复杂,并且仍在当前线程中处理,则会影响到accept函数对其他线程通过connect进行连接,此时就需要再创建一个线程,由新建的线程,并使用返回的一个socket专门处理此次连接后的各项操作,俗称“长连接”
代码案例:TCP服务端
#include <stdio.h>
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")
int main() {
WSADATA wsaData;
SOCKET listenSocket, clientSocket;
struct sockaddr_in serverAddr, clientAddr;
int clientAddrLen = sizeof(clientAddr);
WSAStartup(MAKEWORD(2, 2), &wsaData);
// 创建 TCP socket
listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 绑定 IP + 端口
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(8080);
bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr));
// 启动监听
listen(listenSocket, SOMAXCONN);
printf("Server is listening on port 8080…\\n");
// 接受客户端连接(阻塞)
clientSocket = accept(listenSocket, (SOCKADDR*)&clientAddr, &clientAddrLen);
if (clientSocket == INVALID_SOCKET) {
printf("Accept failed: %d\\n", WSAGetLastError());
closesocket(listenSocket);
WSACleanup();
return 1;
}
printf("Accepted a connection from %s:%d\\n",
inet_ntoa(clientAddr.sin_addr),
ntohs(clientAddr.sin_port));
// 发送欢迎消息
const char* welcomeMsg = "Hello, client!\\n";
send(clientSocket, welcomeMsg, strlen(welcomeMsg), 0);
// 关闭连接
closesocket(clientSocket);
closesocket(listenSocket);
WSACleanup();
return 0;
}
10、connect函数
在扫描器的应用中,connect是一种简单而有效的连接方式,连接成功,则可以认为对方的端口是打开的。该函数仅用于面向连接的TCP通信中,并且只用于服务器端,用来接收客户端通过connect函数发来的连接申请;面向非连接的UDP通过是不需要处理此函数的
11、send函数
send函数发送数据到已建立连接的socket上,该函数既可以用于服务器端,也可以用于客户端,但双方都必须是采用TCP连接
12、recv函数
recv函数用于接收从已建立连接的socket上的数据,该函数既可以用于服务器端,也可以用于客户端,但双方都必须是采用TCP连接。
13、shutdown函数
shutdown 函数用于关闭某个方向上的数据传输通道:只收、不收、只发、不发,或者两个方向都关。更优雅、明确地通知对方“我不想再发/收数据了”,避免连接状态混乱
int shutdown(SOCKET s, int how);
参数说明:
how 参数的取值:
如果直接调用 closesocket(s) 而不 shutdown:
- 对方可能还在写入你的 socket,但你这边已经关掉了,数据就丢了;
- 没有给 TCP 协议一个“四次挥手” 的机会,可能造成资源悬挂(TIME_WAIT 状态不一致);
正确关闭流程应该是:
shutdown(socket, SD_SEND); // 发送结束,通知对方
recv(...); // 等对方把剩下要说的说完
closesocket(socket); // 最后关闭 socket
14、sendto函数
sendto函数发送数据报到远端的主机指定的端口上。该函数只能用于面向非连接的通信中
15、recvfrom函数
recvfrom函数接收远端发过来的数据报。该函数只能用于面向非连接的通信中
评论前必须登录!
注册