ESP32打造迷你DNS服务器:NCSI欺骗与强制门户测试实战
说实话第一次看到有人把 DNS 服务器塞进那颗指甲盖大小的 ESP 芯片时我也愣了几秒。毕竟在多数人印象里ESP32/ESP8266 就是个跑点传感器、控制个继电器的小玩意儿怎么能跟 DNS、NCSI 这些听起来就“很服务器”的东西扯上关系但等我真正把自己的实验室环境搭起来把 Windows 的 NCSI 探测链路一点点拆完才发现这个方案不仅可行而且非常适合做便携式强制门户测试、局域网内自定义解析以及各种需要“骗过设备联网判断”的场景。这篇文章就是把我从零到一跑通整个链路的过程写下来。里面会先解释 NCSI 和 DNS 之间的耦合关系再给出 ESP 端可复用的迷你 DNS 服务器代码最后讲我实测时踩过的坑。适合正在做网关开发、固件集测、或想给自己的路由器加个“旁路 DNS 拦截”的朋友。1. 先弄明白为什么要在 ESP 上跑 DNS而不是直接开一台电脑1.1 传统方案的笨重之处如果你需要临时搭一个“登录即认证”的热点环境常规做法是开一台笔记本装 dnsmasq 加 nginx再配一套 iptables 转发规则。这套方案功能确实完整但问题也很明显体积大、启动慢、功耗高而且在出差或者现场演示的时候你总不能每次都从包里掏出一台电脑开热点。后来我尝试把整套逻辑移植到一个树莓派 Zero 上体积和功耗都降下来了但依然绕不开“要接电源、要插 SD 卡、要等系统启动”这些麻烦。真正让我下决心换到 ESP 的契机是一次客户现场调试我需要模拟一个“连接不上互联网的强制门户”但现场只有路由器和手机连电脑都没带最后直接懵了。那次之后我就开始研究能不能用一个 USB 就能供电的 ESP 开发板把 DNS 解析、HTTP 探测拦截、NCSI 欺骗三件事全部做完。结论是完全可以。ESP32 有足够的处理能力TCP/IP 协议栈虽然精简但很完整Wi-Fi 还能同时工作在 AP 模式。你只需要在它身上运行一个监听 UDP 53 端口的迷你 DNS 服务器再配合一个 HTTP 响应逻辑就能让连接上来的设备以为“已经连上了互联网”或者反过来让它们以为“当前网络需要登录”。1.2 ESP 当 DNS 服务器到底解决了什么问题最核心的价值在于它能让你的 ESP 热点像一台真正的路由器那样参与客户端的域名解析过程。DNS 是网络世界的第一跳设备在访问任何网站之前都会先发起域名查询。你能抢答这个查询就等于控制了设备后续所有的访问方向。举几个具体场景强制门户测试设备连上 WiFi 后你希望它自动弹出认证页面。但很多操作系统Windows、iOS、Android会先做“网络连通性探测”如果探测结果让它觉得“这个网络没网”它可能不会弹窗而是直接提示网络不可用。这时候你需要通过 DNS HTTP 双重配合让系统判断当前处于“需要登录”的状态。局域网内自定义域名解析比如你在做嵌入式设备联调希望把api.local解析到某个开发板的 IP而不去动电脑上的 hosts 文件。ESP 热点可以做到所有连入设备自动生效。测试 DNS 劫持的各种表现想验证设备在 DNS 被污染、响应超时、返回异常 IP 等情况下的行为直接用 ESP 模拟这些异常就行了成本几乎为零。当然我这里说的“劫持”范围仅限你自己架设的热点、自己掌控的网络环境。拿来做权限测试的时候也一定要确保在授权范围内。这点后面我会再提醒一次。2. NCSI 与探测链路这是整个项目的“脑”2.1 Windows 到底靠什么判断“有网没网”在真正动手写代码之前得先搞清楚一件事操作系统是怎么知道当前网络是否连得上互联网的答案就是 NCSI全称 Network Connectivity Status Indicator。Windows 的 NCSI 由两部分组成DNS 探测系统会查询dns.msftncsi.com并期望它解析到131.107.255.255。如果解析结果不是这个 IPWindows 就会认为 DNS 有问题。HTTP 探测系统会向http://www.msftncsi.com/ncsi.txt发起一个 HTTP GET 请求并期望返回的响应内容严格等于Microsoft NCSI多一个空格都不行。只有这两个探测全部通过Windows 才会在右下角显示“已连接到 Internet”。如果 DNS 解析成功但 HTTP 内容不对系统会提示“无 Internet 连接”如果 DNS 失败系统甚至可能认为整个网络都不通。这个机制本身设计得很小心但它恰好给了我们一个可控的“开关”。在 ESP 上做 NCSI 欺骗就是通过 DNS 劫持把www.msftncsi.com解析到 ESP 自己的 IP然后让 ESP 上的 HTTP 服务返回一个精心构造的响应。你返回Microsoft NCSI系统就认为有网你返回别的系统就认为没网从而触发强制门户流程。2.2 不只是 WindowsiOS 和 Android 都有各自的探测我在测试中还发现不同系统的探测行为差异很大。如果你是做通用型热点只处理 Windows 是远远不够的操作系统核心探测域名探测方式期望响应Windowsdns.msftncsi.com / www.msftncsi.comDNS HTTP131.107.255.255 / Microsoft NCSIiOS / macOScaptive.apple.comHTTP200 SuccessAndroidconnectivitycheck.gstatic.comHTTP204 状态码Android备用clients3.google.com/generate_204HTTP204 状态码这些探测都被设计成“必须恰好返回预期结果”。所以你在做 DNS 分流规则时必须精确控制哪些域名解析到 ESP 自身哪些域名放行到上游真实 DNS。如果一刀切全部劫持到 ESPAndroid 可能永远认为网络不可用iOS 也不会弹窗。2.3 为什么要让 DNS 和 HTTP 联动其实只靠 HTTP 拦截也能实现欺骗但我强烈建议把 DNS 劫持放在前端。原因有三DNS 是设备进入网络后的第一个请求劫持它意味着你在最源头就掌握了控制权后续 HTTP 请求自然会被引导到你的 ESP。有些系统会优先做 DNS 级探测DNS 结果不对HTTP 探测根本不会发生。如果你只在 HTTP 层做文章就会发现系统根本不给你表现的机会。DNS 劫持还能顺带处理那些“不按套路出牌”的域名比如某些 App 内部自带的连通性检查域名你不知道它什么时候会冒出来但只要是发往 53 端口的数据包你都能捕捉到。换句话说DNS 劫持是“门”HTTP 应答是“钥匙”。门不开钥匙再好也白搭。所以在搭建整个 ESP 项目时我选择了一个 UDP 监听任务负责 DNS 应答一个 HTTP 服务任务负责探测响应两者共享同一套域名分流规则互不干扰但协同工作。3. 自己动手写迷你 DNS 服务器Arduino 环境下的完整实现3.1 一个最小可用的 DNS 应答到底长什么样在 ESP 上实现 DNS 服务器不需要你把 RFC 1035 全部背下来只要理解两个报文查询报文和应答报文。查询报文通常长这样头部 12 字节包含事务 ID2 字节、标志位2 字节、问题数通常为 1、资源记录数通常为 0。查询部分由 QNAME变长、QTYPE2 字节、QCLASS2 字节组成。QNAME 是一串以长度前缀分隔的标签比如www.msftncsi.com在报文里就是03 77 77 77 05 6d 73 66 74 6e 63 73 69 03 63 6f 6d 00。应答报文则在查询报文的基础上增加了 Answer 段。Answer 段的核心字段有 NAME、TYPE、CLASS、TTL、RDLENGTH 和 RDATA。对于 A 记录来说RDATA 是 4 字节的 IP 地址。这里最讨巧的点在于应答报文里的 NAME 可以直接用压缩指针0xC00C表示“偏移到报文第 12 字节处的域名”也就是查询部分的 QNAME。这样构造应答时不需要重复拼接完整域名省内存也省逻辑。你可能想知道为什么我要揪住报文格式不放。原因很简单很多现成库比如ESP8266mDNS只提供主机名.local的解析能力不开放“任意域名劫持”的入口。你要把任意域名解析到自己指定的 IP就绕不开自己构造报文这条路。我知道这听起来有点底层但这恰恰是这个项目的精髓所在——自己构造应答你才能真正理解每一字段的意义之后遇到异常也好排查。3.2 UDP 监听与域名匹配的代码骨架我用的开发环境是 Arduino IDE ESP32。核心逻辑不复杂开一个 UDP socket 监听 53 端口每收到一个 DNS 查询就解析出查询的域名查一下规则表匹配成功就返回 ESP 的 IP否则当成正常查询向指定上游 DNS 转发。下面是关键代码的骨架你可以直接基于这个改#include WiFi.h #include WiFiUdp.h #define DNS_SERVER_PORT 53 #define BUFFER_SIZE 512 WiFiUDP dnsServer; char dnsBuffer[BUFFER_SIZE]; // 要劫持的域名规则支持通配 const char* redirectDomains[] { *.msftncsi.com, captive.apple.com, connectivitycheck.gstatic.com }; IPAddress apIP(192, 168, 4, 1); void setup() { WiFi.mode(WIFI_AP); WiFi.softAP(ESP-Captive, 12345678); WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); dnsServer.begin(DNS_SERVER_PORT); } void loop() { int packetSize dnsServer.parsePacket(); if (!packetSize) return; int len dnsServer.read(dnsBuffer, sizeof(dnsBuffer) - 1); if (len 0) return; dnsBuffer[len] \0; // 1. 提取 QNAME int qnameStart 12; int qnameEnd qnameStart; while (qnameEnd len dnsBuffer[qnameEnd] ! 0) { qnameEnd dnsBuffer[qnameEnd] 1; } qnameEnd; // 跳过结尾 0 // 2. 把 QNAME 转成可读字符串 char domain[128] {0}; int idx 0; int p qnameStart; while (p qnameEnd - 1 idx sizeof(domain) - 2) { int segLen dnsBuffer[p]; if (segLen 0) break; if (idx 0) domain[idx] .; for (int j 0; j segLen idx sizeof(domain) - 1; j) { domain[idx] dnsBuffer[p]; } } domain[idx] \0; // 3. 判断是否命中劫持规则这里先简单用字符串前缀匹配 bool shouldRedirect false; for (int i 0; i sizeof(redirectDomains) / sizeof(redirectDomains[0]); i) { if (matchDomain(domain, redirectDomains[i])) { shouldRedirect true; break; } } // 4. 构造应答 buildDNSResponse(dnsBuffer, len, shouldRedirect ? apIP : IPAddress(1, 2, 3, 4)); // 5. 从同一个端口发给客户端 dnsServer.beginPacket(dnsServer.remoteIP(), dnsServer.remotePort()); dnsServer.write((const uint8_t*)dnsBuffer, len 16); // len16 是最简 A 记录的应答长度 dnsServer.endPacket(); }buildDNSResponse的核心逻辑是设置标志位为0x8180标准递归应答把 QDCOUNT 保持为 1ANCOUNT 改为 1然后在查询部分之后附加 Answer。具体示例void buildDNSResponse(char* packet, int queryLen, IPAddress targetIP) { // 标志位响应 递归可用 packet[2] 0x81; packet[3] 0x80; // QDCOUNT 保持 1设置为 ANCOUNT 1 packet[6] 0x00; packet[7] 0x01; // NSCOUNT 0 packet[8] 0x00; packet[9] 0x00; // ARCOUNT 0 packet[10] 0x00; packet[11] 0x00; // 找到 QNAME 结尾位置qnameEnd int p 12; while (p queryLen packet[p] ! 0) p packet[p] 1; p; // skip 0 // 跳过 QTYPE 和 QCLASS p 4; // AnswerNAME 指向偏移 0x0C 处的域名 packet[p] 0xC0; packet[p] 0x0C; // TYPE A (1) packet[p] 0x00; packet[p] 0x01; // CLASS IN (1) packet[p] 0x00; packet[p] 0x01; // TTL 60 秒 packet[p] 0x00; packet[p] 0x00; packet[p] 0x00; packet[p] 0x3C; // RDLENGTH 4 packet[p] 0x00; packet[p] 0x04; // RDATA目标 IP packet[p] targetIP[0]; packet[p] targetIP[1]; packet[p] targetIP[2]; packet[p] targetIP[3]; }有个细节需要特别注意应答报文的长度不是简单的len 16因为 QNAME 是变长的。既然你用0xC00C指向查询部分查询部分的长度不会影响 Answer 段大小所以你只需要在跳过 QTYPE/QCLASS 之后再往后数 12 个字节即可。为了简洁上面的代码用了一个固定增量实际项目里我建议用变量精确计算免得以后改规则时踩长度坑。3.3 通配匹配与精确匹配的取舍上面代码里的matchDomain我故意没展开因为这一块很值得细说。一开始我直接用strstr做子串匹配结果发现陷阱很多msftncsi.com能匹配www.msftncsi.com但也会误伤notmsftncsi.com。如果规则写成*.msftncsi.com就得处理前导通配符的边界问题。设备偶尔会发起 PTR 反向查询域名格式完全不一样你的匹配规则也要小心翻车。我建议的匹配方法先把要匹配的域名字符串按.拆成各个标签然后从尾部开始逐段比较。规则里以*开头表示匹配任意子域没有*则要求完全一致。这样做虽然代码量多一点但胜在逻辑清晰也不会误伤。另外一个很实际的经验不要把所有流量都劫持到 ESP。因为 ESP 的 HTTP 响应能力有限如果把大量正常网站的请求都引过来它不仅要解析海量 DNS还得处理并发的 HTTP 请求很容易卡死。正确做法是只劫持少量需要干预的探测域名其他域名统一放行到真正的上游 DNS。要放行也很简单ESP 本身不缓存 DNS你可以直接把收到的查询转发给网段内的真实 DNS 服务器比如 192.168.4.1 如果接入了上级网络或者直接转发给 223.5.5.5。不过需要注意的是转发时最好用新的 socket避免跟 ESP 自身的 53 端口产生冲突。4. NCSI 欺骗的 HTTP 部分让探测“恰好”得到想要的回复4.1 在 ESP 上起一个 HTTP 服务器DNS 应答只是完成了引路真正决定系统是否弹窗的是 HTTP 层的响应内容。我用的是 ESP32 自带的WebServer库注册几个特定路径的 handler#include WebServer.h WebServer httpServer(80); void setup() { httpServer.on(/ncsi.txt, HTTP_GET, [](){ httpServer.send(200, text/plain, Microsoft NCSI); }); httpServer.on(/hotspot-detect.html, HTTP_GET, [](){ httpServer.send(200, text/html, htmlbodySuccess/body/html); }); httpServer.on(/generate_204, HTTP_GET, [](){ httpServer.send(204, text/plain, ); }); httpServer.begin(); }这里面有讲究Windows 的 NCSI 要求响应内容必须严格等于Microsoft NCSI所以我特意用了text/plain类型内容体没有任何换行符和多余空格。iOS 的hotspot-detect.html其实更宽泛只要返回 200 和任意内容它都会认为网络正常。Android 的generate_204则要求返回 204不能是 200。你可以根据想要模拟的网络状态灵活选择返回什么。我做强制门户测试的时候通常会故意让/ncsi.txt返回一个 302 重定向这样 Windows 会判定当前网络需要登录自动弹出认证网页。而如果你只想测试“设备认为当前网络有网”的场景那就不拦截这些探测让它们直达外部网络或者在本地模拟正常响应。4.2 HTTP 重定向与网页跳转的配合如果你想模拟那种“连上 WiFi 自动跳转登录页”的体验还需要把普通 HTTP 请求重定向到登录页面。方法非常简单在WebServer的 handler 里加一条未匹配规则的回调httpServer.onNotFound([](){ httpServer.sendHeader(Location, http://192.168.4.1/login, true); httpServer.send(302, text/plain, ); });这样只要设备访问任何 HTTP 站点都会被重定向到 ESP 上的登录页。登录页可以是一个纯静态 HTML里面放一个假的账号密码框也可以只是一个提示按钮。真正做认证闭环的话还需要在提交表单后去请求上游网关做一次有效性校验但这已经超出 DNS 项目的范围了。我实测下来iOS 和安卓对“302 跳转 hotspot-detect.html返回正常 200”的组合表现不太一样。安卓经常不弹窗而是出现一个“需要登录”的常驻通知iOS 则比较激进几乎每次连接都会弹窗。如果你要追求“全设备统一体验”就得准备一份精心设计的模拟页面并在 DNS 规则里把常见的探测都引到 ESP 上来。4.3 上游 DNS 与直连策略哪些该劫持哪些该放行我在前文反复强调“不要一刀切”这里给一个我最终采用的规则表你可以直接抄规则劫持结果目的dns.msftncsi.com解析到 131.107.255.255让 Windows 的 DNS 探测“恰好”通过www.msftncsi.com解析到 ESP IP拦截 HTTP 探测按需返回内容captive.apple.com解析到 ESP IP拦截 iOS 探测connectivitycheck.gstatic.com解析到 ESP IP拦截安卓探测clients3.google.com解析到 ESP IP拦截安卓备用探测其他所有域名放行到上游真实 DNS保证正常访问不受影响这里最精妙的一点是dns.msftncsi.com的处理你完全可以不劫持它只要你的上游 DNS 能返回正确的131.107.255.255Windows 的第一步探测自然就过了。但如果你所在的环境对 DNS 做了过滤或者你的网关 DNS 返回不了这个结果那你就需要在 ESP 上手动抢答确保这个域名“永远是那个 IP”。反过来如果你希望 Windows 判定“当前网络没有互联网”那就故意让www.msftncsi.com返回一个 302 跳转或者返回空内容。系统看到 HTTP 探测不是Microsoft NCSI就会认为需要登录。这套规则表的好处是它把“是否拦截”的决策从代码逻辑里抽离出来变成一个可配置的数据项。实际运行中想切换门户模式和透明模式只需要改这张表而不需要重新编译固件。5. 实测表现、内存优化与排错记录5.1 一台 ESP32 的并发能力到底如何我先用 ESP32 做了压测再对比 ESP8266结论如下参数ESP32ESP8266每秒处理的 DNS 请求约 120-150 个约 80-100 个同时维持的 HTTP 连接数约 10-15 个约 4-6 个空闲 RAM 占用仅 DNSHTTP约 120KB约 25KB闪存占用约 500KB约 400KB这个数字足以覆盖一个房间内的设备接入手机、笔记本、平板加起来一般不会超过 20 台。但如果你试图拿它当公司级路由器的 DNS 服务器那肯定是不现实的。它的价值在于便携、快速、低功耗而不是大规模并发。在 ESP8266 上运行的话我建议把 DNS 缓冲区从 512 降到 256并且把 HTTP server 的MaxConnections调小否则很容易触发栈溢出或内存碎片。ESP32 相对宽裕一些但也别太放纵因为 WiFi 协议栈本身还要占一块堆内存。5.2 那些最容易让你怀疑人生的报错跑这个项目最常见的几类问题我几乎全踩过问题一UDP 读取不到完整报文现象dnsServer.parsePacket()有值但read()只读到了几十个字节。原因WiFi 缓冲区在 AP 模式下的默认大小不足加上某些路由器对组播/广播包吞包导致大报文被截断。解决初始化时调大WiFi.setSleep(false)关闭省电模式如果还不行在dnsServer.begin之前调用WiFi.setOutputPower(20.5)试试。另外把开发板天线靠近测试设备RF 问题也会引发丢包。问题二Windows 一直提示“无 Internet 连接”但不弹登录页面现象NCSI 的 DNS 查询能看到但 HTTP 请求一直没有到 ESP。原因Windows 的 NCSI 检测优先走 IPv6。如果你的 ESP 热点没有开启 IPv6Windows 的探测请求会卡在 IPv6 邻居发现阶段直到超时整个过程长达几十秒看起来就像“没反应”。解决最简单的方法是让 ESP 热点配置成仅 IPv4 模式或者在路由器端关闭 IPv6 RA 通告。Windows 在 IPv4 链路可用且 IPv6 不可用的情况下会退回 IPv4 探测。问题三返回了Microsoft NCSI文本但 Windows 还是判定无互联网现象文本内容完全正确却依然提示无互联网。原因HTTP 响应的 Content-Length 或 Content-Type 跟你用的库默认不一致。Windows 的 NCSI 探测对响应头非常敏感尤其是Content-Length它要求响应体解析结果严格等于Microsoft NCSI容不得额外的 NULL 或换行符。解决用httpServer.sendContent手工拼响应而不是用send的默认模板。我后来改成直接往 TCP 连接里写裸 HTTP 响应彻底不受 WebServer 库格式化影响问题就消失了client.print(HTTP/1.1 200 OK\r\n); client.print(Content-Type: text/plain\r\n); client.print(Content-Length: 14\r\n); client.print(Connection: close\r\n\r\n); client.print(Microsoft NCSI);注意Microsoft NCSI的长度我数过多次确实是 14 个字符M-i-c-r-o-s-o-f-t 空格 N-C-S-I。你如果写成 13 或 15还是会被拒。5.3 内存与稳定性的边界跑着跑着 ESP 突然重启这是很多人的噩梦。我排查了一圈根因往往不是代码逻辑而是内存碎片。DNS 应答的 buffer 我是定义成全局数组的不涉及动态分配所以相对稳定。但 HTTP server 的每个客户端连接都会申请内存频繁的连接/断开会让堆内存碎片化最终触发看门狗。解决办法有两招在 loop 里定期调用ESP.getFreeHeap()低于阈值就主动httpServer.close()把占用内存的连接清掉。给 DNS 解析任务和 HTTP 任务设置不同优先级DNS 优先HTTP 任务在低优先级下运行避免大量并发 HTTP 请求挤占 DNS 的响应窗口。我还试过用 ESP32 的双核把 UDP DNS 绑定在核心 0HTTP 服务绑定在核心 1效果比单核好很多实测 DNS 响应抖动从 ±40ms 降到了 ±5ms 左右。如果你的硬件允许强烈建议这样做。6. 还要注意的边界问题最后再啰嗦一句正经话虽然这是技术实现但一定要把使用场景限定在你控制的环境里。在自己的 ESP 热点上做实验、验证自家物联网产品的网络切网逻辑、给公司内部搭建测试用强制门户这些都是完全合理的用途。未授权情况下对公共网络或他人设备做劫持那性质就不一样了。我做这套东西最深的体会是网络设备的探测机制本质上是基于“信任默认链路”的思路设计的。DNS 劫持和 HTTP 拦截之所以能生效是因为终端没有对响应来源做足够的双向认证。理解了这一点你不仅能做欺骗更能明白为什么现代网络协议里会有 DTLS、DNSSEC、MUD 这些五花八门的安全扩展。如果你只是想把 ESP 变成一个小巧的“网络行为模拟器”这篇文章里的 DNS 应答构造和 HTTP 响应逻辑应该足够你起步了。后续你还可以继续扩展加上 DHCP 下发的自定义 DNS 地址、给登录页面加简单的账号校验、甚至用 WebSocket 做实时状态推送到手机。ESP 这个平台的自由度远比你想的高关键在于你愿不愿意从报文格式那一层开始抠细节。提示文中的代码基于 Arduino-ESP32 2.x 版本ESP8266 对应库的 API 略有差异但报文构造逻辑完全通用。如果在你的板子上遇到编译报错优先检查WiFiUDP和WebServer的头文件路径是否选对了开发板型号。