ESP32 AP 模式下通过 DNS 劫持实现强制门户跳转
ESP32 开启热点时,需要一个机制让连接上来的设备自动打开控制面板网页,而不是让用户手动输入 IP 地址。这个机制在酒店/机场 WiFi 中很常见——连上热点后自动弹出一个登录页面,这就是强制门户(Captive Portal)。
实现强制门户需要三个环节配合:DHCP 通告 DNS、DNS 劫持、HTTP 302 跳转。
整体流程
- 设备连接 ESP32 热点,通过 DHCP 获取 IP,同时被告知 DNS 服务器地址为
192.168.4.1 - 操作系统在后台发送强制门户检测请求(如
http://captive.apple.com/hotspot-detect.html) - DNS 查询被 ESP32 上的 DNS 服务拦截,全部返回 ESP32 自身 IP
- HTTP 请求到达 ESP32 的 Web 服务器,未知路径返回 302 重定向到
/ - 操作系统收到非预期响应,识别为强制门户,弹出浏览器窗口
DHCP 通告 DNS 服务器
首先让 DHCP 在分配 IP 时告诉客户端:DNS 服务器就是 ESP32 自己。
esp_netif_dns_info_t dns_info = {0};
dns_info.ip.u_addr.ip4.addr = esp_ip4addr_aton("192.168.4.1");
dns_info.ip.type = ESP_IPADDR_TYPE_V4;
esp_netif_dhcps_option(s_wifi_ap_netif, ESP_NETIF_OP_SET,
ESP_NETIF_DOMAIN_NAME_SERVER,
&dns_info, sizeof(dns_info));esp_netif_dhcps_option 直接修改 ESP-IDF 内置 DHCP 服务器的配置,不需要额外引入 DHCP 库。
DNS 劫持服务
核心是一个 FreeRTOS 任务,创建 UDP socket 监听 53 端口,对所有 DNS 查询返回一个 A 记录,IP 指向 ESP32 自身的 AP 地址 192.168.4.1。
完整代码如下:
#define DNS_PORT 53
#define DNS_MAX_QUERY_LEN 512
static void dns_server_task(void *arg)
{
(void)arg;
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(DNS_PORT);
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
ESP_LOGE(TAG, "dns: socket create failed");
vTaskDelete(NULL);
return;
}
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
ESP_LOGE(TAG, "dns: bind failed");
close(sock);
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "dns hijack server started on port %u", DNS_PORT);
uint8_t buf[DNS_MAX_QUERY_LEN];
while (1) {
struct sockaddr_in from = {0};
socklen_t fromlen = sizeof(from);
int len = recvfrom(sock, buf, sizeof(buf), 0,
(struct sockaddr *)&from, &fromlen);
if (len < 12) continue;
uint8_t response[DNS_MAX_QUERY_LEN];
memcpy(response, buf, (size_t)len);
response[2] |= 0x80;
response[3] |= 0x80;
response[6] = 0x00;
response[7] = 0x01;
size_t answer_off = (size_t)len;
uint8_t answer[] = {
0xC0, 0x0C,
0x00, 0x01,
0x00, 0x01,
0x00, 0x00, 0x00, 60,
0x00, 0x04,
0x00, 0x00, 0x00, 0x00
};
memcpy(response + answer_off, answer, sizeof(answer));
esp_netif_ip_info_t ip_info;
if (s_wifi_ap_netif && esp_netif_get_ip_info(s_wifi_ap_netif, &ip_info) == ESP_OK) {
uint32_t ip = ip_info.ip.addr;
memcpy(response + answer_off + sizeof(answer) - 4, &ip, 4);
} else {
response[answer_off + sizeof(answer) - 4] = 192;
response[answer_off + sizeof(answer) - 3] = 168;
response[answer_off + sizeof(answer) - 2] = 4;
response[answer_off + sizeof(answer) - 1] = 1;
}
sendto(sock, response, answer_off + sizeof(answer), 0,
(struct sockaddr *)&from, fromlen);
}
}
static void dns_server_start(void)
{
xTaskCreate(dns_server_task, "dns_server", 3072, NULL, 5, NULL);
}代码要点
最小 DNS 报文验证。 if (len < 12) continue 跳过不完整的请求。DNS 头部固定 12 字节,小于这个长度一定是无效包。
复用查询包构造响应。 memcpy(response, buf, len) 把原始查询报文复制到响应缓冲区。这样做的好处是自动保留了 Transaction ID(前 2 字节)和 Question 段,客户端才能匹配请求和响应。
修改标志位。 response[2] |= 0x80 将 QR 位置 1,表示这是响应而非查询。response[3] |= 0x80 设置 RA(Recursion Available),告知客户端服务器支持递归查询。
设置回答计数。 response[6]=0x00, response[7]=0x01 表示 Answer RRs = 1。后续追加一个 A 记录回答。
构造 DNS 回答段。 16 字节的固定回答模板:
| 偏移 | 字节 | 含义 |
|---|---|---|
| 0 | 0xC0 0x0C |
域名指针,指向查询报文偏移 12 处的域名 |
| 2 | 0x00 0x01 |
Type = A (Host Address) |
| 4 | 0x00 0x01 |
Class = IN (Internet) |
| 6 | 0x00 0x00 0x00 60 |
TTL = 60 秒 |
| 10 | 0x00 0x04 |
RD Length = 4 字节 |
| 12 | 4 字节 IP | 回答的 IPv4 地址 |
使用 0xC0 0x0C 域名指针而不是重复拷贝域名,节省了响应报文长度。0xC0 表示这是一个压缩指针,0x0C 指向 DNS 报文偏移 12(即紧接头部之后的 Question 域名段)。
填充 ESP32 AP IP。 优先从 s_wifi_ap_netif 获取真实的 AP 地址,fallback 硬编码 192.168.4.1(ESP-IDF 默认 AP IP)。
HTTP 302 跳转
DNS 劫持把请求送到了 ESP32,但 Web 服务器需要对未知路径做出响应。返回 404 不会触发强制门户检测——系统可能认为这只是网络不通。返回 302 重定向到 / 则被系统识别为强制门户,同时浏览器自动加载首页。
FILE *file = fopen(path, "r");
if (file == NULL) {
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "/");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}littlefs_static_handler 原本对不存在文件返回 404,改为 302 后所有未知路径(包括各操作系统的强制门户检测 URL)都会被重定向到 /。
各平台强制门户检测 URL
| 平台 | 检测 URL |
|---|---|
| iOS / macOS | http://captive.apple.com/hotspot-detect.html |
| Android | http://connectivitycheck.gstatic.com/generate_204 |
| Windows | http://www.msftconnecttest.com/connecttest.txt |
这些 URL 的域名经 DNS 劫持后全部解析到 ESP32,HTTP 请求被 302 重定向,触发各自系统的强制门户弹窗。
启动方式
在 wifi_init() 中,esp_wifi_start() 之后调用 dns_server_start() 即可。
ESP_ERROR_CHECK(esp_wifi_start());
dns_server_start();不需要单独管理 DNS 任务的生命周期——WiFi 初始化后启动,随 ESP32 一直运行。
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。