ESP32搭建DNS服务器:NCSI欺骗与强制门户实战
发布时间:2026/9/16 4:13:43 作者:尧图编辑部 阅读量:1,286

很多玩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 8http://www.msftconnecttest.com/connecttest.txtHTTP 200内容为“Microsoft Connect Test”Windows (旧版/其他)http://www.msftncsi.com/ncsi.txtHTTP 200内容为“Microsoft NCSI”Androidhttp://connectivitycheck.gstatic.com/generate_204HTTP 204 No ContentiOS / macOShttp://captive.apple.com/hotspot-detect.htmlHTTP 200内容为“Success”Linux (NetworkManager)http://nmcheck.gnome.org/check_network_status.txtHTTP 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模式并分配IPDNS服务层接收所有域名解析请求并返回指定IPWeb响应层处理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 htmlhtmlheadmeta charsetutf-8; html titleWiFi 认证/title/headbody; 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设置QR1标志然后把问题段原样复制回去再追加回答段——回答段里包含域名指针、类型、TTL和IPv4地址。DNSServer.h帮你把这些细节都封装好了但它内部实现逻辑就是这样你在排查“为什么某个域名解析不对”时可以从这个结构入手去思考。4.4 关于UDP端口监听的调试技巧DNS服务默认走UDP 53端口。UDP和TCP不一样它没有握手过程所以抓包调试的时候不像TCP那么容易定位问题。我建议调试时用processNextRequest()的日志输出在解析到请求时打印出域名和返回的IP这样至少能确认“DNS请求到底有没有到达ESP32”。如果发现设备一直不发起DNS请求先检查设备的网络配置——确保它拿到的是192.168.4.1作为DNS服务器地址。有些系统为了节省流量会缓存或使用DoHDNS 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 手机连上热点后不弹认证页面这是最常见的坑。原因可能是手机存在私有DNSDoH或者系统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网络状态机这些平时不太注意的底层原理建立真正的体感。这才是这个项目最有价值的地方。