很多玩ESP32的朋友,都把它当传感器采集器或者“点灯神器”——接个DHT11测温度,点个WS2812灯带,做个智能开关也就到头了。但你有没有想过,这块板子其实还能干一件相当硬核的网络活:当一台完整的DNS服务器?我最近就在一个局域网实验环境里,用ESP32搭了一台能用的DNS服务器,顺手实现了NCSI欺骗和DNS劫持。听到“DNS劫持”先别皱眉,这套技术在合法的网络工程场景里特别常见,比如公共WiFi的强制门户(Captive Portal)、局域网广告拦截、IoT设备固件升级地址重定向等,都属于这类技术的正当用法。
这套方案解决的问题很具体:当你需要一台成本极低、即插即用的DNS控制设备时,完全不需要翻出树莓派甚至一台旧电脑,一个ESP32就搞定了。它的功耗很低,体积也就口香糖大小,却能完整实现市面上商业级强制门户设备的基础功能。如果你做嵌入式、做网络调试、或者做智能家居本地化控制,这篇文章会从原理到代码、再到我踩过的坑,把整套实现完整拆给你看,跟着操作你也能复现。
1. 从“转发DNS”到“造假DNS”:这项目到底在干什么
1.1 为什么ESP32能当DNS服务器
先说一个容易被忽略的事实:DNS服务器本质上就是一个监听UDP 53端口、按固定报文格式收发数据的服务程序。它不挑硬件,只要能跑TCP/IP协议栈,就能实现。ESP32本身带完整的WiFi协议栈,又跑得动Arduino或者ESP-IDF,所以做DNS服务器完全在能力范围内。
我见过不少人以为DNS服务器一定要用BIND、PowerDNS这类重量级软件跑在Linux主机上。其实对于局域网这种规模,DNS查询压力很小,一个ESP32处理几十台设备的域名解析绰绰有余。关键在于它能不能监听53端口、能不能快速构造和解析DNS报文——这两个问题在Arduino生态里都有现成方案。
这里需要解释一下,真正的互联网DNS解析链路是分层的:设备把域名请求发给路由器(或本地指定的DNS),路由器再转发给运营商DNS,运营商DNS向上级递归查询,最终返回解析结果。ESP32做局域网DNS服务器的时候,它并不需要去递归查询整个互联网,而是根据自己的规则直接给出结果。这就引出了“DNS劫持”的雏形:当它接管了设备的域名解析请求后,它说这个域名指向哪个IP,设备就会去访问哪个IP。
1.2 “DNS劫持”不一定是坏事:几种正当用途
我理解很多人一听“劫持”就想到流量被劫、隐私泄露。但在网络工程里,DNS劫持是一种常见且受控的手段,核心在于“由谁劫持、劫持后交给谁”。在ESP32这个项目里,劫持通常发生在你自己创建的局域网环境内,目的是实现下面三种需求:
第一种是强制门户认证。公共WiFi的运营者希望用户连接后先看到一个认证页面,输入手机号、扫码或点击“同意上网协议”,然后才能访问外网。实现方式就是让ESP32在WiFi网络里充当DNS服务器,把用户设备的所有域名解析都指向ESP32自己的IP,而ESP32上的Web服务返回一个认证页面,完成认证后再放行或跳转。
第二种是广告域名拦截。家庭路由器里常见的“广告拦截”功能,本质就是DNS层面的黑名单劫持。ESP32维护一份广告域名列表,如果DNS请求命中了列表里的域名,就返回一个无效IP(比如0.0.0.0),让设备找不到服务器,从而达到看不到广告的效果。
第三种是本地开发调试。开发者在做App或Web测试时,可能需要把某个正式域名(比如 api.example.com)解析到本地测试服务器(比如192.168.1.100),这时候一台随时可控的DNS劫持设备就比手动改每台设备的hosts文件省事得多。ESP32刚好能随身携带,插上电就是一台独立DNS,不需要依赖电脑环境。
这三种场景都说明了一个问题:DNS劫持本身是中性的,关键在于用途是否合法合规。
2. 原理拆解:NCSI检测与DNS劫持的完整链路
2.1 NCSI到底是个什么东西
NCSI的全称是Network Connectivity Status Indicator,网络连通性状态指示器。这是Windows 8以后引入的一套主动探测机制,用于判断当前网络是否真正能访问互联网。为什么需要它?因为仅仅把网线插上、连上WiFi,不代表你就有互联网访问能力——路由器可能没拨号成功、运营商网络可能故障、公共WiFi可能需要先认证。
所以Windows的做法是:连上网络后,定期向微软的一个固定URL发起HTTP GET请求,如果能拿到预期响应,系统就认为“网络已连接、可以上Internet”;如果拿到的是重定向、超时或错误内容,系统就会判断为“当前网络无法访问Internet,可能处于强制门户状态”。
类似的机制其实各家厂商都有,只不过域名和探测接口不一样。我之前整理过一个表,做这个项目的时候特别有用:
| 系统/平台 | 探测URL | 期望响应 |
|---|---|---|
| Windows 8+ | http://www.msftconnecttest.com/connecttest.txt | HTTP 200,内容为“Microsoft Connect Test” |
| Windows (旧版/其他) | http://www.msftncsi.com/ncsi.txt | HTTP 200,内容为“Microsoft NCSI” |
| Android | http://connectivitycheck.gstatic.com/generate_204 | HTTP 204 No Content |
| iOS / macOS | http://captive.apple.com/hotspot-detect.html | HTTP 200,内容为“Success” |
| Linux (NetworkManager) | http://nmcheck.gnome.org/check_network_status.txt | HTTP 200,任意文本内容 |
理解了这张表,NCSI欺骗的原理就清楚了:当设备的系统向这些探测URL发起请求时,ESP32的Web服务返回它期望的响应,系统就会认为“我已经正常连上互联网了”。这就是“欺骗”的实质——不是去黑掉什么,而是在局域网里模拟出一个正常的网络连通状态。
2.2 两种工作模式:让系统认为“已上网”还是“需认证”
在做这个项目的时候,我意识到NCSI欺骗有两种完全相反的应用方向,取决于你想让设备表现成什么状态。
第一种模式是让系统认为“已连接互联网”。方法是完全返回上表中的期望响应,设备会显示WiFi已连接且能正常上网,用户不会有任何弹窗。这种模式很适合广告拦截、透明监控和固件重定向场景,因为用户无感知,但流量又受到ESP32的控制。
第二种模式是让系统认为“这是一个强制门户”。方法是刻意不返回期望响应,而是返回HTTP 302重定向到ESP32上的认证页面。系统检测到重定向后,就会自动弹出浏览器并访问那个认证页面,这正是公共WiFi想要的效果。
这个二选一的设计,是整个项目里最核心的思路。我当时调试的时候,就为这个问题纠结了好一会儿:一开始只做了“欺骗成功”的版本,结果手机连上后系统提示“互联网可用”,根本不弹认证页面,完全不是公共WiFi的体验。后来增加了重定向分支,才真正做出强制门户的效果。
2.3 完整链路:从设备连接WiFi到浏览器弹出认证页
为了方便你理解整个过程,我以强制门户模式为例,把一次完整的访问链路拆成六个步骤:
第一步,设备(手机或笔记本)连接上ESP32创建的WiFi热点。第二步,系统自动发起NCSI探测,比如Windows会去请求http://www.msftconnecttest.com/connecttest.txt。第三步,系统需要先解析这个域名,于是向DHCP分配的DNS服务器(也就是ESP32自己)发出一条DNS查询请求。第四步,ESP32的DNS服务收到查询后,把该域名解析为ESP32自身的IP(比如192.168.4.1)并返回给设备。第五步,设备根据这个IP发起HTTP请求,访问ESP32上的Web服务。第六步,Web服务识别到这个请求是NCSI探测请求,返回302重定向到认证页面;设备浏览器收到重定向后自动打开认证页面。
这六步几乎是在几秒内完成的。如果只想要“广告拦截”效果,只需要在第四步把广告域名解析为0.0.0.0即可;如果只想要“无感DNS控制”,在第六步正常返回200响应即可。
3. 硬件选型与环境搭建
3.1 ESP32还是ESP8266
我建议能用ESP32就用ESP32。原因很直接:ESP32的内存和处理能力比ESP8266充裕得多,跑DNS服务加Web服务器的同时,还能轻松处理日志输出或维护几个TCP连接。ESP8266也不是不行,但它的RAM只有160KB左右可用,而且WiFi协议栈本身就占了不少,在并发DNS请求多的时候容易丢包。
具体到开发板型号,我用的是一块最普通的ESP32 DevKitC,板载4MB Flash,够用了。如果你要长期插电运行,也可以选带金属外壳的工业级模组,但逻辑代码完全一样。
这次项目里我对开发板没有特殊要求,连外部天线都不需要,板载PCB天线在短距离测试环境下信号足够稳定。如果是在复杂电磁环境中做设备,再考虑外接天线也不迟。
3.2 开发环境准备
开发环境方面,我选择的是Arduino IDE,因为生态成熟、库丰富,新手也容易上手。需要安装ESP32开发板支持包,在Arduino IDE的“开发板管理器”里搜索esp32,选装乐鑫官方的esp32 by Espressif Systems即可。版本号建议选新一点的稳定版,我测试时用的是2.0.x系列。
代码里依赖两个关键的库:WiFi.h和DNSServer.h。其中DNSServer.h是Arduino生态里一个轻量级的DNS服务器库,专门用来实现DNS解析和劫持功能,API很简洁。Web服务部分使用的是WebServer.h,是ESP32核心库自带的HTTP服务库。这三个库都不用单独下载,安装ESP32支持包后就有了,省了很多麻烦。
3.3 网络规划细节
做这个项目前,建议先想清楚网络地址的规划。ESP32在AP模式下,默认网段是192.168.4.0/24,网关和自身IP都是192.168.4.1。这个网段是乐鑫固件预设的,除非你手动调用softAPConfig()修改,否则保持默认就行。
端口规划上看,DNS服务固定使用UDP/TCP 53端口,Web服务使用HTTP 80端口。设备在连接WiFi后,DHCP服务会下发网关和DNS地址,其中DNS地址就是192.168.4.1。设备发起域名解析时,请求自然会到达ESP32的53端口。
还有一个细节值得注意:IP地址租约时间。默认DHCP租约可能是比较长的,如果设备之前连接过这个热点,后续重连时可能仍缓存着上一次的DNS配置。测试时建议在手机或电脑上先“忘记网络”,再重新连接,否则可能出现诡异的“连上了但DNS还是旧的”问题。
4. 动手实现:核心代码与NCSI欺骗的落地
4.1 工程整体结构
代码整体上分三层:WiFi配置层、DNS服务层、Web响应层。WiFi配置层负责把ESP32设置为AP模式并分配IP;DNS服务层接收所有域名解析请求并返回指定IP;Web响应层处理NCSI探测返回的响应结果以及认证页面的跳转逻辑。
下面是完整的示例代码,基于Arduino框架:
#include <WiFi.h> #include <DNSServer.h> #include <WebServer.h> // ========== WiFi AP 配置 ========== const char* ssid = "ESP_DNS_Lab"; const char* password = "12345678"; const IPAddress apIP(192, 168, 4, 1); const IPAddress netmask(255, 255, 255, 0); // ========== DNS 配置 ========== DNSServer dnsServer; const byte DNS_PORT = 53; // ========== Web 服务配置 ========== WebServer webServer(80); // 是否开启“强制门户”模式 // true : NCSI 返回302重定向,系统弹出认证页面 // false: NCSI 返回期望响应,系统认为已连接互联网 bool captivePortalMode = true; void setup() { Serial.begin(115200); delay(500); // —— 第一步:启动AP模式 —— WiFi.mode(WIFI_AP); WiFi.softAPConfig(apIP, apIP, netmask); WiFi.softAP(ssid, password); Serial.println("AP started, IP: " + WiFi.softAPIP().toString()); // —— 第二步:启动DNS服务,所有域名全部解析到ESP32自身 —— bool dnsStarted = dnsServer.start(DNS_PORT, "*", apIP); if (dnsStarted) { Serial.println("DNS server started on port 53"); } else { Serial.println("DNS server start FAILED"); } // —— 第三步:注册NCSI探测响应路由 —— // Windows 8+ 测试:http://www.msftconnecttest.com/connecttest.txt webServer.on("/connecttest.txt", HTTP_GET, []() { if (captivePortalMode) { // 强制门户模式:重定向到认证页面 webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/plain", ""); } else { // 欺骗成功模式:返回Windows期望内容 webServer.send(200, "text/plain", "Microsoft Connect Test"); } }); // Windows 旧版探测:http://www.msftncsi.com/ncsi.txt webServer.on("/ncsi.txt", HTTP_GET, []() { if (captivePortalMode) { webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/plain", ""); } else { webServer.send(200, "text/plain", "Microsoft NCSI"); } }); // Android 探测:http://connectivitycheck.gstatic.com/generate_204 webServer.on("/generate_204", HTTP_GET, []() { if (captivePortalMode) { webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/plain", ""); } else { webServer.send(204, "text/plain", ""); } }); // iOS / macOS 探测:http://captive.apple.com/hotspot-detect.html webServer.on("/hotspot-detect.html", HTTP_GET, []() { if (captivePortalMode) { webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/plain", ""); } else { webServer.send(200, "text/html", "Success"); } }); // Linux NetworkManager 探测 webServer.on("/check_network_status.txt", HTTP_GET, []() { if (captivePortalMode) { webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/plain", ""); } else { webServer.send(200, "text/plain", "OK"); } }); // —— 第四步:认证页面 + 兜底路由 —— webServer.on("/", HTTP_GET, []() { String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"; html += "<title>WiFi 认证</title></head><body>"; html += "<h2>欢迎使用ESP32强制门户</h2>"; html += "<p>这是ESP32上部署的一个简单的认证页面。</p>"; html += "</body></html>"; webServer.send(200, "text/html", html); }); // 其他任意路径:如果captivePortalMode开启,统一重定向到首页 webServer.onNotFound([]() { if (captivePortalMode) { webServer.sendHeader("Location", "http://192.168.4.1/", true); webServer.send(302, "text/html", ""); } else { webServer.send(404, "text/plain", "Not Found"); } }); webServer.begin(); Serial.println("HTTP server started"); } void loop() { dnsServer.processNextRequest(); webServer.handleClient(); }4.2 这段代码的关键点解读
dnsServer.start(DNS_PORT, "*", apIP)这行有两个参数值得注意。中间的"*"表示通配符,也就是所有域名都解析到apIP。如果你只想劫持特定的几个域名,可以写一个域名列表,比如"www.msftconnecttest.com"或"*.gstatic.com",或者自己写条件判断。通配符方案适合“强制门户”这种需要把所有访问都导到认证页的场景。
webServer.onNotFound()这个回调函数很关键。当设备访问一个ESP32上没有注册的路径时,会进入这个函数。在强制门户模式下,我把它统一设成了302跳转到首页,这样即使用户手动输入了任意网址,也会被拉回认证页面。这是强制门户最核心的交互逻辑。
关于captivePortalMode这个布尔变量,它相当于一个模式开关。测试的时候我先把它设为true,验证强制门户弹窗;然后改为false,验证“无感NCSI欺骗”。实际项目里,你完全可以把判断逻辑写得更细一点,比如结合GPIO引脚状态来切换模式。
4.3 自己解析DNS报文的一种思路
可能有人会问:不用DNSServer.h库,自己写DNS解析逻辑行不行?当然行,但没必要重复造轮子,不过理解DNS报文的基本结构对你排错很有帮助。
DNS查询报文的结构很简单:开头是一个12字节的头部(Header),包含事务ID、标志字段、问题计数等;紧接着是问题段(Question),里面是以长度前缀方式编码的域名(比如www对应的十六进制是03 77 77 77),然后是查询类型(A记录是00 01)和查询类别(IN类,也是00 01)。
响应报文需要在头部回填相同的事务ID,设置QR=1标志,然后把问题段原样复制回去,再追加回答段——回答段里包含域名指针、类型、TTL和IPv4地址。DNSServer.h帮你把这些细节都封装好了,但它内部实现逻辑就是这样,你在排查“为什么某个域名解析不对”时,可以从这个结构入手去思考。
4.4 关于UDP端口监听的调试技巧
DNS服务默认走UDP 53端口。UDP和TCP不一样,它没有握手过程,所以抓包调试的时候不像TCP那么容易定位问题。我建议调试时用processNextRequest()的日志输出,在解析到请求时打印出域名和返回的IP,这样至少能确认“DNS请求到底有没有到达ESP32”。
如果发现设备一直不发起DNS请求,先检查设备的网络配置——确保它拿到的是192.168.4.1作为DNS服务器地址。有些系统为了节省流量,会缓存或使用DoH(DNS over HTTPS),这时本地DNS劫持就不起作用了。安卓新版本默认可能使用私有DNS或DoH,测试时应关闭“私人DNS”选项。
5. 实测效果与功能验证
5.1 验证方法:从浏览器到命令行
代码烧录完后,我把电脑和手机分别连接ESP_DNS_Lab这个热点,开始验证。
第一步是验证基本连通性。在Windows电脑上打开命令提示符,执行ipconfig,查看网关和DNS地址是否都是192.168.4.1。如果是,说明ESP32的DHCP下发配置正确。
第二步是验证DNS劫持是否生效。在命令行执行nslookup www.msftconnecttest.com,正常情况下会看到解析结果指向192.168.4.1。如果看到的是外部IP,说明设备没有把DNS请求发给ESP32,或者走了其他DNS通道。
第三步是验证NCSI欺骗的效果。在captivePortalMode = true的模式下,连接WiFi后Windows系统会自动弹出一个浏览器窗口,显示ESP32上的认证页面。在Android手机上连接热点后,系统状态栏可能短暂出现“需要登录网络”的提示,点击提示后也能打开认证页面。
在captivePortalMode = false的模式下,连接WiFi后系统直接显示“已连接,无互联网保护警告”,且不弹任何页面——看起来和普通WiFi完全一样。这正好验证了NCSI欺骗的两种效果。
5.2 用抓包工具验证DNS响应
如果你想看得更细,可以使用Wireshark在电脑上抓包(需要在电脑上做一个局域网热点,或者用路由器做中间网络,ESP32连接到同一交换机并作为旁路DNS)。不过我建议简单一点:直接用nslookup和浏览器效果来做判断,成本最低。
我在测试时遇到过一个问题:浏览器里输入http://www.example.com,结果一直没有跳转到认证页面。排查后发现是浏览器启用了DNS缓存,正在使用之前缓存的IP地址。解决办法是清空浏览器缓存或换一个没访问过的域名测试。这也解释了为什么强制门户的通用实践中,系统NCSI探测用的域名往往不在本地缓存里,所以能顺利触发重定向。
6. 踩坑记录与常见问题排查
6.1 DNS服务器启动失败,53端口被占用
偶尔会遇到dnsServer.start()返回false的情况。常见原因是某些库或功能也占用了53端口,或者之前的代码没有正确释放资源。我在第一次测试时,因为之前跑过另一个网络相关程序没有复位,就出现过端口占用。解决办法是复位ESP32并重新烧录,或者检查是否有其他服务监听了同一个端口。
6.2 手机连上热点后不弹认证页面
这是最常见的坑。原因可能是手机存在私有DNS(DoH)或者系统NCSI探测超时时间较长。我实测下来,Android 10以上的设备默认启用私人DNS时,会绕过本地DNS服务器,导致劫持失效。排查步骤如下:进入WiFi设置,长按已连接的网络名称,找到“私人DNS”或“DNS设置”,改为“关闭”或“自动”。iPhone上相对好一些,它使用系统默认行为,通常都能触发强制门户弹出。
6.3 HTTPS网站无法被重定向
很多刚接触这个项目的人会问:为什么访问https://www.google.com时,没有跳转到认证页面?原因很简单:HTTPS本身是不可被中间人修改内容的,ESP32返回一个302重定向,浏览器只有在发起HTTP明文请求时才能遵循这个重定向;对于HTTPS请求,浏览器会尝试建立TLS握手,而ESP32上的8080/80端口根本不会响应443端口的TLS握手,最终表现为“网页无法访问”。
解决思路是:既然DNS已经把域名解析到了ESP32,那HTTPS请求到了ESP32的443端口,ESP32需要能处理TLS握手。但这需要ESP32上存放证书私钥,并且要提前在设备上安装CA证书才可以做中间人。这在公共WiFi环境里不可能做到,所以实际工程中强制门户对HTTPS流量通常是“阻断”而非“重定向”的——反而是用户在访问任何一个HTTP站点时(比如很多新闻网站仍然支持纯HTTP),重定向才会触发。
6.4 广告拦截模式如何设置黑名单
如果你想把这套系统做成广告拦截器,切入思路是在DNS服务层做判断。DNSServer.h的默认实现是直接对域名做通配符解析,如果你要加黑名单,可以修改它的处理逻辑,或者在WebServer里加一套规则判断。但更简洁的做法是直接替换DNS库的解析回调:当收到DNS请求后,先检查域名是否在黑名单里,如果在就返回0.0.0.0,否则返回apIP。黑名单可以用数组或SPIFFS文件存放,我测试时直接把广告域名写死在代码里,思路最简单。
6.5 多设备并发访问时DNS响应变慢
ESP32处理DNS请求是单线程轮询机制,在设备很少(3~5台)的情况下,实测响应时间在几十毫秒内,感受不到延迟。但如果接入设备超过10台,且并发DNS请求比较密集,可能出现偶尔丢包或响应变慢的情况。优化方向有几个:一是关闭WiFi省电模式,二是把DNS报文缓存放在内存的RingBuffer里,减少处理时间;三是在代码循环里尽可能减少阻塞操作(比如避免在loop()中调用长延时的日志打印)。实测下来,关闭省电模式能显著改善响应稳定性。
6.6 关于DNS TTL设置的经验
DNSServer.h库返回记录时默认TTL是60秒,这个值对调试和有变化需求的场景很友好。如果是商业环境,你可以把它调大一些(比如300秒或600秒),减轻DNS服务器的查询压力。但如果你经常更换ESP32的IP地址,TTL太大会导致设备继续沿用旧的IP,反而增加排错难度。所以我建议开发测试期保持60秒即可,不要过度优化。
7. 实际运行中的一点心得
最后分享几条我在这次项目中总结出来的经验。如果你想要一个随时能用的环境,建议直接做成一个“双ESP32”方案:一个ESP32做入口网关和DNS服务器,另一个ESP32做认证页面Web服务器,两个通过局域网通信。这样职责分离,便于扩展和调试。如果你只需要单一设备完成所有功能,那代码里一次setup()就能全部跑起来,完全够用。
关于供电稳定性,DNS服务器这类常驻服务建议用5V/2A的电源适配器给ESP32供电,不要用电脑USB口或劣质充电宝,因为WiFi发射瞬间的电流波动经常导致随机重启。我测试时用了一个旧充电宝,结果设备每隔几十分钟就重启一次,排查了很久才发现是供电不足。
再提一个功能扩展方向:你可以把这篇项目里的DNS劫持和NCSI欺骗逻辑,与一个简单的用户数据库结合,做一个真正的公共WiFi认证网关。设备连接后先弹认证页面,用户输入访客密码或同意条款,ESP32记录MAC地址后放行。基于现在的代码,你只需要在认证成功的回调里维护一个MAC白名单,然后控制网络访问策略即可。如果你后续有精力,这个方向值得继续往下做。
根据我的个人经验,这种“看似不起眼的小芯片干了一件正经网络设备才干的事”的项目,做完之后带来的收获远不只是几个Demo代码——你会对DNS协议、NCSI检测机制、WiFi网络状态机这些平时不太注意的底层原理建立真正的体感。这才是这个项目最有价值的地方。