资讯动态

ESP32变身DNS服务器:从NCSI欺骗到内网劫持的完整实战

发布时间:2026/9/10 11:07:08 来源:尧图企业网站定制
我最初碰这个组合纯粹是被 Windows 的“无 Internet 连接”给逼的。当时拿一块 ESP32 做无线传感器网关开了 AP 热点让笔记本连上去看数据面板结果 Windows 一直在托盘里挂个黄色感叹号系统里各种“dns probe started”报错刷屏。后来才搞清楚这是 Windows 的 NCSINetwork Connectivity Status Indicator在探测外部网络它检测不到互联网就默认你这个网络“不可用”。研究了一圈解决办法居然不是去配置 Windows而是让 ESP32 自己把 DNS 服务扛起来——它一边当 DNS 服务器一边把 NCSI 探测域名“劫持”到本地伪造出一个“联网成功”的状态。这个思路趟通之后我发现ESP32 做 DNS 服务器这件事本身就有很大的玩头不光是骗过系统状态检测还能做广告域名拦截、内网域名重定向甚至搭一个简易的强制门户。这篇文章就把整套实现从协议原理到完整代码掰开揉碎讲一遍适合手里有 ESP32、又对网络协议感兴趣的玩家。1. ESP32 跑 DNS 服务器的底气硬件底子与协议开销拆解先从硬件预判说起。很多人一听到“DNS 服务器”这五个字脑子里浮现的是 BIND 跑在 Linux 服务器上的画面再一看 ESP32 只有 520KB SRAM就觉得不靠谱。实际上完全不是一回事。DNS 协议本身极简尤其是作为局域网内的权威服务器或者劫持服务器时它根本不需要做递归解析。一次完整的 DNS 交互就是客户端发一个 Query 报文服务器回一个 Response 报文。典型的一个 A 记录查询请求报文长度基本在 40 到 100 字节之间响应报文也就 80 字节上下。对比一下ESP32 的 WiFi 硬件协议栈是内置的UDP 收发不占太多 CPU 资源双核 240MHz 的主频处理这种几十字节的报文交换属于杀鸡用牛刀。家里正常的路由器下面挂几十台设备每台设备开机时可能会发几条 DNS 请求但瞬时 QPS 也就个位数甚至更低ESP32 完全扛得住。这里要先澄清一个边界问题。ESP32 能当的“DNS 服务器”准确说是局域网内的权威/劫持型 DNS 服务器不是传统意义上能做递归查询的公共 DNS。它不负责去根域名服务器迭代解析而是拿着本地规则表回答客户端。规则表里有的域名直接返回预设 IP规则表里没有的可以选择返回空应答也可以选择丢弃请求让客户端走兜底 DNS。这个定位决定了它的实现难度和代码量都远低于跑一个完整 DNS 程序但这恰恰是它适合被嵌入到各种 IoT 网关里的原因。真正需要考虑的性能瓶颈其实是双线程问题。DNS 服务器跑一个循环HTTP 服务器或者 MQTT 客户端可能还要跑另一个循环这在 ESP32 上可以用 FreeRTOS 任务解决但要注意共享变量和内存分配的竞态条件。我前期踩过坑在 DNS 回调函数里直接操作 String 对象导致内存碎片和随机重启后来改成固定缓冲区 标志位变量才稳定下来。资源评估我整理成表格方便对比评估项数据结论主频双核 240MHzESP32富余SRAM520KBESP32够用但要避免堆碎片单报文处理耗时微秒到百微秒级可忽略典型报文大小40~100 字节远低于 UDP 载荷上限需要处理的任务DNS HTTP 主逻辑用 RTOS 任务拆开对于 ESP8266 这种单核、只有 160KB SRAM 的芯片也勉强能跑但不建议同时开 HTTP 和 DNS因为内存太紧字符串处理稍不注意就重启。如果手头只有 ESP8266建议把规则表固定死不要做动态管理页面。2. 核心实现一个最小可用的 DNS 响应引擎既然要做 DNS 服务器第一步是把 DNS 协议的报文格式吃透。DNS 报文以 12 字节的 Header 开头然后跟着 Question 区、Answer 区、Authority 区和 Additional 区。我实际写代码时只用到了 Header 和 QuestionAnswer 是在响应时手动构造的。Header 里最关键的是前两个字节的 Transaction ID它用于匹配请求和响应服务器必须原样复制回客户端。紧跟着的两个字节是 Flags常见的标志位包括 QR0 表示查询1 表示响应、Opcode标准查询为 0、RD期望递归、RA可递归。响应时通常把 Flags 设为0x8180二进制展开就是1000 0001 1000 0000含义是标准响应、禁止递归但声明可用递归。接着是 QDCOUNT、ANCOUNT、NSCOUNT、ARCOUNT 四个 16 位字段分别记录各区域的数量。我们响应劫持时QDCOUNT 填 1因为只收到一条查询ANCOUNT 填 1返回一条 Answer。Question 区的解析是最容易写错的部分。QNAME 是一个长度前缀的域名序列比如www.example.com被编码成03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00每个标签前面是长度字节最后以 0x00 结束。解析时从偏移量 12 开始循环读长度字节根据长度截取标签拼成可读域名。注意这里有个细节请求报文里 QNAME 通常是未压缩的明文但响应报文的 Answer 里可以用压缩指针。最常用的压缩方式是在 Answer 的 NAME 字段填入0xC0 0x0C表示“跳转到报文偏移 12 处去读域名”也就是直接复用请求里的 QNAME。这样响应报文能省几十个字节很多 Diameter 协议、DNS 实现的文档里都会提到这种指针压缩实测 ESP32 上也稳定。Query 类型字段QTYPE也是必须判断的否则会被各种奇怪请求干扰。现代设备查询时很杂A 记录值 1是最常见的AAAA 记录值 28在 IPv6 环境也会出现偶尔还有 HTTPS/SVCB 类型值 65和 TXT 记录值 16。既然我们的劫持目标是返回 IPv4 地址那对非 A 记录的请求直接置 ANCOUNT 为 0 返回空应答即可客户端会继续走兜底 DNS。下面给一段能直接编译运行的 Arduino 框架代码只实现 DNS 响应的最小内核方便你看清每一条字节是怎么填进去的。这一段我是配合 PlatformIO Arduino-ESP32 测试的依赖 ESP32 的 WiFiUDP 库。#include WiFi.h #include WiFiUdp.h WiFiUDP udp; const int DNS_PORT 53; uint8_t dnsBuffer[512]; // 解析 DNS 请求中的 QNAME写入 name返回该字段结束后的偏移 uint16_t parseDnsName(const uint8_t* data, uint16_t offset, uint16_t maxLen, char* name, uint16_t nameLen) { uint16_t pos offset; uint16_t idx 0; while (pos maxLen) { uint8_t len data[pos]; if (len 0) { pos; break; } // 跳过压缩指针请求里一般不用但处理一下更稳 if ((len 0xC0) 0xC0) { pos 2; break; } pos; if (idx len 1 nameLen) break; memcpy(name[idx], data[pos], len); idx len; name[idx] .; pos len; } if (idx 0) name[idx - 1] \0; return pos; } void handleDnsPacket() { int len udp.parsePacket(); if (len 0) return; udp.read(dnsBuffer, sizeof(dnsBuffer)); // 只处理标准查询 if ((dnsBuffer[2] 0x78) ! 0) return; uint16_t qdcount (dnsBuffer[4] 8) | dnsBuffer[5]; if (qdcount ! 1) return; char qname[128] {0}; uint16_t qnameEnd parseDnsName(dnsBuffer, 12, len, qname, sizeof(qname)); uint16_t qtype (dnsBuffer[qnameEnd] 8) | dnsBuffer[qnameEnd 1]; // 只回答 A 记录查询 if (qtype ! 1) return; IPAddress targetIP(192, 168, 4, 1); // 劫持到指定 IP uint8_t response[256]; uint16_t rlen 0; // Header: 复制 ID flags 0x8180 QDCOUNT1 ANCOUNT1 response[rlen] dnsBuffer[0]; response[rlen] dnsBuffer[1]; response[rlen] 0x81; response[rlen] 0x80; response[rlen] 0x00; response[rlen] 0x01; response[rlen] 0x00; response[rlen] 0x01; // ANCOUNT response[rlen] 0x00; response[rlen] 0x00; response[rlen] 0x00; response[rlen] 0x00; // Question: 原样复制请求中的 Query 区 uint16_t qLen qnameEnd 4 - 12; memcpy(response[rlen], dnsBuffer[12], qLen); rlen qLen; // Answer: 压缩指针指向 Question 区的域名 response[rlen] 0xC0; response[rlen] 0x0C; // TYPEA, CLASSIN response[rlen] 0x00; response[rlen] 0x01; response[rlen] 0x00; response[rlen] 0x01; // TTL60 response[rlen] 0x00; response[rlen] 0x00; response[rlen] 0x00; response[rlen] 0x3C; // RDLENGTH4 response[rlen] 0x00; response[rlen] 0x04; // RDATA: 目标 IP response[rlen] targetIP[0]; response[rlen] targetIP[1]; response[rlen] targetIP[2]; response[rlen] targetIP[3]; udp.beginPacket(udp.remoteIP(), udp.remotePort()); udp.write(response, rlen); udp.endPacket(); } void setup() { // 假设已经连上 WiFi 或者开启了 AP udp.begin(DNS_PORT); } void loop() { handleDnsPacket(); }这段代码的运行逻辑很直白循环读取 UDP 53 端口的数据包解析域名判断类型然后构造一条 A 记录响应。测试时可以用nslookup指到 ESP32 的 IP 上也可以把电脑 DNS 手动设置成 ESP32 的地址然后nslookup baidu.com如果返回了192.168.4.1说明劫持生效。有两点需要特别提示。第一parseDnsName里对压缩指针的处理是跳过 2 字节但请求包里一般不会用压缩。真正需要压缩的是响应包里的 Answer NAME这个我已经用0xC0 0x0C实现了。第二UDP 缓冲区设为 512 字节是最小安全值如果遇到带 EDNS0 的请求比如 DNS Cookie、DNSSEC 相关的 OPT 记录报文可能超过 512我建议直接不管附加区只解析到 QNAME 结束这样既不会破坏报文的正常解析也不会因为缓冲区溢出崩掉。3. NCSI 欺骗让 Windows 不再纠结你到底通没通网说回文章开头那个问题Windows 的“无 Internet 连接”提示到底是谁在判断答案就是 NCSI。Windows 10/11 的网络状态检测流程大致如下系统检测到网络接口通了之后会向www.msftconnecttest.com发起 HTTP 请求访问/connecttest.txt期望返回 200 状态码并带有一段固定文本。如果这个请求超时或者内容不对系统就判定“此网络无 Internet 访问”于是托盘出现黄色感叹号UWP 应用可能拒绝联网某些后台服务也会启动延迟。那 NCSI 欺骗的思路就很清晰了通过 DNS 劫持把www.msftconnecttest.com解析到 ESP32 自己身上然后在 ESP32 上开一个 HTTP 服务访问/connecttest.txt时返回 200 和一段包含固定关键字的文本。Windows 探测成功立马把网络状态更新为“已连接”。不只是 Windows安卓和苹果也有类似的机制只是域名和校验方式不同。我把常见平台的探测域名和期望响应整理了出来平台探测域名期望响应校验方式Windowswww.msftconnecttest.com200 Microsoft Connect TestHTTP 状态码 文本Androidconnectivitycheck.gstatic.com204 空响应HTTP 状态码Applecaptive.apple.com200 HTMLHTTP 状态码ChromeOSwww.gstatic.com/generate_204204 空响应HTTP 状态码注意这个细节安卓的探测域名是connectivitycheck.gstatic.com路径是/generate_204服务器返回 204 空响应即可不需要带内容。Windows 则必须返回那串文本所以 HTTP handler 要按域名区分路径。在 DNS 劫持规则里这些 NCSI 域名必须被标记为“本地响应”并且 TTL 设短一点。我这里 TTL 设 60 秒防止设备缓存住旧结果导致状态判断不更新。如果条件允许可以再加一个 UDP 53 监听在 STA 模式获取的接口上这样设备通过路由器上网时也能被劫持不过注意别跟你自己电脑上跑的上游 DNS 客户端冲突。HTTP 部分我用 Arduino 的 WebServer 库代码不长#include WebServer.h WebServer httpServer(80); void setup() { // 原 DNS 代码不变新增 httpServer.on(/connecttest.txt, HTTP_GET, []() { httpServer.send(200, text/plain, Microsoft Connect Test); }); httpServer.on(/generate_204, HTTP_GET, []() { httpServer.send(204, text/plain, ); }); httpServer.on(/hotspot-detect.html, HTTP_GET, []() { httpServer.send(200, text/html, HTMLHEADTITLESuccess/TITLE/HEADBODYSuccess/BODY/HTML); }); httpServer.begin(); } void loop() { handleDnsPacket(); httpServer.handleClient(); }把这个 HTTP 服务器跑起来再配合 DNS 劫持规则整体就是一个“网络状态伪装器”。实测效果很直观Windows 连接 ESP32 的 AP 后大概三到五秒内托盘图标就从黄色感叹号变成正常状态dns probe started的日志也不会再刷了。这里要强调一个安全与合规边界。NCSI 欺骗本质上是修改本机设备的网络状态判断结果可以用在调试自建网关、内网测试环境、离线演示这些合法场景里用来模拟设备“已联网”的状态。但如果你拿这套去干扰别人的网络、伪造公共网络认证那性质就变了。我在自己实验室环境里测试仅限自己手中的设备和开发板大家落地时也要注意测试范围不要拿来整蛊同事或者蹭网。4. 完整的主控程序规则表驱动的劫持与内网域名解析前面给的代码是“把所有域名都劫持到同一个 IP”的简单版本。但实际场景里我需要区分三类域名NCSI 探测域名返回本地 HTTP 服务地址ESP32 自身 IP广告/追踪域名返回0.0.0.0让请求直接失败起到广告拦截的作用自定义映射域名比如把nas.local指向一台内网服务器 IP方便局域网访问这个需求用一张规则表就能搞定。我用的是 Arduino 的std::map但要注意内存碎片问题在 setup 阶段一次性初始化运行时只读不写这样安全很多。#include map std::mapString, IPAddress dnsRules; void setupRules() { // NCSI 域 dnsRules[www.msftconnecttest.com] IPAddress(192, 168, 4, 1); dnsRules[connectivitycheck.gstatic.com] IPAddress(192, 168, 4, 1); dnsRules[captive.apple.com] IPAddress(192, 168, 4, 1); // 广告拦截指向 0.0.0.0 dnsRules[ad.doubleclick.net] IPAddress(0, 0, 0, 0); dnsRules[ads.google.com] IPAddress(0, 0, 0, 0); dnsRules[pagead2.googlesyndication.com] IPAddress(0, 0, 0, 0); // 内网自定义解析 dnsRules[dev.local] IPAddress(192, 168, 4, 88); dnsRules[sensor.local] IPAddress(192, 168, 4, 66); }在handleDnsPacket里解析出 qname 后直接查这张表const char* qnameLow qname; IPAddress targetIP; auto it dnsRules.find(String(qnameLow)); if (it ! dnsRules.end()) { targetIP it-second; } else { // 未匹配规则返回空应答客户端会继续走其他 DNS respondEmpty(); return; }未匹配时的空应答构造方式和前面类似只是 ANCOUNT 置 0不再追加 Answer 区。客户端收到空应答后会立刻换下一个 DNS 服务器重试这就是前面说的“兜底 DNS”场景。比如 ESP32 是通过路由器联网的STA 模式而路由器本身就是 DNS 服务器那未匹配的域名就会落到路由器手里实现正常解析。如果想让未匹配的域名直接走 ESP32 自身去递归查询也可以开一个 UDP DNS 客户端转发请求到上游比如路由器的 192.168.1.1 或者公共 DNS再把响应转回内网设备。这就需要处理多路并发和超时复杂度立刻上升。我的经验是如果不是做全功能代理没必要在初始版本里搞递归转发容易把代码改成意大利面。先用“空应答 客户端兜底”的方式跑通等核心逻辑稳定了再考虑转发。规则表这个设计还有一个隐藏好处可以做成远程管理。把表内容存到 Preferences 或 SPIFFS 里再开一个 HTTP 管理页面你就能在不改代码的情况下动态增删劫持规则。我后来就是这个思路手机浏览器打开 ESP32 的 IP直接编辑 DNS 规则保存后立即生效。这对调试智能家居设备特别方便想临时把某个云服务域名指向测试服务器不用重新编译烧录网页里改一条记录就行。5. 实测验证从 nslookup 到 Windows 网络图标的完整复现代码写完只是万里长征第一步关键在于验证链路是否真的通了。我分三部分做了实测DNS 劫持效果、NCSI 伪装效果、以及整体稳定性。第一部分DNS 劫持效果验证。把电脑的无线网卡接到 ESP32 的 AP 上然后将 IPv4 的 DNS 服务器手动设为192.168.4.1如果你用的是路由器模式DHCP 会自动下发 DNS 地址这里不需要手动设置。接着在命令行敲nslookup dev.local 192.168.4.1预期输出是名称: dev.local Address: 192.168.4.88再测试一个广告域名nslookup ad.doubleclick.net 192.168.4.1预期返回192.168.0.0或者超时取决于客户端怎么处理 0.0.0.0 地址。在 Chrome 里直接访问一些含有广告域名的页面可以看到广告加载明显变慢甚至不加载因为请求发出后在 DNS 层就被掐断了。第二部分NCSI 伪装效果验证。这个观察起来最明显。Windows 连接 ESP32 AP 之前网络图标是地球加黄色感叹号等 DNS 劫持和 HTTP 服务同时生效后重新触发一次网络状态检测断开重连或者ipconfig /flushdns 重启网络适配器图标会在几秒内变成正常的电脑图标。之后打开浏览器访问任意 http 网站Windows 会走系统代理或者直接连接只要通就反馈“已连接”。第三部分稳定性测试。我让这块 ESP32 连着跑了七天期间 WiFi 上挂了两台手机、一台笔记本、一个智能插座。DNS 请求量不算大但 HTTP 和 UDP 同时工作内存占用曲线很平稳。中间遇到一次 WiFi 断连后自动重连重连后 DNS 服务正常恢复没有出现死锁。踩坑记录我专门列一下这些都是实际跑出来的教训现象根因解决办法Windows 一直提示无 InternetHTTP 响应里没返回固定文本connecttest.txt必须返回包含 Microsoft Connect Test 的 200 响应安卓设备连不上网络安卓 NCSI 探测走generate_204要求空 204HTTP handler 区分路径204 响应不能带 body劫持域名偶尔失效设备缓存了之前 DNS 的 TTL 结果TTL 设 0 或 60测试时先清设备 DNS 缓存软重启后规则丢失std::map存在内存里没持久化把规则表写进 PreferencesDNS 响应包乱码大小端写反端口号/长度字段高低字节颠倒DNS 报文全是大端序IP 地址特殊处理关于“dns probe started”这个报错网上搜索热度一直很高其实它对应的就是 Windows 网络状态探测的日志关键词。看到这个提示先别急着怀疑路由器或者 ISP检查一下设备上的 NCSI 请求到底有没有被正确处理。如果是在自建的实验 AP 环境里十有八九是 NCSI 的 HTTP 探测没人响应。6. 真实项目中的避坑经验与玩法扩展最后聊一些只有把项目跑上一段时间才会发现的细节以及这套 DNS 方案可以往哪些方向继续扩展。首先是 DHCP 下发 DNS 的配合问题。如果 ESP32 开的是 AP 模式Arduino 的 WiFi 库默认会启动一个简易 DHCP 服务器如果想让所有连接设备自动把 DNS 指向 ESP32需要在 DHCP 配置里指定 DNS 选项。不同的 ESP32 库方法不同在 ESP-IDF 下可以直接改 DHCP server 的 DNS 参数。如果用的是 ESP32 连接现有路由器STA 模式 AP 模式混合那 AP 下的设备走 ESP32 的 DHCPDNS 指到 ESP32 没问题但 STA 方向自己别把 DNS 覆盖了否则解析链路会乱。然后是端口冲突的雷区。如果 ESP32 既开了 DNS 服务器监听 53又开了 MDNS responder二者不会冲突因为 MDNS 走的是 5353 端口。但要注意如果 ESP32 本身也做了 DNS 客户端比如 HTTP 请求里用域名那它自己发出去的查询不会跑回自己的 53 端口因为代码里是直接通过 WiFi 协议栈发出去不走本地 DNS 服务器。这一点在把 ESP32 当作“DNS 服务器 网关”双重角色时特别容易搞混。再就是性能优化。如果未来想把这个方案用在多设备环境比如展厅同时有二三十台设备在线建议把dnsBuffer从栈数组改成static或堆分配避免每次调用都申请大内存。std::map在运行时反复查找没有问题但最坏时间复杂度是 O(log n)规则表一旦超过几十条查找开销会上升。想追求极致性能可以用排序数组加二分查找或者直接哈希表但说实话在这个量级下没必要。扩展方向我实际尝试过并且觉得好用的有三个。第一个是 DNS 查询日志在handleDnsPacket里把解析出的域名打出来或存到缓冲区就能看到局域网里每台设备在访问什么域名。这个对分析智能家居设备的行为特别有用你会惊讶地发现很多所谓“智能”设备其实在频繁和一堆无关紧要的云服务通信。第二个是结合 Captive Portal 做认证页面实现思路是DNS 劫持所有未认证设备的首次请求让它们无论访问什么域名都跳到本地 HTTP 认证页点击认证按钮后记录 MAC 地址或者 IP后续请求放行。第三个是给规则表加定时任务比如某个域名在特定时间段劫持到本地、其他时间段放走可以用来做基于时间段的域名管控。最后说点个人体会。ESP32 这种设备最迷人的地方就是它可以同时站在“嵌入式”和“网络”两个世界的交汇点上。一个几十块钱的芯片居然能参与到 DNS 协议这种基础网络设施里而且还能跑得挺稳这种体验和在服务器上配 BIND 完全不一样。你会有一种“原来网络协议是可以握在手里的”的实感。我这个项目的初衷只是为了骗过 Windows 的网络状态检测结果事后才发现NCSI 欺骗加 DNS 劫持这套组合其实是一把能打开很多应用场景的钥匙——只要你别把钥匙用在不对的地方它就一直是块宝藏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价