
你是不是也遇到过这种怪事Windows 右下角明明显示“已连接”但浏览器里所有网址都打不开或者反过来你的实验网络其实根本没有外网Windows 却一本正经地告诉你“Internet 访问正常”。这套判断机制背后的主角就是 NCSI。我这次用一片十几块钱的 ESP32 做了一台会“骗人”的 DNS 服务器让 Windows、Android、iOS 全部误以为它连的是真实互联网同时把所有域名悄悄劫持到一个本地页面。这篇文章完整记录了原理拆解、代码实现、抓包验证和踩坑过程适合做 WiFi 安全测试、嵌入式网络开发以及想真正搞懂“设备是怎么判断自己有没有网的”的玩家。1. NCSI 到底是什么为什么骗过它 Windows 就以为网络是通的1.1 NCSI 的四步检测链路NCSI 全称 Network Connectivity Status Indicator也就是 Windows 任务栏里那个“网络连接状态指示器”。Windows 在切换到新网络、恢复休眠、或者网络环境变化时会在后台做一次“主动探测”。这个探测的链路非常清楚一共就四步向固定的探测域名发起 DNS 查询拿到解析结果后向该 IP 的 80 端口发起 HTTP 请求请求一个固定的文本路径校验 HTTP 状态码和返回内容是否精确匹配。只要这四步全部通过Windows 就认定当前网络“有 Internet 访问”任务栏显示正常系统也不会再反复弹出“是否连接到网络”之类的提示。问题在于这套检测机制是完全基于明文 HTTP 和明文 DNS 的它只验证内容不验证来源。谁能抢答正确谁就被 Windows 认为是“真正的互联网”。1.2 不同操作系统怎么判断“有网”不只是 Windows 有这套机制。几乎所有桌面和移动系统都有类似的“强制门户检测”用来判断当前 WiFi 是否需要弹出登录页。我把主流系统的探测目标整理了一张表在后期做兼容时非常有用系统DNS 探测域名HTTP 路径期望返回Windows 7/8dns.msftncsi.com/ncsi.txtMicrosoft NCSIWindows 10/11www.msftconnecttest.com/connecttest.txtMicrosoft Connect TestAndroidconnectivitycheck.gstatic.com/generate_204204 No ContentiOS / macOScaptive.apple.com/hotspot-detect.htmlHTTP 200 且包含 SuccessLinux NetworkManagernmcheck.gnome.org/HTTP 200Windows 10/11 的现代版本主要看重www.msftconnecttest.com这条链路。它先做 DNS 解析得到 IP 后向该 IP 请求/connecttest.txt期望的响应体必须是Microsoft Connect Test这串文本。注意不是“包含”这串文本而是严格匹配。HTTP 状态码必须是 200。1.3 为什么“已连接但无网络”问题里总有它如果你自己搭过路由器或者搞过旁路由十有八九见过这种场面手机连上 WiFi 显示没网但打开一些 App 却能正常刷内容。原因可能是你的 DNS 或代理规则有问题但 NCSI 检测失败也会造成任务栏一直顶着“无 Internet”的提示。这也是 NCSI 最大的特点它会缓存检测结果并且有一定重试周期。你把底层网络改好了任务栏的提示却未必立刻刷新。反过来如果你能让 NCSI 检测永远“成功”设备就会把当前网络当作完全可信的互联网从而放松对后面所有 DNS 解析结果的戒备。这就是 NCSI 欺骗的设计思路和普通网页劫持是两码事。2. 开发环境准备与硬件规划2.1 ESP32 还是 ESP8266我的选型对比虽然标题写的是 ESP但具体用 ESP8266 还是 ESP32我在实际实验里做了一个很直观的对比项目ESP8266ESP32市场价10 元上下20-40 元可用内存约 80KB RAM约 520KB RAMWiFi 稳定性一般需要好一点的电源稳定双核处理并发更从容开发复杂度更简单库更多但代码差别不大适合场景轻量测试钩子完整演示 AP 后续想扩展 HTTPS 服务从纯 DNS 服务器的需求来看ESP8266 完全够用因为 DNS 请求本身非常轻量。但我在测试中发现ESP8266 在同时开 AP 模式、DNS 服务和 Web 服务时如果电源供电质量不好WiFi 会频繁断开重连。ESP32 的抗干扰能力和整体稳定性明显更好所以我建议预算允许就直接上 ESP32。如果家里只有老旧的 ESP8266 也能跑只是要额外注意电源。2.2 开发环境与库的选型我用的是 Arduino 框架。Arduino IDE 和 PlatformIO 都行不过如果你后面想维护代码、加版本管理我更推荐 PlatformIO。为了让 Arduino IDE 识别 ESP32需要在“开发板管理器”里添加 Espressif 官方的包地址https://espressif.github.io/arduino-esp32/package_esp32_index.json添加后搜索esp32 by Espressif Systems安装即可。ESP8266 同理只需在 Arduino IDE 里安装esp8266 by ESP8266 Community。真正干活的库是 Arduino 核心自带的DNSServer.h和WebServer.h不需要额外安装第三方库。这两个库在 ESP8266 里对应的头文件略有差异ESP8266 的 Web 库叫ESP8266WebServer.h注意区分。2.3 网络拓扑AP 模式下的 DNS 链路这个实验的关键在于 ESP 开启的是 SoftAP 模式也就是把自己模拟成无线路由器。设备连上这个 SSID 后ESP 内部的 DHCP 服务会分配 IP 地址、网关地址和 DNS 服务器地址三者都会指向 ESP 自己的局域网 IP通常是192.168.4.1。DNS 默认端口是 53/UDP。所有客户端在访问域名时都会把 DNS 查询发给192.168.4.1而 ESP 上恰好监听了一个 DNS 服务。这就形成了一条天然的“必经之路”。换句话说不需要对客户端做任何手动设置只要它连上了这个 APDNS 流量就会自动经过我们的 ESP。这里要注意客户端如果想手动把 DNS 改成公网 DNS比如 8.8.8.8那么因为 ESP 的 AP 模式本身不做 NAT 转发这个 DNS 查询很可能会超时失败于是设备会表现为“部分网页打不开”。做实验时如果发现劫持不生效优先检查客户端是不是开了“私有 DNS”或者手动 DNS。3. 核心实现在 ESP 上从零搭一台会 NCSI 欺骗和 DNS 劫持的服务器3.1 先用 DNSServer 库跑通全量 DNS 重定向如果只是想让所有域名都解析到192.168.4.1用现成的DNSServer库是三行代码的事#include WiFi.h #include DNSServer.h #include WebServer.h #define AP_SSID Test-Lab-WiFi #define AP_PASS 12345678 #define REDIRECT_IP IPAddress(192, 168, 4, 1) #define DNS_PORT 53 DNSServer dnsServer; WebServer webServer(80); void setup() { WiFi.mode(WIFI_AP); WiFi.softAPConfig(REDIRECT_IP, REDIRECT_IP, IPAddress(255, 255, 255, 0)); WiFi.softAP(AP_SSID, AP_PASS); // 捕获所有域名解析请求统一返回 192.168.4.1 dnsServer.start(DNS_PORT, *, REDIRECT_IP); webServer.begin(); } void loop() { dnsServer.processNextRequest(); webServer.handleClient(); }这段代码的本质是ESP 在 53 端口监听 UDP 包收到任何 DNS 查询后构造一个最小的 DNS 响应把该域名的 A 记录改成192.168.4.1。DNSServer库是 ESP 官方封装好的它会自动处理好 DNS 头、问题段、回答段和事务 ID。*通配符的意思是匹配所有域名。但你会发现一个问题它把所有域名都解析到同一个 IP不够细腻。我想让www.msftconnecttest.com返回本地 IP让example.com指到另一台内网服务器让剩下的域名统一引到登录页。这就需要更精细地自定义 DNS 响应。3.2 自己构造 DNS 响应报文域名级解析分发DNS 协议本身并不复杂。以 A 记录查询为例一次完整的请求和响应包含几个部分DNS 头部事务 ID、标志位、Question 数量、Answer 数量等问题段查询的域名、查询类型 A、查询类 IN回答段域名指针、类型 A、类 IN、TTL、IP 地址。我写了一个不依赖DNSServer的 UDP 处理函数这样就能在代码里按域名做任意分发#include WiFi.h #include WiFiUdp.h WiFiUDP udp; uint8_t dnsBuffer[512]; IPAddress apIP(192, 168, 4, 1); void handleDNSRequest() { int len udp.parsePacket(); if (len 0) return; udp.read(dnsBuffer, sizeof(dnsBuffer)); // 从第 12 字节开始解析查询域名 int qnameStart 12; int pos qnameStart; String domain; while (pos len dnsBuffer[pos] ! 0) { int labelLen dnsBuffer[pos]; domain String((char*)(dnsBuffer pos 1), labelLen); domain .; pos labelLen 1; } if (domain.length() 0 domain.endsWith(.)) { domain.remove(domain.length() - 1); } IPAddress answerIP apIP; bool shouldAnswer true; // 按域名做差异化分发 if (domain example.com) { answerIP IPAddress(192, 168, 4, 2); } else if (domain captive.apple.com) { answerIP apIP; } else { answerIP apIP; } // 构造 DNS 响应 uint8_t resp[512]; int respLen 0; // 事务 ID 原样返回 resp[respLen] dnsBuffer[0]; resp[respLen] dnsBuffer[1]; // 标志位0x8180 表示标准响应、递归可用 resp[respLen] 0x81; resp[respLen] 0x80; // QDCOUNT 1 resp[respLen] 0x00; resp[respLen] 0x01; // ANCOUNT shouldAnswer ? 1 : 0 resp[respLen] 0x00; resp[respLen] shouldAnswer ? 0x01 : 0x00; // NSCOUNT 0 resp[respLen] 0x00; resp[respLen] 0x00; // ARCOUNT 0 resp[respLen] 0x00; resp[respLen] 0x00; // 回写问题段。这里把原始查询里的域名和类型、类都原样抄回去 for (int i qnameStart; i len dnsBuffer[i] ! 0; i) { resp[respLen] dnsBuffer[i]; } resp[respLen] 0; if (len - qnameStart 4) { resp[respLen] dnsBuffer[len - 4]; resp[respLen] dnsBuffer[len - 3]; resp[respLen] dnsBuffer[len - 2]; resp[respLen] dnsBuffer[len - 1]; } // 回答段名称使用压缩指针 0xC00C指向问题段的域名开头 if (shouldAnswer) { resp[respLen] 0xC0; resp[respLen] 0x0C; // TYPE A resp[respLen] 0x00; resp[respLen] 0x01; // CLASS IN resp[respLen] 0x00; resp[respLen] 0x01; // TTL 60 秒设短一点方便调试 resp[respLen] 0x00; resp[respLen] 0x00; resp[respLen] 0x00; resp[respLen] 0x3C; // RDLENGTH 4 resp[respLen] 0x00; resp[respLen] 0x04; // RDATA IP resp[respLen] answerIP[0]; resp[respLen] answerIP[1]; resp[respLen] answerIP[2]; resp[respLen] answerIP[3]; udp.beginPacket(udp.remoteIP(), udp.remotePort()); udp.write(resp, respLen); udp.endPacket(); } }每次processNextRequest()就等价于执行一次完整的 DNS 服务。你可以在loop()里调用void setup() { ... udp.begin(53); ... } void loop() { handleDNSRequest(); webServer.handleClient(); }这套自定义方案的优点有三个首先可以按域名配置不同的目标 IP而不是一刀切其次对不需要解析的域名可以直接不响应或返回错误码最后也是最重要的它逼迫你去理解 DNS 协议体的每一个字段比起无脑调库更能应对后面的疑难杂症。我在实际测试中就用这套逻辑把msftconnecttest.com、connectivitycheck.gstatic.com等探测域名解析到 ESP把用户真正访问的普通域名也解析到 ESP但根据来源 IP 不同Web 服务器可以展现不同的页面。3.3 NCSI 欺骗的关键HTTP 返回内容必须精确匹配如果只是把www.msftconnecttest.com解析到本地 IP 还不够Windows 发起 NCSI 探测时还会请求http://www.msftconnecttest.com/connecttest.txt。浏览器拿到页面后Windows 不会管页面长得多好看只会检查响应状态码和响应体。因此在 WebServer 里必须把这几个路径单独处理void setupPortalPages() { // Windows 10/11 的 NCSI 探测路径 webServer.on(/connecttest.txt, []() { webServer.send(200, text/plain, Microsoft Connect Test); }); // 老版本 Windows 的探测路径 webServer.on(/ncsi.txt, []() { webServer.send(200, text/plain, Microsoft NCSI); }); // Android 的探测路径返回 204 即可 webServer.on(/generate_204, []() { webServer.send(204, text/plain, ); }); // Apple 设备的探测路径 webServer.on(/hotspot-detect.html, []() { webServer.send(200, text/html, HTMLHEADTITLESuccess/TITLE/HEADBODYSuccess/BODY/HTML); }); // 普通用户访问的默认登录页 webServer.on(/, []() { webServer.send(200, text/html, !DOCTYPE htmlhtmlheadtitlePortal/title/head bodyh1Test-Lab-WiFi 内网提示/h1 p欢迎来到实验 APDNS 劫持已生效。/p/body/html); }); }这里有个容易踩的陷阱webServer.send()第三个参数是响应体但 Windows NCSI 对响应体是“精确匹配”。有的教程会写成Microsoft Connect Test\r\n或者带 BOM这可能导致 Windows 校验失败。实际测试中我遇到了好几次类似问题最后严格按官方返回文本才通过。如果你不确定可以在 PC 上用curl http://192.168.4.1/connecttest.txt直接看响应体。3.4 完整主程序如何组织真正烧到 ESP32 里时把 DNS 服务和 HTTP 服务合并在一起主程序结构大概是这样的#include WiFi.h #include WiFiUdp.h #include WebServer.h #define AP_SSID Test-Lab-WiFi #define AP_PASS 12345678 IPAddress apIP(192, 168, 4, 1); WiFiUDP udp; WebServer webServer(80); void handleDNSRequest() { // 将上一节的函数体搬到这里 } void setupPortalPages() { // 将上一节的路径配置搬到这里 } void setup() { Serial.begin(115200); WiFi.mode(WIFI_AP); WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); WiFi.softAP(AP_SSID, AP_PASS); udp.begin(53); setupPortalPages(); webServer.begin(); Serial.println(AP started: String(AP_SSID)); Serial.print(AP IP: ); Serial.println(WiFi.softAPIP()); } void loop() { handleDNSRequest(); webServer.handleClient(); }这套代码跑起来后设备连上Test-Lab-WiFi任何域名都会被解析到192.168.4.1。随后浏览器访问任意站点时WebServer 会优先匹配/connecttest.txt、/generate_204这类探测路径剩下的归默认登录页面。我在 Windows、Android、iPhone 上都做了验证下面展开讲实测结果。4. 实测过程从 flushdns 到强制跳转再到抓包验证4.1 测试环境与准备测试时需要一台安装 Windows 10/11 的笔记本最好提前关掉系统代理、关闭虚拟专用网络客户端避免干扰。因为我们要直接看 DNS 响应所以还需要 Wireshark。手机端的验证我用了 Android 和 iPhone 各一台。烧录完成后先确认 ESP 的串口日志里打出了AP started: Test-Lab-WiFi再用笔记本连接这个热点。连接成功后在命令行里执行以下命令按顺序检查ipconfig /all重点看无线网卡那一栏确认 DHCP 分配的 DNS Server 是192.168.4.1。如果发现 DNS Server 不是这个地址说明客户端用了本地缓存或手动配置先处理掉。接着刷新 Windows 的 DNS 缓存ipconfig /flushdns nslookup www.msftconnecttest.com正常情况下nslookup返回的 Address 应该是192.168.4.1。这就是 DNS 劫持在系统层面的直观效果。4.2 Windows 侧的表现NCSI 欺骗生效然后测试 NCSI 欺骗是否生效。直接在浏览器地址栏输入http://www.msftconnecttest.com/connecttest.txt看到网页输出Microsoft Connect Test说明 HTTP 响应已经命中。也可以换终端验证curl http://192.168.4.1/connecttest.txt此时再随便打开一个网站比如http://example.com会看到我定义的默认登录页。Windows 右下角任务栏从“无 Internet”变成了“已连接”状态通常需要等待几秒到十几秒因为 NCSI 检测有缓存和重试间隔。这个过程恰好说明只要探测链路全部返回预期内容Windows 根本不会发现这是个假互联网。为了让验证更严谨我用 Wireshark 抓了一次完整交互关键过滤表达式如下udp.port 53 http.host www.msftconnecttest.com抓包能看到三个阶段系统发出www.msftconnecttest.com的 DNS 查询ESP 回复 A 记录192.168.4.1TTL 60 秒系统向192.168.4.1发起GET /connecttest.txt服务器返回 200 和正确文本。4.3 Android 和 iOS 上的表现Android 设备连上 AP 后系统会优先请求connectivitycheck.gstatic.com下的/generate_204。这个路径在 WebServer 里返回 204所以 Android 会认为“网络已连接且无需登录”。此时打开任意网页同样会被劫持到本地登录页。注意一点Android 10 以上默认开启“私人 DNS”如果用户手动设置了私人 DNS那么所有 DNS 查询会走加密通道就不经过 ESP劫持会失效。做测试时要在“设置 - 网络和互联网 - 私人 DNS”里选择“关闭”。iOS 的表现和 Android 类似它的探测域名是captive.apple.com。iOS 判断网络正常后WiFi 图标不会消失流量也会正常进入 ESP。iPhone 对 HTTP 服务的处理比较严格如果你的登录页有证书错误或者 HTTP 状态码不规范Safari 可能直接报错因此我在 Apple 的探测路径里严格返回了Success文本。我把整个验证结果整理成了表格检查项期望结果实际结果DHCP 分配的 DNS192.168.4.1通过nslookup msftconnecttest.com192.168.4.1通过curl connecttest.txtMicrosoft Connect Test通过浏览器访问 example.com本地登录页通过Android 图标正常联网通过iOS 图标正常联网通过5. 半小时踩坑汇总你以为成功了其实还有四个坑5.1 IPv6 漏检导致任务栏一直在“无 Internet”第一个坑不是 DNS而是 IPv6。Windows 的 NCSI 探测不仅会走 IPv4还会尝试 IPv6。如果你的 ESP 开启了 IPv6 地址通告或者 AP 环境下系统自动获得了 IPv6 地址Windows 可能直接用 IPv6 去连接微软服务器而 ESP 上根本没有 IPv6 监听于是探测超时失败。我的解决办法是彻底关闭 AP 的 IPv6 功能不去宣告 IPv6 路由前缀。对于默认的 ESP32 SoftAP 来说只要你不主动启用 IPv6基本不会出问题。但我之前在一个带 IPv6 的开发板上折腾了很久最后发现是 IPv6 DNS 解析绕过了劫持直接把问题定位在 IPv6 上会快很多。5.2 DNS 缓存和浏览器预连接造成的假性失败第二个坑是缓存。调试时最迷惑的现象是ESP 日志里明明收到了 DNS 请求也正确回应了但你手机浏览器打开的依然是真实网站。原因通常是设备或浏览器缓存了旧的 DNS 结果。比如你之前连接过正常的 WiFi系统里仍然保留着example.com的本地缓存。解决方法是把所有测试设备的 WiFi 断开重连清空 DNS 缓存。Windows 用ipconfig /flushdnsAndroid 可以切飞行模式再切回来iOS 则是重新连接 WiFi。调试阶段我把 DNS 响应的 TTL 设置成 60 秒这样最多等一分钟缓存就会过期。5.3 私有 DNS / DoH 让劫持“时灵时不灵”第三个坑是加密 DNS。现代操作系统越来越喜欢把 DNS 查询升级为 DoHDNS over HTTPS或者 DoTDNS over TLS。如果你在 Chrome 或 Android 里开启了安全 DNS查询不会发给192.168.4.1的 53 端口而是直接发给 Cloudflare 或 Google 的 HTTPS DNS 服务ESP 根本拦截不到。测试时要在客户端关闭所有安全 DNS 选项。我自己的做法是准备了一台专门的测试手机关掉私人 DNS、关闭自动选择网络、关闭“自动填充 WiFi 认证信息”最大限度减少干扰项。5.4 供电不稳导致 ESP 在验证中途崩掉第四个坑比较硬件向。ESP32 在 AP 模式 WebServer DNS 同时工作时峰值电流会比纯 Station 模式高不少。我最初用电脑 USB 口供电结果跑几分钟后 ESP 掉线重启导致验证到一半所有客户端瞬间断网。后来换了 5V/2A 的独立电源问题消失。如果你用 ESP8266这个问题更需要注意它的片上稳压器在持续 AP 模式下发热明显有条件的话给模块加一个 10V 1000uF 的电解电容靠近 3.3V 引脚做电源缓冲。6. 使用边界、伦理与防御建议6.1 这个实验的合法性边界能做出这个东西不代表就可以随便拿它去连公共 WiFi 或者别人的设备做测试。DNS 劫持在未授权的情况下是明确越界行为轻则违反计算机信息系统安全相关条例重则构成犯罪。我这里的所有验证都是在自建的实验室热点下完成的连入的设备也都是本人或团队授权的测试机。如果你是在公司做无线安全评估务必先拿到书面授权并严格限定测试范围和时间窗口。不要用这个方式去收集他人密码、伪造登录页、诱导输入敏感信息。技术在安全测试里是中立工具使用场景和边界才是决定它性质的关键。6.2 作为防守方如何加固自己的网络反过来这个实验也给了我一套很实用的防御思路。DNS 劫持之所以能生效是因为大量设备默认信任网关下发的 DNS并接受明文 DNS 响应。防御的第一道防线是开启加密 DNSAndroid 手机设置“私人 DNS”为dns.google或cloudflare-dns.comWindows 可以在“设置 - 网络和 Internet - 以太网/无线网 - DNS 服务器分配”里手动指定 DNS并开启 DoH公司内部网络可以部署 802.1X 认证从接入层限制未知 AP企业级无线网络建议开启 WIDS/WIPS检测异常 DNS 响应和私设 AP。从系统层面看Windows 也支持通过组策略关闭 NCSI 主动探测。注册表路径是HKLM\SOFTWARE\Policies\Microsoft\Windows\NetworkConnectivityStatusIndicator新建 DWORD 值NoActiveProbe设置为 1可以关闭主动探测。但我不建议普通用户这么做NCSI 本身是诊断网络问题的重要机制关闭后你反而更难判断网络是否真的可用。6.3 我的一点个人体会整个项目做下来我最大的收获反而不是“ESP 能当 DNS 服务器”这个结论而是明白了操作系统判断网络的方式原来如此简单粗暴一次明文 DNS 查询加一次明文 HTTP 请求。很多像我一样折腾网络的人遇到“设备显示有网但实际没网”或者反过来“设备显示没网但实际能通”的情况第一反应就是重启路由器或者翻系统设置。现在我遇到类似问题会先抓一次 NCSI 探测相关的包看看这几个探测域名返回了什么内容往往几十秒就能定位问题根源。这个思路我在排查家里智能家居设备的联网问题、公司办公网的准入问题、甚至单台电脑的断网问题时都用得上。至少现在我不用再靠反复重启路由器来猜问题了。