ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ESP32打造带NCSI欺骗的DNS服务器:强制门户与DNS劫持实战

ESP32打造带NCSI欺骗的DNS服务器:强制门户与DNS劫持实战 要不是一次临时起意的折腾我估计到现在还不会意识到ESP32这小家伙除了点灯、读传感器、当Web服务器之外居然还能客串DNS服务器甚至能把Windows的网络状态指示器“骗”过去。事情起因很简单我想在实验室里搭一个不带外网的热点用来做强制门户测试。常规做法是拿台电脑开dnsmasq和nginx可那天手边只有一块ESP32开发板于是就有了这套用ESP32实现DNS服务器、NCSI欺骗和DNS劫持的组合方案。这篇文章从协议原理讲起重点放在如何用ESP32写一个带NCSI欺骗的DNS服务器并分享我踩过的坑。适合正在玩ESP32/ESP8266、对DNS协议感兴趣、或者想自己搭一个局域网强制门户的读者。整个实验都在自己的设备和自己的热点里完成所有“劫持”只针对实验环境这一点我会在最后再重点强调。1. 为什么ESP能当DNS服务器先看协议底子1.1 不是所有单片机都能干ESP的WiFi协议栈是关键先别急着写代码我们要先搞清楚一件事为什么ESP32能当DNS服务器换成普通8位单片机行不行答案是不行或者至少很麻烦。DNS服务器本质上是一个“绑定在UDP 53端口上的服务”。它收到客户端发来的UDP数据包解析里面问的域名再回一个UDP数据包告诉客户端对应的IP。这里有两个前提第一芯片要有完整的网络协议栈能收发UDP数据第二芯片要有足够的资源运行网络任务和业务逻辑。ESP32和ESP8266之所以能胜任是因为它们并不是单纯的MCU而是内嵌了WiFi MAC/基带的SoC。厂商在SDK里直接集成了LwIP这套轻量级TCP/IP协议栈也就是说UDP、TCP、ARP、DHCP这些底层协议都已经帮你处理好了。你只需要在应用层去dnsUdp.begin(53)然后parsePacket()收包、beginPacket()发包就能完成DNS服务器最核心的收发动作。ESP8266其实也能跑它早期很多智能插座、继电器项目里就用DNSServer库做配网热点。但ESP8266的RAM很小如果你既要跑DNS服务器又要跑WebServer还要维护一套域名黑名单稍微复杂点就容易内存碎片。所以我更推荐ESP32双核、大内存跑起来从容不少。当然如果你只是做一个“把全部域名都解析到固定IP”的最小演示ESP8266完全够用。1.2 DNS应答的本质一次UDP交换很多人觉得DNS很神秘其实拿它和“查电话簿”类比就很好懂。你在浏览器的地址栏输入一个域名系统先把域名发给DNS服务器。DNS服务器查一下自己手里的“电话簿”找到这个域名对应的IP再返回给系统。系统拿到IP后才能真正建立连接。常见的互联网DNS服务器比如运营商提供的DNS会做递归查询它自己查不到就一层层去找根服务器、顶级域名服务器直到拿到最终结果。但我们在ESP上做的这种“最小DNS服务器”完全可以不做递归查询。因为ESP32既没有全球域名列表也没必要去维护。它只需要做一件事收到一个域名查询根据预设规则返回一个对应的IP地址。这就是整个DNS劫持/NCSI欺骗的基石。DNS协议报文结构也简单得惊人一共就几个部分一个12字节的固定头部里面包含事务ID、标志位、问题数量、回答数量等一个或多个“问题区”每项包含要查询的域名、查询类型A记录还是AAAA记录、查询类别一个“回答区”如果有答案就在这里填上IP地址、TTL等信息。查询类型最常见的是A也就是IPv4地址。Windows NCSI检测、浏览器打开网页基本上都靠它。如果客户端问的是AAAAIPv6地址我们的ESP可以暂时不回答或者返回一个空回答让客户端回落到IPv4查询。这个细节在实战中很重要后面会讲到。1.3 为什么我们“自己写响应”而不是“做完整递归”既然ESP有LwIP理论上也能把DNS请求转发给上游DNS服务器做成一个DNS代理。但完整递归DNS服务器是个很复杂的系统要和根服务器、权威服务器交互还要缓存、处理超时、解析各种记录类型ESP32虽然能跑但没必要。更合理的分工是ESP做“带规则的本地DNS服务器”。匹配到规则的域名要么解析到指定IP要么直接屏蔽没匹配到的域名如果ESP本身能上网再把请求转发给上游DNS。这其实就是很多路由器上“DNS重定向/广告过滤”的实现思路。在后面实战部分我会先做一个“所有域名都解析到ESP自己”的版本这个版本用来做强制门户和NCSI欺骗最方便。等大家理解了原理再讲如何改成“精准分流”和“上游转发”会容易得多。2. NCSI欺骗让Windows以为这个热点有外网2.1 Windows的“小地球”是怎么判断联网的NCSI的全称是Network Connectivity Status Indicator是Windows用来判断当前网络连接状态的机制。下次你看到WiFi图标上出现一个黄色感叹号提示“无Internet访问”那就是NCSI探测失败的结果。Windows是怎么探测的它主要做两个测试DNS测试向配置的DNS服务器查询dns.msftncsi.com这个域名并且期望返回一个固定IP地址131.107.255.255。注意它不只是看“域名能不能解析”还会检查解析结果是不是这个固定IP。HTTP测试向http://www.msftconnecttest.com/connecttest.txt发一个HTTP GET请求期望返回的内容是Microsoft Connect Test这几个字并且状态码是200。只有当这两个探测都通过时Windows才会在任务栏显示“已连接Internet访问”。否则即使你确实能通过局域网IP访问设备Windows还是认为这个网络没有外网。这个机制对物联网设备来说是个大坑。ESP32开了一个热点明明局域网里可以访问但Windows还是会打感叹号因为ESP并没有完成NCSI的探测。解决办法有两种一种是忽略系统提示手动访问IP另一种就是今天讲的主角——在ESP里模拟NCSI的期望响应让Windows认为这个热点有外网。2.2 NCSI欺骗的完整响应表要实现NCSI欺骗其实就是照着Windows的期望“抄作业”。我把常见系统的探测机制整理成了一个表后面实战代码会逐个处理探测机制目标期望结果ESP处理方式Windows DNS探测dns.msftncsi.comA记录131.107.255.255自定义DNS解析到该IPWindows HTTP探测www.msftconnecttest.com/connecttest.txt状态200内容为Microsoft Connect TestWebServer返回该文本Android HTTP探测connectivitycheck.gstatic.com/generate_204状态204无内容WebServer返回204iOS HTTP探测captive.apple.com/hotspot-detect.html状态200内容含SuccessWebServer返回Success浏览器正常访问任意域名能打开页面DNS解析到ESP的IP返回本地页面注意Windows的DNS探测和HTTP探测是配合使用的。如果你只处理HTTP探测但dns.msftncsi.com被解析到了ESP自己的IPWindows依然可能判定“无Internet访问”。所以一定要在DNS层做特判遇到dns.msftncsi.com就返回固定的131.107.255.255。2.3 从NCSI欺骗到强制门户为什么要费这么大劲骗Windows这就不得不提强制门户。你出差住酒店连上WiFi后手机自动弹出一个“登录页面”输入房间号才能上网这就是强制门户。它的原理大致是在认证之前网关不把你的流量转到外网而是把HTTP请求或者DNS解析结果指向认证服务器。设备发现“虽然连上了WiFi但并没有真正访问到互联网”于是自动弹出认证页面。ESP32做强制门户时如果没有NCSI欺骗Windows会认为无Internet访问用户体验很差。骗过NCSI之后Windows以为有网用户打开浏览器浏览器向任意域名发起DNS查询ESP把这个查询解析到自己的IPWebServer返回一个自定义页面——这个页面就可以是“登录页”“公告页”或者“实验提示页”。从这个角度说NCSI欺骗只是强制门户的门票真正让用户看到页面的是后面的DNS劫持/重定向。两者配合才算一个完整的“ESP热点认证页”实验。3. DNS劫持的几种玩法从“全网跳转”到“精准分流”3.1 最简单所有域名都解析到ESP自己的IP如果你只是想快速看效果很多库已经帮你封装好了。以Arduino生态里很常见的DNSServer库为例一段代码就能实现“全域名劫持”#include DNSServer.h DNSServer dnsServer; IPAddress apIP(192, 168, 4, 1); // setup 中 dnsServer.start(53, *, apIP); // loop 中 dnsServer.processNextRequest();start(53, *, apIP)里的通配符*表示所有域名都解析到apIP。这个模式的优点是简单适合验证DNS劫持效果缺点是太“暴力”会把dns.msftncsi.com也解析到ESP的IP导致Windows的NCSI DNS探测失败网络图标依然显示感叹号。所以完整方案里我建议不要直接用库的“全劫持”而是自己写一个十几行的解析函数按域名做不同处理。3.2 精准分流广告屏蔽与内网域名自定义解析规则后能做到的事情就多了。下面是我在实验中用的几个典型规则如果查询dns.msftncsi.com返回131.107.255.255专门喂给Windows NCSI探测如果查询www.msftconnecttest.com返回ESP自己的IP方便HTTP探测如果查询广告域名比如ad.doubleclick.net、ads.example.com返回0.0.0.0或者NXDOMAIN实现广告屏蔽如果查询内网设备域名比如myesp.local返回局域网内的目标IP其他所有域名在强制门户模式里可以直接返回ESP自己的IP实现“全重定向”。返回0.0.0.0和返回NXDOMAIN有什么区别返回0.0.0.0是告诉客户端“这个域名对应IP是0.0.0.0”客户端尝试连接时会瞬间失败返回NXDOMAIN是告诉客户端“这个域名不存在”浏览器一般直接报错。广告屏蔽场景下两种都能用我更喜欢返回0.0.0.0因为行为最可控不会因为系统对NXDOMAIN的特殊处理产生额外重试。3.3 按设备分流做家长路由器的雏形DNS查询包里不仅有“问哪个域名”还有“谁在问”。在ESP的UDP收包代码里可以直接读取dnsUdp.remoteIP()拿到客户端的IP地址。于是我们可以做按设备分流。举个例子同一台ESP对“小孩设备”的IP直接把所有短视频域名解析到0.0.0.0对“管理设备”的IP则正常解析或返回内网页面。这就是最简化的域名过滤/家长控制。严格来说它只过滤了DNS解析如果设备手动配置了其他DNS或者使用加密DNS规则就会失效但在一个小型实验网络里已经足够好玩了。4. 实战在ESP32上写一个带NCSI欺骗的DNS服务器4.1 准备硬件和开发环境建议准备一块ESP32开发板推荐带USB转串口的常见开发板比如ESP32 DevKitC一根Micro USB数据线电脑上装好Arduino IDE或者用PlatformIO也可以在Arduino IDE的“开发板管理器”里安装esp32 by Espressif Systems板卡包。这个实验不需要任何外部网络库用ESP32自带的WiFi.h、WiFiUdp.h、WebServer.h就够了。因为我们要自己解析DNS报文所以我不会依赖DNSServer库免得被它的“全劫持”逻辑限制住。4.2 完整代码AP模式下的NCSI欺骗 DNS劫持下面这段代码我已经把核心部分写完整了。它的工作流程是ESP32开启一个名为ESP-DNS-Lab的AP热点启动UDP 53端口监听接收DNS查询根据域名规则返回不同IP启动WebServer响应NCSI探测页面和正常的HTTP访问。#include WiFi.h #include WebServer.h #define DNS_PORT 53 #define WEB_PORT 80 IPAddress apIP(192, 168, 4, 1); IPAddress netmask(255, 255, 255, 0); WiFiUDP dnsUdp; WebServer web(WEB_PORT); const IPAddress NCSI_DNS_IP(131, 107, 255, 255); const IPAddress LOCAL_IP(192, 168, 4, 1); const IPAddress BLOCK_IP(0, 0, 0, 0); // 解析DNS问题区里的域名 String parseName(const uint8_t* data, uint16_t len, uint16_t off) { String domain ; while (off len) { uint8_t l data[off]; if (l 0) { off; break; } if ((l 0xC0) 0xC0) { // 压缩指针本实验比较简单直接跳过 off 2; break; } off; for (uint8_t i 0; i l off len; i) { domain (char)data[off]; } domain .; } domain.toLowerCase(); if (domain.endsWith(.)) domain.remove(domain.length() - 1); return domain; } // 按域名规则返回解析结果 IPAddress resolveDomain(const String domain) { if (domain.equalsIgnoreCase(dns.msftncsi.com)) { return NCSI_DNS_IP; } if (domain.equalsIgnoreCase(www.msftconnecttest.com) || domain.equalsIgnoreCase(connectivitycheck.gstatic.com) || domain.equalsIgnoreCase(captive.apple.com)) { return LOCAL_IP; } if (domain.startsWith(ad.) || domain.indexOf(doubleclick) 0 || domain.equals(exampleads.com)) { return BLOCK_IP; } // 强制门户模式其余域名全部解析到ESP自己 return LOCAL_IP; } // 构造并发送DNS响应 void sendDnsResponse(IPAddress fromIP, uint16_t fromPort, const uint8_t* req, uint16_t reqLen, uint16_t qStart, uint16_t id, const IPAddress answerIP, bool withAnswer) { uint8_t resp[512] {0}; uint16_t p 0; // Header resp[p] id 8; resp[p] id 0xFF; resp[p] 0x81; // QR1, RD1 resp[p] 0x80; // RA1, RCODE0 resp[p] 0x00; resp[p] 0x01; // QDCOUNT1 resp[p] 0x00; resp[p] withAnswer ? 0x01 : 0x00; resp[p] 0x00; resp[p] 0x00; resp[p] 0x00; resp[p] 0x00; // 把查询区原样复制到响应里 for (uint16_t i qStart; i reqLen p 512; i) { resp[p] req[i]; } if (withAnswer) { // Answer: 名称指针指向查询区偏移12的位置 resp[p] 0xC0; resp[p] qStart; resp[p] 0x00; resp[p] 0x01; // Type A resp[p] 0x00; resp[p] 0x01; // Class IN resp[p] 0x00; resp[p] 0x00; resp[p] 0x00; resp[p] 0x3C; // TTL 60 resp[p] 0x00; resp[p] 0x04; // RDLENGTH 4 resp[p] answerIP[0]; resp[p] answerIP[1]; resp[p] answerIP[2]; resp[p] answerIP[3]; } dnsUdp.beginPacket(fromIP, fromPort); dnsUdp.write(resp, p); dnsUdp.endPacket(); } void handleRoot() { String html htmlheadmeta charsetutf-8/headbody h2ESP DNS 实验/h2 p这个页面说明DNS劫持已生效。/p /body/html; web.send(200, text/html, html); } void setup() { Serial.begin(115200); WiFi.mode(WIFI_AP); WiFi.softAPConfig(apIP, apIP, netmask); WiFi.softAP(ESP-DNS-Lab); dnsUdp.begin(DNS_PORT); web.on(/, handleRoot); web.on(/connecttest.txt, []() { web.send(200, text/plain, Microsoft Connect Test); }); web.on(/generate_204, []() { web.send(204, text/plain, ); }); web.on(/hotspot-detect.html, []() { web.send(200, text/html, HTMLHEADTITLESuccess/TITLE/HEADBODYSuccess/BODY/HTML); }); web.onNotFound([]() { String html htmlheadmeta charsetutf-8/headbody h2ESP DNS 实验/h2 p你访问的域名被解析到了这里。/p /body/html; web.send(200, text/html, html); }); web.begin(); Serial.println(ESP DNS Web Server started); } void loop() { int n dnsUdp.parsePacket(); if (n) { uint8_t req[512]; int len dnsUdp.read(req, 512); if (len 12) return; uint16_t id (req[0] 8) | req[1]; // 只处理普通查询QR位为0 if ((req[2] 0x80) 0) { uint16_t off 12; String qname parseName(req, len, off); uint16_t qtype (req[off] 8) | req[off 1]; Serial.printf(DNS query: %s type%u\n, qname.c_str(), qtype); if (qtype 1) { // 只处理 A 记录查询 IPAddress ans resolveDomain(qname); sendDnsResponse(dnsUdp.remoteIP(), dnsUdp.remotePort(), req, len, 12, id, ans, true); } else { // AAAA或其他类型回复NOERROR空回答让客户端继续用IPv4 IPAddress empty(0, 0, 0, 0); sendDnsResponse(dnsUdp.remoteIP(), dnsUdp.remotePort(), req, len, 12, id, empty, false); } } } web.handleClient(); delay(1); }把这段代码烧录到ESP32后用手机或电脑连接ESP-DNS-Lab这个热点。如果设备是Windows你可能会发现网络图标变成了“已连接”。打开浏览器随便输入一个域名比如www.example.com应该会看到一个写着“ESP DNS 实验”的页面。此时ESP的串口日志里会打印出它收到的每个DNS查询。4.3 如何改成STA模式给家里局域网提供DNS过滤上面这段代码是AP模式适合做热点和强制门户。如果你想把它接到家里路由器上让局域网设备以ESP为DNS服务器可以在setup()里改成STA模式WiFi.mode(WIFI_STA); WiFi.begin(你的WiFi名, 你的WiFi密码); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); }连接成功后ESP会从路由器获取一个IP比如192.168.1.200。然后在路由器DHCP设置里把“首选DNS服务器”改成这个IP局域网的设备就会把DNS查询发给ESP。这样不需要开AP也能实现广告过滤和内网域名解析。但请注意纯STA模式下有一个大坑如果你把所有不匹配的域名都解析到ESP自己的IP客户端无法访问互联网。你需要一个“未命中规则则转发给上游DNS”的机制。转发原理不复杂ESP收到查询后把原始UDP包发给路由器或运营商DNS的53端口再把收到的响应原样返回给客户端。关键在于ESP要记录每个查询ID对应的客户端IP和端口否则上游响应回来后你不知道该回给谁。很多开源项目已经做过类似实现你可以搜“ESP32 DNS proxy”参考。这部分代码量比AP模式要大不少我这次先不展开下次专门写一篇。5. 常见问题与排查技巧实录5.1 网页能弹出来但Windows一直显示“无Internet访问”这大概率是NCSI的DNS探测没有骗成功。Windows会查询dns.msftncsi.com并且期望返回131.107.255.255。很多示例代码直接用DNSServer库的“全劫持”把dns.msftncsi.com也解析成了ESP自己的IP于是NCSI的DNS探测失败Windows就继续显示感叹号。解决办法是在解析函数里进行特判把dns.msftncsi.com单独返回131.107.255.255。另外也不要忘了验证HTTP探测/connecttest.txt必须精确返回Microsoft Connect Test多一个空格都不行。改完规则后在Windows上用ipconfig /flushdns清一次DNS缓存再断开重连WiFi看效果。5.2 打开任意域名结果发现浏览器访问的是HTTPSESP打不开很常见。用户在地址栏手输https://example.com浏览器会默认发起到443端口的TLS连接。ESP上的WebServer监听的是80端口自然无法响应。应对办法有三个思路强制门户页面是通过“设备检测到未认证后自动弹出”的而不是靠用户手输HTTPS地址在页面里提示用户“本实验环境只支持HTTP访问”如果只是本地测试最好直接用http://开头访问或者接入后等待系统自动弹出认证页。强制门户产品通常不会盲目劫持HTTPS因为这样做要么需要伪造证书要么会导致巨大安全隐患。我们做实验时了解这个边界就好。5.3 有些App一直转圈好像DNS劫持对它无效当前很多系统和App已经不支持传统的UDP 53明文DNS了而是强制使用DoHDNS over HTTPS或DoTDNS over TLS。这类流量加密且走443端口ESP的UDP 53监听完全看不到所以劫持无效。在实验环境里排查方法很简单把手机或电脑的“私人DNS”、“安全DNS”功能关闭让系统回到传统DNS模式。在Windows和Android上都有相关设置改完之后再测试。这也是为什么我说“DNS劫持不是万能”的它在大型公共网络中效果有限但用来学习DNS协议和做局域网实验仍然很有价值。5.4 ESP突然重启串口打印Guru Meditation Error这通常是内存问题。DNS报文解析我用的是Arduino的String在ESP8266上非常容易产生内存碎片ESP32也好不到哪去。如果你想长期运行建议把代码里的String改成固定大小char[]或者使用reserve()预先分配空间。如果只是做实验现在这个版本够用。另外注意一下不要在WebServer的handler里做耗时操作不要在中断里调用String拼接否则也会触发看门狗超时重启。5.5 排查DNS劫持是否生效的通用步骤遇到问题先不要猜按下面的顺序查设备连接到ESP热点后用命令手动查询DNS。Windows下用nslookup www.example.com 192.168.4.1Linux/Mac下用dig 192.168.4.1 www.example.com。如果能返回192.168.4.1说明DNS劫持生效了。看ESP的串口日志确认它确实收到了DNS查询并且查一下域名解析是否正确。用Wireshark抓包过滤条件写udp.port 53。重点看响应报文里的“Answers”字段和“Flags”字段。如果Flags里的RCODE是0且Answers里有具体的A记录就说明响应正常。6. 安全与扩展这是实验不是攻击工具6.1 技术边界与合规提醒NCSI欺骗和DNS劫持这两个词听起来有点“攻击性”但它们本质上是网络协议的技术研究。我所描述的所有操作都必须在自己的设备、自己的热点、经授权的网络环境里进行。未经授权修改他人网络的DNS解析或者仿冒认证页面去收集信息是明确违法且违背职业道德的行为。强制门户和DNS重定向在商用场景里也有合法用途比如访客WiFi的认证页、企业内部网络的管理。但商用产品会有完整的流量转发、加密通信、接入认证等手段ESP32这里只是一个技术验证。大家做实验时可以大胆折腾但不要把它部署到别人的网络上也不要用来绕过任何付费机制或窃取信息。6.2 更多可玩方向实验到这里ESP32的“DNS能力”已经展示得差不多了。后面还能怎么玩我列几个我自己想尝试的方向。第一给ESP32加一块小屏幕把每个DNS查询的域名实时滚动显示出来像一个小型“DNS监控面板”。如果你在玩LVGL正好可以把列表页和滚动动画接上看起来很酷。第二把广告屏蔽规则放到SD卡或者Preferences里做到重启不丢失。虽然ESP32的Flash不大但存几百条黑名单域名完全没问题。第三接上MQTT把DNS查询记录上报到家里的服务器做成一个轻量级的网络行为分析工具。注意这个功能涉及隐私建议只在自己家里或者实验室使用。第四做一个更完整的“访客WiFi认证页”用户连接热点后自动弹出一个同意条款页面点击同意后ESP记录MAC地址后续不再劫持。这时DHCP、流量转发、访问控制都要配合上复杂度会上一个台阶但成就感也大很多。这次踩坑最大的感触是很多看似高大上的技术本质上就是一条128字节的DNS报文而已。动手抓一抓包比看十篇教程都管用。下次我打算把这块板子接到家里路由器后面做成一个小型DNS黑名单网关到时候再来分享。
返回列表