日常上网离不开DNS(域名系统),但很少有人注意到:DNS默认用UDP协议传输,可一旦查询结果超过512字节(比如包含大量IP的CDN域名),客户端就会自动切换到TCP 53端口重新请求。传统的TCP DNS服务需要维护每一个连接的状态(比如三次握手、四次挥手的上下文),高并发时会消耗大量服务器资源;而"无状态TCP"技术能绕开连接维护,用更轻量的方式处理TCP DNS请求,甚至能实现DNS请求的转发或劫持------这就是我们今天要拆解的核心技术思路。
一、先搞懂两个核心概念:有状态VS无状态TCP
要理解这个方案,先分清"有状态TCP"和"无状态TCP"的区别,用大白话讲就是:
- 有状态TCP:像我们和朋友打电话,从拨号(三次握手)到挂电话(四次挥手),全程要记住"通话状态"------比如对方说了什么、当前聊到哪一步。服务器会记录每个连接的SEQ/ACK序号、窗口大小、连接阶段,优点是可靠,但高并发时会占满内存和CPU。
- 无状态TCP:像街边发传单,不管谁来拿,发完就忘,不记任何人的信息。服务器收到每个TCP报文后,只根据当前报文的内容(比如源IP、端口、SEQ序号)生成响应,不用记住之前的交互,优点是极致轻量化,缺点是需要手动"伪造"TCP报文,对协议细节要求极高。
另外要补充:TCP DNS和UDP DNS有个关键区别------TCP DNS的报文开头会加2个字节的"长度字段"(告诉对方后面的DNS数据有多长),而UDP DNS没有这个字段;无状态方案的核心之一,就是处理好这个长度字段的"加/减"。
二、无状态TCP实现DNS代理的核心原理
整个流程就像"手工模拟TCP服务器",全程绕开操作系统的有状态TCP协议栈,手动处理每一个数据包,拆解成5个关键步骤:
1. 网卡抓包:只盯TCP 53端口的报文
首先用libpcap库(tcpdump、Wireshark的底层核心)监听指定网卡的所有数据包,通过过滤规则只保留"目的端口是53的TCP报文"------相当于"守在网卡门口",只捡和TCP DNS相关的数据包,其他报文直接忽略。
2. 解析报文:剥洋葱式拆出核心数据
拿到数据包后,像剥洋葱一样逐层解析:
- 先剥14字节的以太网头(不管,只取后面的IP报文);
- 再解析IP头:获取源IP、目的IP、IP头长度、协议类型(确认是TCP);
- 最后解析TCP头:获取源端口、目的端口、SEQ/ACK序号、报文标志位(SYN/FIN/ACK)、TCP头长度,以及后面的DNS数据。
这里要注意:IP头和TCP头的长度都不是固定的(因为有可选字段),必须用协议宏(比如IP_HL、TH_OFF)计算实际长度,否则会解析错数据位置。
3. 无状态响应:伪造TCP报文"骗"客户端
客户端发TCP报文时,会带不同的标志位,服务器要针对性伪造响应,全程不记录任何状态:
- 收到SYN报文(客户端想建立连接):直接伪造SYN+ACK报文发回去。关键是交换源/目的IP和端口,SEQ序号用当前时间生成(不用记),ACK序号是客户端SEQ+1,还要加MSS(最大分段大小)选项(告诉客户端单次能传1220字节),最后算好IP和TCP校验和。
- 收到FIN报文(客户端想关闭连接):伪造ACK报文回应,同样交换地址/端口,计算正确的SEQ/ACK,发完就忘。
- 收到带数据的报文(DNS查询):先伪造一个ACK报文回应(防止客户端超时重传),再处理后面的DNS数据。
4. 转发请求:把TCP DNS转成UDP发给后端
TCP DNS报文开头有2字节长度字段,UDP DNS不需要,所以先剥掉这2字节,然后通过普通UDP Socket发给后端的真实DNS服务器(比如8.8.8.8)------这么做是因为公共DNS的UDP端口更稳定、处理更快,还不用和后端建立TCP连接,进一步简化逻辑。
5. 封装响应:把UDP结果转回TCP发给客户端
收到后端DNS的UDP响应后,先加回2字节的长度字段(还原成TCP DNS格式),如果响应太大,就分成512字节的小块(符合TCP的MSS限制),为每个小块伪造TCP报文(带ACK标志),最后一块还要加FIN标志(表示数据发完),计算好校验和后发送给客户端。
三、设计思路:为什么要这么做?
这个方案的设计核心是"极致轻量化",所有逻辑都围绕"不维护连接状态"展开:
- 不用连接表,省资源:传统TCP服务器要为每个连接存SEQ/ACK、窗口大小等信息,无状态方案只处理当前报文,哪怕十万个客户端同时请求,内存占用也不会暴涨,适合嵌入式设备、低配置服务器。
- Raw Socket绕开系统协议栈:普通Socket(TCP/UDP)由操作系统处理协议头,而Raw Socket能让程序直接操作IP/TCP头,手动伪造所有字段------这是实现无状态的关键,相当于"跳过操作系统,自己做TCP协议栈"。
- UDP转发简化逻辑:后端用UDP而非TCP,不用和后端维护连接,进一步降低复杂度;只需要处理"TCP转UDP""UDP转TCP"的格式转换(核心是2字节长度字段)。
- 分片发送防丢包:把大响应分成512字节块,符合TCP的最大分段大小,避免报文被IP分片,提高传输可靠性。
四、涉及的核心技术领域(知识点总结)
这个方案看似是"DNS代理",实则覆盖了网络编程的多个核心领域,是学习底层协议的绝佳案例:
1. libpcap抓包技术
所有抓包工具的底层核心,能直接从网卡获取原始数据包,支持自定义过滤规则(比如只抓TCP 53端口),是实现"报文监听"的基础。
2. Raw Socket编程
网络编程的高级用法,普通Socket只处理应用层数据,Raw Socket能直接操作IP/TCP层,需要手动构造协议头、计算校验和------适合做协议分析、报文伪造、网络测试。
3. TCP/IP协议栈细节
- IP头:要关注版本+头长度、总长度、DF标志(不分片)、TTL、协议类型(6=TCP)、校验和;
- TCP头:核心是SEQ/ACK序号(无状态下用时间生成SEQ,ACK是客户端SEQ+1)、标志位(SYN/FIN/ACK)、MSS选项、校验和;
- TCP伪头:计算TCP校验和必须包含伪头(IP源/目的地址、协议号、TCP长度),否则报文会被客户端丢弃。
4. DNS协议格式
关键是区分TCP和UDP的差异:TCP DNS开头加2字节长度字段,UDP没有;DNS查询/响应的核心格式一致,只是标志位不同。
5. 校验和计算
IP和TCP校验和都用"反码求和":先把数据按16位分组求和,再对结果取反;计算时要先把校验和字段设为0,最后填回结果。
五、实现流程示意图
SYN(建立连接)
FIN(关闭连接)
有数据(DNS查询)
libpcap监听网卡
过滤出TCP 53端口报文
解析以太网/IP/TCP头
判断TCP标志位
伪造SYN+ACK报文发送
伪造ACK报文发送
伪造ACK报文回应(防重传)
剥掉TCP DNS的2字节长度字段
通过UDP转发给后端DNS
接收后端UDP DNS响应
添加2字节长度字段,分512字节块
为每个块伪造TCP报文(ACK标志)
最后一块添加FIN标志,发送
结束单次报文处理(无状态,不记录)
cpp
...
struct ip_hdr {
u_char ip_vhl;
#define IP_HL(ip) (((ip)->ip_vhl) & 0x0f)
#define IP_V(ip) (((ip)->ip_vhl) >> 4)
u_char ip_tos;
u_short ip_len;
u_short ip_id;
u_short ip_off;
#define IP_RF 0x8000
#define IP_DF 0x4000
#define IP_MF 0x2000
#define IP_OFFMASK 0x1fff
u_char ip_ttl;
u_char ip_p;
u_short ip_sum;
struct in_addr ip_src,ip_dst;
};
typedef u_int32_t tcp_seq ;
struct tcp_hdr {
u_short th_sport;
u_short th_dport;
tcp_seq th_seq;
tcp_seq th_ack;
u_char th_offx2;
#define TH_OFF(th) (((th)->th_offx2 & 0xf0) >> 4)
u_char th_flags;
#define TH_FIN 0x01
#define TH_SYN 0x02
#define TH_RST 0x04
#define TH_PUSH 0x08
#define TH_ACK 0x10
#define TH_URG 0x20
#define TH_ECE 0x40
#define TH_CWR 0x80
#define TH_FLGS (TH_FIN|TH_SYN|TH_RST|TH_ACK|TH_URG|TH_ECE|TH_CWR)
u_short th_win;
u_short th_sum;
u_short th_urg;
};
struct tcp_option {
u_char opt_type ;
u_char opt_len ;
u_short opt_val ;
} ;
...
void got_packet(u_char *args, const struct pcap_pkthdr *header, const u_char *packet)
{
...
ip = (struct ip_hdr*)(packet + SIZE_ETHERNET);
size_ip = IP_HL(ip)*4;
if (size_ip < 20) {
printf(" * Invalid IP header length: %u bytes\n", size_ip);
return;
}
if (ip->ip_p != IPPROTO_TCP)
return ;
tcp = (struct tcp_hdr*)(packet + SIZE_ETHERNET + size_ip);
size_tcp = TH_OFF(tcp)*4;
if (size_tcp < 20) {
printf(" * Invalid TCP header length: %u bytes\n", size_tcp);
return;
}
if (ntohs(tcp->th_dport) != PORT)
return ;
ip_payload = (u_char *)(packet + SIZE_ETHERNET);
payload = (u_char *)(packet + SIZE_ETHERNET + size_ip + size_tcp);
size_payload = ntohs(ip->ip_len) - (size_ip + size_tcp);
if (tcp->th_flags & TH_SYN)
{
send_tcp_synack(ip_payload) ;
return ;
}
if (tcp->th_flags & TH_FIN)
{
send_tcp_ack(ip_payload) ;
return ;
}
if (size_payload > 0)
{
bcopy(payload,cmd_buffer,size_payload) ;
cmd_buffer[size_payload] = '\0';
server_request(ip_payload,cmd_buffer,size_payload) ;
}
return;
}
int main(int argc, char **argv)
{
...
sprintf(filter_exp,"dst port %d and dst host %s",PORT,THIS_HOST) ;
if (argc == 2)
{
dev = argv[1];
}
else if (argc > 2)
{
fprintf(stderr, "error: unrecognized command-line options\n\n");
exit(EXIT_FAILURE);
}
else
{
dev = pcap_lookupdev(errbuff);
if (dev == NULL)
{
fprintf(stderr, "Couldn't find default device: %s\n",errbuff);
exit(EXIT_FAILURE);
}
}
if (pcap_lookupnet(dev, &net, &mask, errbuff) == -1)
{
fprintf(stderr, "Couldn't get netmask for device %s: %s\n", dev, errbuff);
net = 0;
mask = 0;
}
in = (struct in_addr *) &net ;
printf("Device: %s Network: %s Mask: %x\n", dev,inet_ntoa(*in),ntohl(mask));
printf("Filter expression: %s\n", filter_exp);
handle = pcap_open_live(dev, SNAP_LEN, 1, 1000, errbuff);
if (handle == NULL)
{
fprintf(stderr, "Couldn't open device %s: %s\n", dev, errbuff);
exit(EXIT_FAILURE) ;
}
if (pcap_compile(handle, &fp, filter_exp, 0, net) == -1)
{
fprintf(stderr, "Couldn't parse filter %s: %s\n", filter_exp, pcap_geterr(handle));
exit(EXIT_FAILURE) ;
}
if (pcap_setfilter(handle, &fp) == -1)
{
fprintf(stderr, "Couldn't install filter %s: %s\n", filter_exp, pcap_geterr(handle));
exit(EXIT_FAILURE) ;
}
open_raw_socket() ;
pcap_loop(handle, -1, got_packet, NULL) ;
pcap_close(handle);
return(0);
}
If you need the complete source code, please add the WeChat number (c17865354792)
执行以下命令启动程序,指定要监听的网卡(比如eth0,根据你的机器调整):
bash
sudo ./test_dns eth0
此时程序会一直运行,监听eth0网卡上发往本机TCP 53端口的所有报文。
六、测试验证(核心步骤)
我们需要验证两个核心点:①无状态TCP的SYN+ACK响应是否正常 ②DNS查询是否能通过这个程序转发并返回结果。
测试1:验证TCP 53端口的无状态响应
用nc(netcat)工具模拟TCP DNS客户端,发起连接请求:
bash
# 新开一个终端,执行以下命令(替换成你的测试机IP)
nc -v 192.168.1.100 53
如果程序正常工作,nc会显示:
Connection to 192.168.1.100 53 port [tcp/domain] succeeded!
这说明程序伪造的SYN+ACK报文生效了------因为系统本身没有监听TCP 53端口,是程序手动返回的SYN+ACK,模拟了TCP连接建立。
测试2:验证DNS查询转发功能
用dig命令(DNS查询工具)指定TCP协议,向测试机发起DNS查询:
bash
# 关键参数:@测试机IP 域名 tcp(强制用TCP查询)
dig @192.168.1.100 www.baidu.com tcp
如果程序正常工作,dig会返回百度的DNS解析结果,和直接查询8.8.8.8的结果一致;如果返回超时,说明转发环节有问题(比如后端DNS不可达、代码里的长度字段处理错误)。
测试3:抓包验证(进阶,看底层报文)
用tcpdump抓包,直观看到无状态TCP的报文交互:
bash
# 新开终端,抓eth0网卡上的TCP 53端口报文
sudo tcpdump -i eth0 -nn port 53 and tcp
然后再执行dig @测试机IP www.baidu.com tcp,你会看到:
- 客户端发SYN报文 → 程序返回SYN+ACK(伪造的);
- 客户端发带DNS查询的TCP报文 → 程序返回ACK(伪造的);
- 程序向8.8.8.8发UDP DNS查询 → 8.8.8.8返回UDP响应;
- 程序把UDP响应封装成TCP报文(加2字节长度字段)发给客户端;
- 最后程序发FIN+ACK关闭连接(无状态,不用等客户端的FIN)。
总结
无状态TCP实现DNS代理的核心,是"手动替代操作系统的TCP协议栈"------从抓包、解析、伪造响应,到转发请求、封装响应,全程不保存任何连接状态。这种方案的优势是极致轻量化,适合资源受限的场景;但缺点也很明显,需要对TCP/IP协议细节了如指掌,且不支持TCP的高级特性(比如窗口缩放),属于"极简版"TCP实现。
从学习角度看,这个方案把抓包、原始套接字、协议解析、报文伪造等知识点串联起来,能帮我们跳出"调用API"的表层,深入理解TCP/IP协议的本质------毕竟,无状态TCP的实现,就是把操作系统帮我们做的事,手动重新做了一遍。
Welcome to follow WeChat official account【程序猿编码】