1. 明明是一块MCU,怎么就被拿去当DNS服务器了
1.1 一块开发板逆袭成“假路由器”的底气
有朋友问我:你拿ESP32搞过那么多花活,有没有试过把它当成一台真正的“服务器”?我指的服务器不是网页点个灯那种小打小闹,而是——DNS服务器。我第一次想到这个方向也愣了一下,毕竟在绝大多数人认知里,DNS是跑在机房Linux机器上的基础设施,和一块几十块钱的开发板扯不上关系。但你把DNS拆开看,它的本质就是一台监听UDP 53端口、收到查询后回一条记录包的程序。只要设备自带网络协议栈和收发UDP的能力,理论上都能干。
ESP32能成为这个“歪门邪道”的最佳载体,核心原因是它有一个完整的WiFi协议栈加lwIP协议栈,同时还能开softAP(软AP)。这意味着ESP32不只是自己能收DNS请求,它还能把自己伪装成一个无线路由器,把整个AP内所有设备的DHCP和DNS流量全部接管过来。再加上几十块钱的硬件成本、硬币大小的体积、电池就能供电,它甚至能被塞进一个普通充电宝外壳里。你在实验室里演示时,不用扛着笔记本去找测试点,口袋里揣一块开发板就够了。
1.2 这个场景能干嘛:授权测试的典型玩法
这套玩法在无线安全领域非常典型。最常见的场景有三种。
第一种是钓鱼热点演示。把ESP32配置成一个开放热点,名字叫“FreeWiFi”或者商户名,手机连上后系统误判为“网络可以正常访问”,然后用户打开任意HTTP页面时,都会被替换成钓鱼页面。这就是标题里“NCSI欺骗 + DNS劫持”的组合拳。
第二种是网络教学演示。让学生直观理解DNS协议栈长什么样、系统是怎么判断“网络通不通”的。与其对着Wireshark包讲半天,不如让他们自己抓一个ESP32的DNS应答包,看一眼就有了肌肉记忆。
第三种是红队或IoT测试里的近源攻击验证。在授权范围内,把设备放在目标办公区域附近,模拟一个恶意热点,看终端设备有没有防范机制。
这里必须反复强调一个前提:所有这些场景都必须发生在你自己家里、实验室里,或者获得了明确授权的测试项目里。未经授权在公共场所搭这个玩意儿,性质就完全变了。后面我会专门说边界。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. NCSI到底在检测什么:系统连上WiFi后那几秒钟的“小动作”
2.1 Windows的NCSI探测:一个txt文件定生死
在开始写欺骗逻辑之前,先弄明白系统凭什么判断“这个网络能上网”。Windows从Vista开始就有了NCSI(Network Connectivity Status Indicator,网络连接状态指示器)。你每连上一个WiFi,系统都会在后台偷偷访问一个固定的URL,判断返回内容是不是预期值。Vista到Windows 8用的是 http://www.msftncsi.com/ncsi.txt,期望返回纯文本Microsoft NCSI;Windows 10之后换成了 http://www.msftconnecttest.com/connecttest.txt,期望返回Microsoft Connect Test。如果请求超时、内容不匹配、返回302跳转,系统就在任务栏给你来个“无Internet访问”的黄色感叹号。
Windows还藏了一个特别容易忽略的DNS探测:系统会尝试解析 dns.msftncsi.com,期望的A记录是 131.107.255.255。如果你把整个DNS都劫持到本地,把这个域名也解析成192.168.4.1,Windows会认为DNS被污染或异常,同样判定网络不可用。所以做欺骗的时候,不能只处理HTTP探测,DNS探测的坑也得一并填上。
2.2 Android与iOS的探测逻辑:204还是Success
安卓从4.0开始引入Captive Portal检测机制,默认请求 http://connectivitycheck.gstatic.com/generate_204,服务器必须返回204 No Content且body为空,系统才认为“可以访问互联网”。如果返回200带页面,系统就认为这是个强制门户,会弹出“需要登录”的通知。苹果系的逻辑类似,iOS和macOS请求 http://captive.apple.com/hotspot-detect.html,期望HTTP 200且body里包含Success这个字符串。
这三个系统的检测URL和期望值完全不同,网上很多教程只提Windows,拿那套方案直接用在安卓和苹果上,结果表现为“有的手机能骗过,有的手机骗不过”。你以为是自己代码写错了,其实是没搞清楚NCSI分平台。另外,这类探测不是只在连接瞬间发生一次,系统会在网络切换、信号变化、休眠唤醒时反复触发,所以你的HTTP服务必须一直在线,不能只在开机那一刻响应。
2.3 探测失败的代价:永不消失的“无互联网”提示
为什么NCSI欺骗这么重要?因为探测失败后,系统不仅仅是在状态栏显示一个“已连接,无互联网”,它还会改变整个网络的行为策略。安卓在判定网络不可用后,会暂停大部分后台流量,应用商店直接显示无网络,很多APP的数据同步全部挂起。最关键的是,用户看到“需要登录”的提示,第一反应是换个WiFi。你的钓鱼热点搭得再精致,用户不上钩,一切都是白搭。
所以NCSI欺骗不是附加功能,而是整套方案能不能成立的前置条件。你必须先让手机相信“这个网络是通的”,手机里的APP们才愿意产生网络请求,你的DNS劫持才有机会发挥作用。搞清楚了这层逻辑,接下来就是纯实现问题。
3. NCSI欺骗的落地思路:让每一台设备都以为“网络通了”
3.1 欺骗链:DNS劫持 + 本地HTTP应答
NCSI欺骗完整的链路是两段式。
第一段是DNS劫持。客户端查询connecttest.txt、generate_204、hotspot-detect.html这些域名时,ESP32直接把A记录解析成自己的IP——192.168.4.1。这步很关键,因为如果你把探测域名解析到外网真实服务器,ESP32自己又没有外网出口,那请求就石沉大海了。整个方案的核心就是“外部网络其实是断的,但我在本地伪造一个‘通’的假象”。
第二段是本地HTTP响应。ESP32上跑一个HTTP server,收到对应URL时,按系统期望返回内容。对Windows返回Microsoft Connect Test文本,对安卓返回204空body,对苹果返回200 + Success。这三类响应必须精确控制,不能画蛇添足。比如给安卓的generate_204返回带body的200,系统就会判定为强制门户,然后弹出登录页,你的钓鱼页就穿了帮。
3.2 三种系统的期望响应对照
下面这个对照表我建议直接存下来,做这类实验时天天都要用到。
| 系统 | 探测URL | 期望响应 |
|---|---|---|
| Windows 8及以下 | http://www.msftncsi.com/ncsi.txt | 200,body为Microsoft NCSI |
| Windows 10及以上 | http://www.msftconnecttest.com/connecttest.txt | 200,body为Microsoft Connect Test |
| Windows DNS探测 | dns.msftncsi.com的A记录查询 | A记录应为131.107.255.255 |
| Android | http://connectivitycheck.gstatic.com/generate_204 | 204 No Content,空body |
| iOS/macOS | http://captive.apple.com/hotspot-detect.html | 200,body包含Success |
| Linux NetworkManager | http://nmcheck.gnome.org/check_network_status.txt | 200,body包含NetworkManager is online |
表格里每一行都有对应的“坑”。比如Windows的DNS探测,如果你把所有域名都解析成192.168.4.1,它就能发现不对;但如果你真的把dns.msftncsi.com解析成131.107.255.255,那这个IP只是微软用于识别检测服务器的“暗号”,不会真的建立TCP连接。安卓现在还有一堆备选域名,比如connectivitycheck.android.com、connectivitycheck.google.com,全都要做进规则表里。
3.3 HTTP处理器里如何精准分流
在ESP32的HTTP server里,不能用“收到请求就返回钓鱼页”这种一刀切逻辑,必须对URL和Host做双重判断。我用esp_http_server框架时,会注册一个catch-all的URI处理器,然后在handler里先取uri和Host头,判断是不是NCSI探测请求,是就返回对应探测结果,否则才进入钓鱼页逻辑。
c复制static esp_err_t root_handler(httpd_req_t *req)
{
char buf[100];
httpd_req_get_url_query_str(req, buf, sizeof(buf)); // 实际取uri和host需要解析
const char *host = httpd_req_get_hdr_value_str(req, "Host");
const char *uri = req->uri;
if (strstr(uri, "connecttest.txt") || strstr(uri, "ncsi.txt")) {
httpd_resp_set_status(req, "200 OK");
httpd_resp_set_type(req, "text/plain");
httpd_resp_send(req, "Microsoft Connect Test", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
if (strstr(uri, "generate_204")) {
httpd_resp_set_status(req, "204 No Content");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
if (strstr(uri, "hotspot-detect.html") || strstr(uri, "success.html")) {
httpd_resp_set_status(req, "200 OK");
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
// 其他请求,统一返回钓鱼页
const char *phish_html = "<html>...</html>";
httpd_resp_send(req, phish_html, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
代码本身不复杂,但要注意几个细节。第一是resp_send之前先set_status,顺序反了可能出问题。第二是204响应不要调send一个空字符串,传NULL和0。第三是微软的探测返回纯文本,不要用text/html,我一直用的text/plain,Windows对Content-Type的容忍度高,但保持规范总没错。另外,别忘记给dns.msftncsi.com单独留一个DNS规则,返回131.107.255.255,不然Windows的DNS探测这关过不去。
4. DNS劫持的核心实现:把53端口改写成“你说什么就是什么”
4.1 DHCP下发DNS:让客户端主动来找你
NCSI欺骗是“应用层的结果”,它依赖底层的DNS劫持。DNS劫持的第一步,是让客户端的DNS查询都到ESP32上来。最直接的做法是让ESP32作为DHCP服务器,给客户端分配IP时,把DNS Option(Option 6)设置成192.168.4.1。这样客户端拿到IP后,不管自己原本配置了什么DNS,都会优先使用DHCP下发的这个DNS。
用Arduino框架的人可能更熟悉另一个库——DNSServer,它配合WiFi.softAPConfig就能把这个流程跑通:
cpp复制#include <DNSServer.h>
#include <WiFi.h>
const byte DNS_PORT = 53;
IPAddress apIP(192, 168, 4, 1);
DNSServer dnsServer;
void setup() {
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
WiFi.softAP("FreeWiFi");
dnsServer.start(DNS_PORT, "*", apIP);
}
void loop() {
dnsServer.processNextRequest();
}
这段代码运行起来后,所有连上“FreeWiFi”的设备,任意域名都会被解析到192.168.4.1。DNSServer库内部自动处理了DHCP下发的DNS设置,所以体验上非常傻瓜。但它的弱点也很明显:规则太粗,要么全劫持,要么全放行,没法区分“哪些域名要劫持、哪些域名要正常放行”。如果你还需要让用户真正能上外网,做高级一点的诱饵,那就得自己写DNS服务器了。
4.2 DNS请求解析与响应伪造:主要代码逻辑
我自己的项目里,用ESP-IDF从零写了一个可控的DNS服务器。实现思路就是绑定UDP 53端口,收到查询后解析域名,查规则表,然后按需构造响应。核心流程分成四步。
第一步,创建UDP socket并bind到53端口。
c复制static void dns_server_task(void *pvParameters)
{
char rx_buffer[512];
struct sockaddr_in dest_addr = {
.sin_addr.s_addr = htonl(INADDR_ANY),
.sin_family = AF_INET,
.sin_port = htons(53)
};
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
bind(sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
while (1) {
struct sockaddr_in source_addr;
socklen_t socklen = sizeof(source_addr);
int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer), 0,
(struct sockaddr *)&source_addr, &socklen);
if (len > 0) {
handle_dns_request(sock, rx_buffer, len, &source_addr);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
第二步,解析DNS请求里的QNAME(查询域名)。DNS里域名采用标签长度前缀编码,比如www.example.com被编码成 03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00。解析时要逐个标签跳过,直到遇到0结尾。还有一个细节,请求里的域名可能出现压缩指针(0xC0开头),虽然UDP查询请求里不常见,但稳妥起见也要处理。
第三步,查规则表。我的规则表支持三类匹配:精确匹配(example.com)、后缀匹配(.example.com)、全局默认()。每个规则对应一个目标IP,可能返回192.168.4.1,也可能返回某个NCSI特殊IP。
第四步,构造响应包。这一步要改DNS头部的flags、ANCOUNT,然后在Question后面拼Answer记录。Answer结构是:Name(可以用0xC00C指针指向Question里的域名)、Type、Class、TTL、RDLENGTH、RDATA。
c复制static void build_dns_response(char *buf, int len, int qname_len,
uint16_t qtype, uint32_t answer_ip)
{
dns_header_t *hdr = (dns_header_t *)buf;
hdr->qr = 1;
hdr->aa = 1;
hdr->ra = 1;
hdr->ancount = htons(1);
int offset = sizeof(dns_header_t) + qname_len + 4; // 4 = QTYPE + QCLASS
// Answer: NAME=0xC00C 压缩指针
buf[offset++] = 0xC0;
buf[offset++] = 0x0C;
// TYPE=A, CLASS=IN
buf[offset++] = 0x00;
buf[offset++] = 0x01;
buf[offset++] = 0x00;
buf[offset++] = 0x01;
// TTL=60秒
buf[offset++] = 0x00;
buf[offset++] = 0x00;
buf[offset++] = 0x00;
buf[offset++] = 0x3C;
// RDLENGTH=4
buf[offset++] = 0x00;
buf[offset++] = 0x04;
// RDATA=answer_ip
memcpy(buf + offset, &answer_ip, 4);
}
这里给answer_ip赋值时注意用htonl转换。整个响应构造完,直接用sendto发回客户端。这个方案在ESP32上跑起来,响应时间通常在1ms以内,比公共DNS还快,用户完全感知不到异常。
4.3 边界情况:EDNS0、AAAA查询、域名压缩
整个过程中最坑的三个边界情况,我一个个说。
第一个是EDNS0。现在很多系统发DNS查询时会带一个OPT记录,用来声明自己能接受更大的UDP响应。如果你返回的响应里没有反映这个能力,某些解析器也能凑合,但为了兼容性,建议在构造响应时保留OPT记录原样带回,或者至少不要把响应截断到512字节以下。我在代码里接收缓冲区固定是512字节,实测够用。
第二个是AAAA查询。手机连上WiFi后,会并行发起A记录和AAAA记录查询。如果AAAA查询返回错误码SERVFAIL,有些系统的解析器会认为“解析失败”,不再尝试使用A记录。正确做法是对AAAA查询返回NOERROR且ANCOUNT=0,意思是“我查了,这域名没有IPv6地址”,这样客户端就会老老实实用它拿到的IPv4地址。
第三个是QNAME压缩。这个问题在前面提过,但值得再强调一遍。解析域名时如果看到0xC0开头的字节,不要当作普通标签长度去读,要处理成跳转指针。只解析一遍很容易出错,而且一旦解析错位,整个DNS包就废了,客户端会一直转圈。
5. 实测中翻过的车:从“连不上”到“被系统识别”的排查全过程
5.1 iOS始终转圈:captive探测远不止一个URL
我第一次做这套系统的完整实测时,用的是自己的一部Android手机,NCSI全部做对了,Android显示网络正常,一切顺利。然后换了iPhone,问题马上来了:手机连上热点后,WiFi图标一直转圈,Safari也打不开任何页面,但系统又没有弹出“无互联网”的提示。
我立刻抓包,发现iOS除了请求captive.apple.com/hotspot-detect.html,还请求了www.apple.com/library/test/success.html,这两个URL的期望内容还不完全一样。我只在规则表里加了hotspot-detect.html,漏了success.html,所以iOS认为网络还没通。补上之后,iPhone正常了。这之后我养成了一个习惯:每次接入新平台的设备,第一件事就是开Wireshark抓包看它到底访问了哪些探测URL,不要只信网上教程里列的清单。
5.2 Android提示“可以上网”但劫持不生效:TTL和缓存在搞鬼
另一个经典故障出现在Android上:手机已经显示“已连接,可以上网”,说明NCSI欺骗成功了。但我用浏览器访问一个目标域名时,它居然打开的是真实网站,而不是我预设的钓鱼页。排查了一圈,发现是DNS缓存的问题。
原因有两个层面。第一,这台手机之前连过正常的网络,系统里缓存了这个域名的解析结果,TTL还没到,所以即使本地DNS劫持规则变了,系统也优先用缓存里的旧IP。解决办法是让测试设备断开再重新连WiFi,或者直接在系统设置里清空DNS缓存。第二,Android的Private DNS(私有DNS)默认是关闭的,但如果某个用户手动开了,或者厂商默认开启了加密DNS,那么系统会把DNS查询打包成TLS流量发给上游,你ESP32监听的UDP 53端口根本收不到请求。所以实验环境里必须把测试设备的私有DNS关掉。这也直接引出一个结论:加密DNS是这套方案的天敌。
5.3 HTTPS页面被浏览器拦截:DNS劫持的天然边界
钓鱼页只能针对HTTP协议,这是个绕不开的现实。当你把某个域名的流量劫持到本地IP后,用户如果访问的是HTTPS站点,浏览器会在TLS握手阶段发现证书不匹配,直接给出大红叉警告。理论上ESP32也能临时生成一张证书,但浏览器会校验证书链,除非用户手动信任,否则必然拦截。
所以实际演示时效果最好的目标是纯HTTP站点,或者用户第一次访问某个还没有强制HSTS的站点。对于微信、抖音这类大流量APP,几乎全部走HTTPS,劫持之后用户只会看到一片白屏或证书警告,攻击者根本拿不到有效数据。很多网上教程不告诉你这一点,等你做出来发现“怎么打不开”才恍然大悟。我建议在项目一开始就明确目标:NCSI欺骗是为了让系统“看起来正常”,DNS劫持是为了把HTTP流量导向钓鱼页,HTTPS部分不要硬碰。
6. 防守方视角:这套攻击怎么被识破和拦截
6.1 终端侧:加密DNS是最直接的一刀
站在防守方角度,这套攻击其实有很清晰的破解路径。最强的措施就是加密DNS。安卓的Private DNS(基于DoT)和iOS的加密DNS配置,会把所有DNS查询用TLS加密后发给可信解析器,从ESP32的角度看,它只能看到加密的流量,完全无法解析查询内容,自然没办法做劫持。网络层搞不定,NCSI欺骗也就失去了“指路牌”。
从用户习惯上,公共WiFi环境下尽量少访问敏感页面,看到证书警告立刻停止操作,不要点“继续访问”。这些老生常谈在防御这套方案时依然非常有效。
6.2 网络侧:Rogue AP检测与网关异常监控
从企业网管角度看,这类攻击最大的特征是出现了一个非法的开放热点。无线控制器可以通过扫描周围环境,检测到一个陌生的BSSID,并且信号在办公区域内,就应当触发告警并自动阻断。这叫Rogue AP检测,主流企业级无线方案都有。
另外一个监控维度是网关层面的行为。ESP32热点会给客户端下发一个与自己IP相同的DNS服务器地址,同时响应所有DNS查询。企业内网如果部署了DNS监控,会发现某一个IP在极短时间内收到大量客户端的DNS请求,而这个IP根本不是公司授权的DNS服务器。把这类异常直接上报给态势感知平台,基本一拦一个准。
6.3 应用侧:证书固定让重定向无处遁形
应用开发者的防御手段是证书固定。APP在代码里预先写死服务端证书的公钥或指纹,TLS握手时对比真实证书和本地预置值,如果不一致,立刻断开连接。这样一来,即使攻击者劫持了DNS、把流量引到自己的服务器、伪造了一张看起来很像的证书,应用也不会允许握手成功。银行类、支付类APP基本都做了这一层加固,这也是为什么高级攻击往往优先选择诱导用户手动安装证书,而不是硬碰硬对抗。
7. 写在最后:动手之前,先想清楚边界
7.1 授权测试与合规底线
这类技术在授权范围内的红队演练、无线安全课程、CTF比赛里非常实用,ESP32这样的低成本平台尤其适合教学演示。但我必须把话说清楚:把设备放到公共场合做未授权的实验,这不是技术问题,是合规问题,后果非常严重。我自己做测试时,全程都在自己家或专用实验室里搭热点,设备名也会明确标注实验标识,避免路人误连。技术本身是中性的,但动手的人要有边界感。
7.2 给想动手的人的最后几个建议
如果你打算完整复现这个项目,我的建议是分三步走。第一步,先用Arduino的DNSServer库跑通“WiFi热点 + 域名全劫持”,把NCSI欺骗的HTTP响应逻辑写好。第二步,再用ESP-IDF从零写一个可控DNS服务器,加上规则表和NCSI特殊IP处理。第三步,最后再做HTTP钓鱼页和装饰性的网络页面,让整个环境看起来更真实。
还有一个很实用的扩展思路:如果想让连上热点的人真的能上网,可以给ESP32加一个STA接口,让它同时连接你家里的路由器,然后把流量从STA口转发到AP口。这样用户能正常刷网页,但部分域名被悄悄劫持,诱饵环境更逼真。NCSI欺骗照样做,只要DNS规则表足够精细,系统完全感知不到异常。
最后分享一个排错技巧:做这类实验,一定要在电脑上开Wireshark,同时筛选DNS或HTTP流量,观察每个请求的源IP和URL。很多时候不是你代码写错了,而是设备缓存、系统行为、私有DNS设置这些外部因素在捣乱。抓包能看到全部真相,比自己瞎猜快得多。
