ESP32 AP 模式下通过 DNS 劫持实现强制门户跳转

ESP32 开启热点时,需要一个机制让连接上来的设备自动打开控制面板网页,而不是让用户手动输入 IP 地址。这个机制在酒店/机场 WiFi 中很常见——连上热点后自动弹出一个登录页面,这就是强制门户(Captive Portal)。

实现强制门户需要三个环节配合:DHCP 通告 DNS、DNS 劫持、HTTP 302 跳转。

整体流程

  1. 设备连接 ESP32 热点,通过 DHCP 获取 IP,同时被告知 DNS 服务器地址为 192.168.4.1
  2. 操作系统在后台发送强制门户检测请求(如 http://captive.apple.com/hotspot-detect.html
  3. DNS 查询被 ESP32 上的 DNS 服务拦截,全部返回 ESP32 自身 IP
  4. HTTP 请求到达 ESP32 的 Web 服务器,未知路径返回 302 重定向到 /
  5. 操作系统收到非预期响应,识别为强制门户,弹出浏览器窗口

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 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。