参考:
实验环境:os: centos8.5 / kernel: 4.18.0 / gcc: 8.5.0 / arch: x86-64
示例内核代码版本:5.15.5
1. 概述
本篇文章主要记录如何检测对端某个端口上是否提供了 udp 服务。
2. 如何检测
2.1 tcp 端口开放检测
对于 tcp,只需要写一个 tcp 客户端,阻塞调用 connect() 函数来判断返回值:
- 如果目的 host 或 port 没有回应,那么第一个 syn 包将超时(经历重传),返回 -1(ECONNREFUSED)
- 如果目的 host 对应端口没有提供 tcp 服务,则会回复 rst,返回 -1(ECONNREFUSED)
2.2 udp 端口开放检测
2.2.2 icmp unreachable
icmp 不可达消息分为 host 不可达和 port 不可达,host 不可达一般是路由器回复的,port 不可达一般是主机回复的。且一个 udp 应用层网络服务也可以手动回复 host/port unreachable icmp 报文回去。
但是路由器并不一定会回复 icmp host unreachable 报文,很多情况下发往一个不存在主机的 udp 报文都会石沉大海,没有任何回应。
2.2.2 connect() 系统调用
我们知道 udp 不是一个面向连接的协议,没有三次握手的过程,但是我们依然可以调用 connect() 函数:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
在 udp 中调用 connect() 函数,实际上内核只是将第二个参数 addr 指代的目的 host 和 port 记录下来,形成了独一无二的网络四元组,但是网络上什么都不会发生。所以 udp 无法利用 connect() 检测端口是否开放。
2.2.3 sendto() 系统调用
send() 系统调用返回的是发送到网络中的字节数,返回值与对端的回应无关。
2.2.4 sendto() + recvfrom()
在不调用 connect() 的前提下,udp 发送数据必须调用 sendto() 来指出目标主机的 host 和 port,这个时候如果对端回应了 icmp unreachable(port 或 host),recvfrom() 系统调用也将一直阻塞,没有任何返回:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define DEST_PORT 3000
#define DSET_IP_ADDRESS "127.0.0.1"
int main() {
// 建立udp socket
int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 设置目标 address
struct sockaddr_in addr_serv;
int len;
memset(&addr_serv, 0, sizeof(addr_serv));
addr_serv.sin_family = AF_INET;
addr_serv.sin_addr.s_addr = inet_addr(DSET_IP_ADDRESS);
addr_serv.sin_port = htons(DEST_PORT);
len = sizeof(addr_serv);
char send_buf[20] = "hey, who are you?";
char recv_buf[20];
printf("client send: %s\n", send_buf);
int send_num = sendto(sock_fd, send_buf, strlen(send_buf), 0, (struct sockaddr *)&addr_serv, len);
if (send_num < 0) {
printf("sendto error no: %d %d %s\n", send_num, errno, strerror(errno));
exit(1);
}
int recv_num = recvfrom(sock_fd, recv_buf, sizeof(recv_buf), 0,
(struct sockaddr *)&addr_serv, (socklen_t *)&len);
if (recv_num < 0) {
printf("recvfrom error no: %d %d %s\n", recv_num, errno, strerror(errno));
exit(1);
}
recv_buf[recv_num] = '\0';
printf("client receive %d bytes: %s\n", recv_num, recv_buf);
close(sock_fd);
return 0;
}
上述代码使用 gcc test.c -o udp 编译,运行后代码将一直阻塞在 recvfrom() 系统调用中,抓包能看到对端回复了 ICMP port unreachable:
这是为什么呢?原因在于内核中 udp 收到 icmp 报文后的处理函数 --->linux-5.15.5/net/ipv4/udp.c::__udp4_lib_err():
/*
* This routine is called by the ICMP module when it gets some
* sort of error condition. If err < 0 then the socket should
* be closed and the error returned to the user. If err > 0
* it's just the icmp type << 8 | icmp code.
* Header points to the ip header of the error packet. We move
* on past this. Then (as it used to claim before adjustment)
* header points to the first 8 bytes of the udp header. We need
* to find the appropriate port.
*/
int __udp4_lib_err(struct sk_buff *skb, u32 info, struct udp_table *udptable)
{
// ...
// 忽略前面的代码(主要是设置错误码)
// 这里,如果 inet->recverr 为 0,那么会进入 if 语句
if (!inet->recverr) {
// 这里,如果 udp 不是处于 TCP_ESTABLISHED 状态(调用过 connect()),那么,进入 out
if (!harderr || sk->sk_state != TCP_ESTABLISHED)
goto out;
} else
ip_icmp_error(sk, skb, err, uh->dest, info, (u8 *)(uh+1));
sk->sk_err = err;
// 继续向应用层报告 icmp 错误
sk_error_report(sk);
// 直接退出,不向应用层报告 icmp 错误
out:
return 0;
}
如上代码,所以,我们可以设置 inet->recverr 不为 0,或者将 udp 状态设置为 TCP_ESTABLISHED,都能在应用层收到 icmp unreachable 错误反馈:
- 设置 inet->recverr 为 1,调用 setsockopt() 函数设置 IP_RECVERR 参数为 1
- 将 udp 状态设置为 TCP_ESTABLISHED,在 send() 前调用 connect()
2.2.5 icmp unreachable 正确的检测方式
经过上一节的讨论,我们可以有如下代码:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define DEST_PORT 3000
#define DSET_IP_ADDRESS "127.0.0.1"
int main() {
// 建立udp socket
int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 设置目标 address
struct sockaddr_in addr_serv;
int len;
memset(&addr_serv, 0, sizeof(addr_serv));
addr_serv.sin_family = AF_INET;
addr_serv.sin_addr.s_addr = inet_addr(DSET_IP_ADDRESS);
addr_serv.sin_port = htons(DEST_PORT);
len = sizeof(addr_serv);
char send_buf[20] = "hey, who are you?";
char recv_buf[20];
printf("client send: %s\n", send_buf);
#if 1
int ret = connect(sock_fd, (struct sockaddr *)&addr_serv, len);
if (ret < 0) {
printf("connect error no: %d %d %s\n", ret, errno, strerror(errno));
exit(1);
}
#elif 0
int val = 1;
(void)setsockopt(sock_fd, IPPROTO_IP, IP_RECVERR , &val, sizeof(int));
#endif
int send_num = sendto(sock_fd, send_buf, strlen(send_buf), 0, (struct sockaddr *)&addr_serv, len);
if (send_num < 0) {
printf("sendto error no: %d %d %s\n", send_num, errno, strerror(errno));
exit(1);
}
int recv_num = recvfrom(sock_fd, recv_buf, sizeof(recv_buf), 0,
(struct sockaddr *)&addr_serv, (socklen_t *)&len);
if (recv_num < 0) {
printf("recvfrom error no: %d %d %s\n", recv_num, errno, strerror(errno));
exit(1);
}
recv_buf[recv_num] = '\0';
printf("client receive %d bytes: %s\n", recv_num, recv_buf);
close(sock_fd);
return 0;
}
使用 gcc test.c -o udp 编译并运行:
2.3 可用性的问题
实际上上面的程序只能检测 udp 端口没有开放(收到 icmp unreachable),如果 udp 端口开放了,或者 udp 消息石沉大海,则会面临如下问题:
- 如果调用是阻塞的,udp 服务不做任何回应,那么 recv()/recvfrom() 不返回,因为没有 icmp unreachable 或其它任何消息
- 如果调用是阻塞的,udp 消息石沉大海,那么 recv()/recvfrom() 不返回,因为没有 icmp unreachable 消息
- 如果调用是非阻塞的,那么 recv()/recvfrom() 将立即返回,并收到 -1(EAGAIN) 错误,这样也无法判断 udp 端口是否开放
如果确定能收到 icmp unreachable(host/port) 的前提下,一种解决方法是在阻塞模式下,设置 socket 接收数据超时时间:
int setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv);
如果 recv()/recvfrom() 返回 -1(EAGAIN),说明 udp 端口是开放的,如果返回 -1(ECONNREFUSED),说明收到了 icmp unreachable(host 或者 port 不可达)。
但是要注意,正常情况下 -1(EAGAIN) 无法区分是 udp 消息石沉大海还是因为 udp 服务故意什么都不回应。因此个人认为没有完美的方法检测 udp 端口是否开放。