看到这个标题,估计不少玩ESP的朋友第一反应是:ESP不是做物联网小硬件的吗?Flash才4MB,RAM更是以KB为单位,让它去当DNS服务器,这不是硬凑吗?
先说结论:如果你把“DNS服务器”想象成BIND、PowerDNS那种跑在机房里、每天处理几十亿次解析的服务,那ESP当然不行。但如果你把“DNS服务器”理解成“监听UDP 53端口、收到解析请求后返回一条A记录”,那ESP不仅做得到,而且很多量产智能硬件里早就这么干了。智能插座初次配网时弹出来的配置页面、路由器管理系统里的“强制门户”,核心机制都包含一个微型DNS响应器。
这篇文章我打算把这件事一次讲透:ESP怎么当DNS服务器、业内常说的“DNS劫持”在局域网实验里是怎么实现的、以及Windows在判断“当前网络是否连上互联网”时用的NCSI机制,为什么会被一个几十块钱的板子“骗”过去。整个实验只涉及你自己创建的局域网、自己的AP和受控客户端,目的是理解协议和做开发调试,不是去别人网络里搞破坏。
1. 凭什么说“ESP能当DNS服务器”?先把硬件底子盘清楚
1.1 DNS请求本质上就是几十字节的UDP报文
很多人觉得“DNS服务器”很重,是因为公网DNS需要递归查询、缓存、抗DDoS、支持DNSSEC、处理各种超时和重试逻辑。但在一个局域网或实验性软AP里,DNS负载完全不是同一个量级。
给你算一笔账:一次普通的DNS查询报文,通常不超过100字节,响应也差不多。ESP8266主频80到160MHz,内存虽小但处理一个“查表就回IP”的请求,耗时通常不到几毫秒;ESP32双核240MHz、520KB SRAM,处理这种小报文更轻松。如果你的场景是“家里几十台设备接进来做实验”,一秒十几个DNS请求已经算压力不小,ESP的算力完全顶得住。
真正的瓶颈不在CPU,而在UDP协议栈的处理方式。Arduino框架下的ESP8266默认能同时打开的UDP socket数量很有限,每次收包后要很快处理完,否则下一个包可能被丢弃。所以ESP适合做“低并发、低延迟、高成功率”的轻量DNS响应器,不适合放在高并发公网环境下。
我做过一个简单压测,客户端连续发起几千次查询,ESP8266能稳定响应其中大部分请求,偶发超时主要集中在客户端瞬间并发打出大量请求的时候。这个表现做局域网实验和产品配网Portal完全够用了。
1.2 三种“ESP DNS”形态,你适合做哪种
在开始写代码前,建议先明确你想让ESP扮演什么角色。不同角色对应完全不同代码复杂度:
| 实现形态 | 工作方式 | 内存占用 | 典型场景 |
|---|---|---|---|
| 全量域名吸附型 | 收到任何域名解析请求,全部返回ESP自身IP | 很低 | 配网Portal、强制门户演示 |
| 白名单重定向型 | 命中指定域名时返回自定义IP,其余解析转发到上游DNS | 中 | 内网开发调试、私有域名测试 |
| DNS旁路转发型 | 自己不解析,只把请求原样转发给真实DNS | 低 | 实验性DNS代理 |
这篇文章主要带你落地第一形态,因为它是“NCSI欺骗 + DNS劫持”这个实验最直观的载体。第二种形态适合做内网调试,我会在后面的扩展思路里讲一下,但不会给出完整的生产级实现,因为ESP在一个进程里同时做接收和转发,天然会碰到并发问题,需要额外设计状态管理才能稳定跑起来。
1.3 为什么以前很少有人想到让ESP干这个
一方面是惯性思维。大家提到“DNS服务器”就觉得应该是跑Linux的大型服务,ESP那么小,容易被忽略。另一方面,很多ESP的项目用不到域名解析,只要能控制GPIO、能上报传感器数据就行。
但如果你玩过用ESP做Wi-Fi配网,会发现一个现象:手机连上ESP开的热点后,系统会自动弹出一个网页,或者状态栏显示“需要登录”。这个体验背后的原理,就是一个藏在ESP里的微型DNS服务器,把手机发出的任意域名解析请求全部“吸”到自身,然后提供一个HTTP配置页面。
如果你做完配网功能之后继续往深里问一句:为什么手机连上热点就知道要弹页面?为什么会显示“已连接”而不是“无Internet”?这个问题就会引出一个更深的东西:NCSI。刚好这篇文章就把这块一起讲了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “DNS劫持”到底在劫什么:局域网域名吸附实验
2.1 先澄清:这里的“劫持”是可控的本地重定向
说到DNS劫持,正经网络安全从业者马上会想到恶意行为:比如在公网链路上篡改DNS响应,把用户访问的域名解析到钓鱼服务器。这种是违法的,也是这篇内容完全不涉及的部分。
局域网开发里还有另一种“DNS劫持”,本质上是可控的域名吸附。客户端向你自己的AP发起域名解析请求,正常情况下它应该去真正的DNS服务器查记录,但在实验网络里,客户端配置的DNS就是ESP本身,ESP可以选择性地回答“你要解析的这个域名地址是192.168.4.1”或者干脆不回消息。
这就像小区门口传达室,物业统一告诉访客:不管你要去哪一栋,先到我这里登记。访客在小区内部体验到的就是“所有地址都要先过传达室”,但如果传达室不放行,访客也去不了真正的目的地。
这里必须说清楚边界:整个实验只允许在你自己的AP、自己连接的设备上做。如果你把设备放到别人网络、公共热点里去做这类测试,或者利用这个机制干扰其他人的上网体验,既不道德也可能违反服务条款甚至法律。动手之前先把这条路封死。
2.2 一个DNS请求从客户端发出后,ESP实际看到了什么
先看一个最简DNS查询的结构。当你在电脑上敲 nslookup test.lab 时,发出的UDP报文发往53端口,内容大致分为两段:
- Header:前12字节,包含交易ID、标志位、问题数、回答数等。
- Question:包含你要查询的域名(QNAME)、查询类型(QTYPE,比如A记录是1)、查询类别(QCLASS,通常是1)。
ESP收到这个报文后,要做的事情非常直接:从报文中提取域名,和内部规则比对,如果符合“吸附”条件,就构造一个应答报文。应答报文需要保留原查询里的交易ID和Question部分,再加上Answer部分,里面写一条A记录:“你问的这个域名,IP是192.168.4.1”。
如果你自己用UDP实现,这个逻辑完全能写下来,但没必要重复发明轮子。Arduino生态里有现成的 DNSServer 库,它封装了报文解析和响应构造,核心调用就两行:
cpp复制dnsServer.start(53, "*", apIP);
第一个参数是监听的UDP端口,DNS固定是53;第二个是域名匹配规则,"*" 表示匹配所有域名;第三个是解析结果IP,所有请求都会被解析到这个地址。
这里有一个知识点值得展开:"*" 能通配,但不代表什么都替你做。DNSServer 库内部只会返回它认为匹配的答案,如果域名不匹配,它不会帮你转发给上游DNS,而是直接忽略请求。这就是为什么真正的“白名单重定向型”不能只靠这个库完成。
2.3 最小可运行版本:所有域名都指向ESP
下面先给一个最简单的ESP8266程序,它能让连接设备访问任意域名时都拿到192.168.4.1这个解析结果:
cpp复制#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <ESP8266WebServer.h>
const byte DNS_PORT = 53;
IPAddress apIP(192, 168, 4, 1);
DNSServer dnsServer;
ESP8266WebServer webServer(80);
void setup() {
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
WiFi.softAP("ESP-DNS-Lab");
dnsServer.start(DNS_PORT, "*", apIP);
webServer.on("/", []() {
webServer.send(200, "text/html",
"<h1>ESP DNS Redirection OK</h1>"
"<p>你已经成功连上ESP的AP,并且所有域名都被解析到了这里。</p>");
});
webServer.begin();
Serial.begin(115200);
}
void loop() {
dnsServer.processNextRequest();
webServer.handleClient();
}
把这段代码烧进ESP8266后,用手或电脑搜索名为 ESP-DNS-Lab 的Wi-Fi热点,连接成功后访问任意域名,比如在浏览器地址栏输入 http://anything.example,正常情况下你看到的是ESP返回的页面,而不是ICP备案错误页或真实的站点。
为什么能成功?关键在于客户端在连接ESP的AP时,DHCP服务器会把192.168.4.1作为DNS下发给客户端。随后客户端发起域名解析请求时,实际上查询的就是192.168.4.1的53端口,也就是ESP自己。
验证时最直观的方法是打开命令行执行:
bash复制nslookup test.lab
如果你看到类似下面的结果,说明ESP的DNS响应器已经生效:
code复制服务器: 192.168.4.1
Address: 192.168.4.1#53
名称: test.lab
Address: 192.168.4.1
看到 Address: 192.168.4.1 这一行,就说明“劫持”生效了,因为它把一个根本不存在的 test.lab 域名解析到了ESP的IP上。
2.4 一个容易踩的坑:现代手机可能不按套路出牌
这套方案在普通路由器时代非常灵,但拿现代手机测试时经常“失灵”。主要原因是现代手机系统默认开启了私有DNS或DoH。如果你的手机把DNS查询加密后发给了公共DNS服务器,那么ESP的UDP 53端口根本收不到请求,因为你设置的是“DNS over HTTPS/TLS”,流量不会走普通的UDP DNS。
如果你要测试这个实验,建议把手机的“私有DNS”关掉,或者设置成“自动”,再连ESP的AP。同时,如果你手机上有代理类App,最好也先关闭,因为代理会接管网络流量,域名解析不一定发生在你本机。
Windows电脑相对好一些,只要网卡拿到的是192.168.4.1这个DNS,解析查询就会走常规UDP DNS。所以在这个实验里,Windows电脑是最稳定的测试客户端。
3. NCSI欺骗:Windows为什么会被一块ESP“骗”出已连接
3.1 Windows的“网络连接状态指示器”到底测了什么
你可能注意到一个现象:Windows任务栏右下角的网络图标有时显示地球或黄色感叹号,有时显示满格Wi-Fi图标且写着“Internet访问”。这个判定不是靠“能不能打开某个网页”来做的,而是靠Windows的NCSI组件,全称叫“网络连接状态指示器”。
NCSI的检测大概分两段:
第一段是DNS探测。Windows会去解析一个特殊域名 dns.msftncsi.com,这个域名的预期解析结果是IP地址 131.107.255.255。请注意这个IP地址本身是一个特殊的广播地址,不是用来访问服务的,它仅仅作为一个“预期值”参与比对。如果解析结果匹配,说明DNS链路至少是通的。
第二段是HTTP探测。Windows会向 http://www.msftconnecttest.com/connecttest.txt 发起一次GET请求,如果服务器返回HTTP 200,并且响应体内容恰好是 Microsoft Connect Test 这段文本,Windows就认为当前网络能访问互联网。对旧版本系统,还会去请求 http://www.msftncsi.com/ncsi.txt,预期响应是 Microsoft NCSI。
你可以把这个机制理解为Windows大楼门口的保安。他不关心你是不是真的去写字楼上班,他只问你两个固定暗号:第一,“131.107.255.255怎么走”,第二,“对一句Microsoft Connect Test”。你只要把暗号答对了,他就放行。
3.2 ESP是怎么“骗”过NCSI的
现在把前面两件事连起来看:
你连接的是ESP开的一个纯AP热点,这个热点后面没有任何外网链路,理论上Windows应该显示“无Internet”。但如果ESP做了两件事,情况就变了:
第一,ESP作为DNS响应器,把所有域名解析请求都回成192.168.4.1。Windows去解析 dns.msftncsi.com,ESP照样回答:“它就在192.168.4.1”。这里DNS解析出的IP并不是预取的 131.107.255.255,按微软的逻辑第一段检测可能就不匹配。不过实测中不同Windows版本对这个结果的容忍度不一样,而且新版本的重点更偏HTTP探测。
第二,ESP在192.168.4.1的80端口上跑了一个HTTP服务,并且对 /connecttest.txt 这个路径返回了200和文本 Microsoft Connect Test。于是Windows的HTTP探测那一段直接通过。
两条链路叠加后,Windows右下角就可能显示“已连接,Internet访问”,尽管这个AP完全没有接外网。这就是很多人会在实验里看到的“神奇现象”。
要复现这个效果,需要在刚才程序的基础上,给WebServer增加几个特殊路径:
cpp复制webServer.on("/connecttest.txt", []() {
webServer.send(200, "text/plain", "Microsoft Connect Test");
});
webServer.on("/ncsi.txt", []() {
webServer.send(200, "text/plain", "Microsoft NCSI");
});
webServer.on("/generate_204", []() {
webServer.send(204, "text/plain", "");
});
webServer.on("/hotspot-detect.html", []() {
webServer.send(200, "text/html",
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
});
/generate_204 是Android系统连接检测用的路径,返回204 No Content代表网络可用。/hotspot-detect.html 是Apple设备常用的检测路径,返回一段包含“Success”的HTML,代表没有强制门户。
加完这四个路径后,重新烧录程序,连接ESP的AP,观察Windows网络图标的反应。如果你在设备管理器里看到网络状态从“无Internet”变到“Internet访问”,说明NCSI欺骗成功。
3.3 一个实验陷阱:Windows的缓存会干扰你的判断
我第一次测的时候,明明改了代码重新烧录,但Windows还是显示黄色感叹号,后来才发现问题出在Windows自身缓存了上一次网络检测结果。NCSI检测不是每次都实时发请求的,系统会根据网络变化、DNS变化、重启等因素决定要不要重新探测。
如果排障时觉得ESP已经改了,但设备状态没变化,可以打开管理员命令行,执行:
code复制ipconfig /flushdns
这个命令会清空本地DNS缓存,部分场景下可以促使系统重新走一遍NCSI检测。如果还不行,可以断开Wi-Fi热点重新连接,或者干脆重启电脑。Windows网络栈的缓存比想象中顽固,几次状态更新也需要时间。
另外,ESP端需要留意,NCSI探测的HTTP请求会打到路径 /connecttest.txt,Arduino的WebServer对路径匹配是区分大小写的。如果你的路径写成了 /Connecttest.txt 或者末尾带了一个斜杠,Windows可能拿不到预期响应。
3.4 常用系统连接检测路径汇总
我在实验里把各平台的检测路径做成了对照表,方便嵌入式开发时直接参考:
| 平台 | 检测域名/路径 | 预期响应 |
|---|---|---|
| Windows 10/11 | http://www.msftconnecttest.com/connecttest.txt | 200,正文 Microsoft Connect Test |
| Windows 旧版 | http://www.msftncsi.com/ncsi.txt | 200,正文 Microsoft NCSI |
| Android | http://connectivitycheck.gstatic.com/generate_204 | 204 无内容 |
| iOS/macOS | http://captive.apple.com/hotspot-detect.html | 200,正文含 Success |
这张表对做智能硬件配网的人特别有用。当设备自带AP需要弹出Portal页面时,你至少得让苹果设备认为“当前网络有Portal需要处理”;反之,如果你希望系统认为“网络可用”,就需要对探针返回“成功”的响应。不同系统探针细节可能随版本微调,但大方向基本稳定。
4. 手把手完整实验:基于Arduino框架编译烧录并验证效果
4.1 准备硬件和开发环境
做这个实验需要准备一块ESP8266或ESP32开发板,NodeMCU、D1 Mini、ESP32 DevKit都可以,不需要外扩展模块。板载Wi-Fi已经足够。如果你用的是ESP32,还需要注意一下 DNSServer 库是否随Arduino core一起安装,早期部分ESP32 Arduino版本需要单独装,装法是在库管理器里搜索“DNSServer”并安装。
开发环境我推荐用Arduino IDE配合PlatformIO,二选一。Arduino IDE上手快,适合第一次玩的朋友;PlatformIO适合后续要接更多库、做自动化编译的场景。
如果你用的是ESP32,建议把开发板选型设置成 ESP32 Dev Module 或对应型号,Flash容量按你板子实际配置。烧录前插上USB线,先确认串口号,Windows设备管理器里能看到一个新的COM口。烧录工具方面,Arduino和PlatformIO都会自动调用esptool,你不用额外下载。
4.2 完整程序:DNS吸附NCSI欺骗一体化
下面给出一份能在ESP8266上直接编译运行的完整程序。它会把ESP配置成一个名为“ESP-DNS-Lab”的AP,所有DNS查询都被吸附到ESP自身,同时ESP的HTTP服务会对各类NCSI探针返回预设响应。
cpp复制#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <ESP8266WebServer.h>
const byte DNS_PORT = 53;
IPAddress apIP(192, 168, 4, 1);
DNSServer dnsServer;
ESP8266WebServer webServer(80);
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
WiFi.softAP("ESP-DNS-Lab");
// 所有域名解析请求都返回 192.168.4.1
dnsServer.start(DNS_PORT, "*", apIP);
// 用户直接访问 http://192.168.4.1 时看到的主页
webServer.on("/", []() {
webServer.send(200, "text/html",
"<h1>ESP DNS Redirection OK</h1>"
"<p>这个页面由 ESP 提供。所有未匹配域名的DNS请求都被解析到了这里。</p>");
});
// Windows NCSI 探针
webServer.on("/connecttest.txt", []() {
webServer.send(200, "text/plain", "Microsoft Connect Test");
});
webServer.on("/ncsi.txt", []() {
webServer.send(200, "text/plain", "Microsoft NCSI");
});
// Android 探针
webServer.on("/generate_204", []() {
webServer.send(204, "text/plain", "");
});
// iOS 探针
webServer.on("/hotspot-detect.html", []() {
webServer.send(200, "text/html",
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
});
webServer.begin();
Serial.println("ESP-DNS-Lab started");
Serial.print("AP IP: ");
Serial.println(apIP);
}
void loop() {
dnsServer.processNextRequest();
webServer.handleClient();
}
编译前有几个易错点:
- 如果你用的是ESP32,头文件里的
ESP8266WiFi.h要改成WiFi.h,其他逻辑基本通用。 WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0))前两个参数分别是AP的IP和网关IP,这里都设成同一个地址,即ESP自身。第三个参数是子网掩码,一般255.255.255.0。- 如果你希望AP名称不暴露实验痕迹,可以把
"ESP-DNS-Lab"改成自己喜欢的名字,比如“MyLabAP”。
4.3 一步步验证DNS吸附效果
程序烧录成功后,打开串口监视器,把波特率调到115200,能看到设备打印出的IP信息。然后用电脑搜索并连接 ESP-DNS-Lab 这个热点。
连接成功后,建议按这个顺序验证:
第一,检查IP地址。连上热点后,打开命令行执行 ipconfig,找到无线网卡对应的IPv4地址,正常情况下会得到一个192.168.4.x的地址,网关是192.168.4.1。
第二,验证DNS解析。执行:
bash复制nslookup test.lab
如果结果里的Address是192.168.4.1,说明DNS吸附生效。
第三,验证HTTP服务。在浏览器打开 http://192.168.4.1,能看到ESP返回的主页。再尝试访问 http://anything.test,这里需要注意部分浏览器会自动加前缀或强制走HTTPS,遇到这种情况可以临时关闭浏览器的“始终使用HTTPS”或“安全DNS”选项。
第四,观察Windows网络状态。连接ESP热点后,如果Windows NCSI探针被ESP成功应答,系统可能显示“已连接”。如果不是,先别急着怀疑代码,看看系统是否缓存了旧状态,或者是否被网络位置感知服务卡住了。
4.4 把DNS白名单重定向作为扩展方向
实验做通之后,你可以思考一个问题:如果不想吸附所有域名,只想让 *.dev.lan 这类域名解析到ESP,其他域名正常走外部DNS,该怎么做?
最简单的方法是扩展成一个双模逻辑。ESP工作在STA+AP模式,也就是说它一边连接你家的真实路由器从而获得外网访问能力,一边开启AP提供实验热点。DNS收到请求后判断查询域名:
- 如果域名以
.lan或命中你设置的测试域名,就返回ESP的IP。 - 否则,把原始DNS请求转发给你家路由器的DNS(通常就是网关IP或公共DNS)。
这个逻辑本身不难,难点在于ESP8266只有一个处理器内核,手写一个同步转发代码会导致在等待上游DNS响应期间,无法及时处理其他客户端的请求。真要做得稳定,需要用状态机非阻塞地管理多个进行中的转发,或者用一个标识串号记录客户端请求和上游响应,完成一次转发后就回填。
这种方式在你的局域网内低频调试场景能跑,但不要期望它达到家用路由器自带DNS转发那样的稳定性。如果你只是临时调试,也可以简单地把ESP设为STA模式后,使用自带 config 接口去调节默认网关。
4.5 常见问题速查表
我把排障过程中遇到过的高频问题整理成一张表,方便直接对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 连上AP后域名解析失败 | 手机开启了私有DNS/DoH | 关闭私有DNS或改用Windows测试 |
| DNS能解析到ESP,但网页打不开 | 浏览器强制HTTPS访问 | 地址栏前加 http://,关闭浏览器安全DNS |
| Windows仍显示无Internet | NCSI缓存或检测间隔 | 执行 ipconfig /flushdns,断开重连,必要时重启 |
| ESP重启频繁 | 供电不足或Wi-Fi功耗大 | 换USB口或外接5V电源,不要用劣质充电线 |
| 代码里用了ESP8266头文件却编不过 | 板卡选错 | 检查开发板选型,ESP32会报找不到头文件 |
| DNS吸附不生效 | 客户端网关不是192.168.4.1 | 查看DHCP分配结果,必要时手动设置静态IP |
| 串口乱码 | 波特率不对 | 把串口监视器波特率调到115200 |
最后分享两个自己的调试习惯
这个实验做下来,我对ESP能干什么有了全新认识。过去总觉得它是个“传感器网关”,做了这个项目之后我才意识到,它其实是一块非常适合做网络协议实验的低成本硬件,尤其是配合 DNSServer 库和WebServer,能快速复现各种局域网网络行为。
调试过程中有两个习惯帮了我大忙。第一,尽量用命令行工具而不是浏览器来判断DNS是否生效,nslookup 不会像浏览器那样走HTTPS缓存或系统代理,能直接看到裸的DNS解析结果。第二,分析问题时学会抓包,用Wireshark在客户端网卡上抓UDP 53端口和HTTP流量,能清楚看到每一次NCSI探测是否到达了ESP、响应内容是什么,比看代码猜快很多。
如果你想继续往深扩展,建议下一步用ESP32实现一个带Wi-Fi扫描和记忆功能的配网Portal,让设备先进入配网状态,手机连上AP后自动弹配置页面,选好要连接的路由器后回传给ESP,再由ESP以STA模式去连接目标Wi-Fi。这块做出来基本就是一个小型智能硬件设备的配网模块了。整个过程核心没有变,都是靠这个“微型DNS + 轻量HTTP”的组合在撑腰。
