基于 ESP32-C3 的校园网自动认证:从协议分析到实机问题排查
宿舍校园网采用深澜(Srun)认证,掉线频繁,每天早晨恢复供电后需要手动登录一次。为解决这个问题,我用一块十几元的 ESP32-C3 实现了自动认证。
整体思路是:开发板以 STA 模式接入路由器 WiFi,周期性探测网络连通性,检测到掉线后按浏览器的行为完成一次门户登录。校园网认证按 IP 生效,路由器工作在 NAT 模式,因此开发板完成认证后,局域网内所有设备一并恢复联网。路由器无需刷机,也无需安装插件。
一、协议分析:掉线判定与登录流程
编码之前,先在已认证的电脑上抓取门户请求,确认几项事实。
掉线判定是第一个关键点。门户劫持时 generate_204 返回 200 与一段 HTML,并非常见的 302 跳转。判定逻辑因此必须只接受 204,返回 200 一律视为掉线。
登录流程为标准 Srun 三步:查询状态、获取 token、提交登录。密码经过 HMAC-MD5,info 字段使用自定义 xencode 与变种 base64,最后附带 SHA-1 校验和。完整接口如下:
1 | 连通性探测 http://connect.rom.miui.com/generate_204 |
探测之所以轮换三个不同厂商的 generate_204 端点,是因为单个端点偶发异常会被直接误判成掉线。门户一侧同时保留域名与 IP 两种访问方式,DNS 异常时仍能完成登录。
加密部分参考了 GitHub 上的 Go 实现,逐函数移植到 C++。
ac_id 这类参数不能直接复用:公开项目里多为 20,实际抓包得到的取值是 1,不同校区并不一致。
整体拓扑如下:
二、加密实现与交叉验证
加密代码移植完成后,我做了一次交叉验证:把固件中的 C++ 代码编译到 PC 上执行,与另一份独立编写的 Python 实现逐字节比对。输入使用门户返回的真实 challenge,密码包含中文字符。
首次比对即不一致。定位到两处缓冲区问题:xEncode 中 challenge 长度为 16 个 word,而 key 缓冲区仅按 4 个 word 分配,溢出检查直接返回 0(参考实现实际只读取前 4 个 word);buildInfo 所需字节数比调用方提供的数组多一个。
这两处缺陷在设备上的唯一表现是登录失败,串口日志没有任何可用线索。
验证脚本不能用同一套算法再实现一遍,同构实现不具备验证价值,必须另写一份独立实现。
三、串口无输出:从 CH340 假设到 USB 枚举
固件烧录成功,串口监视器没有任何输出。
最初的假设是 CH340 方案,Serial 映射到 UART0,问题出在监听时机。于是写了一个脚本,在打开串口时拉 RTS 复位开发板,从上电开始抓取日志,仍然没有输出。
第二个假设是 C3 的原生 USB 需要启用 CDC。加入编译标志、调整 DTR/RTS 时序反复尝试,依旧没有输出,其中一次还把开发板复位进了下载模式。
最终依据是 USB 设备枚举记录:VID:PID 为 303A:1001,属于 ESP32-C3 片内 USB-Serial-JTAG,并非 CH340(1a86:7523)。此前参考的引脚文档由 AI 生成,其中”CH340 串口方案”这一条并不成立。真实原因是 IDE 默认未启用 USB CDC,Serial 被编译到 GPIO20/21 上的 UART0,日志始终发往一个不存在的串口。
修复方式只是:在 IDE 菜单中启用 USB CDC On Boot,代码无需改动。
随后遇到工具链版本差异。PlatformIO 使用 esp32 core 2.x 可以编译,Arduino IDE 安装的 core 3.3.11 基于 mbedtls 3.x,编译时报告 mbedtls_sha1_ret 未声明,该 API 在 3.x 中更名为 mbedtls_sha1。改名后两个环境都能通过,再次对拍确认输出未变。此后每处修改,我都用 IDE 的工具链在命令行复现一次编译。
四、LED 误判、RMT 资源耗尽与功耗
随后出现三个现象:板载蓝灯常亮、手动注销后长时间未重登、串口停止输出。三者实际同源。
LED 部分先有判断错误。商家例程中 GPIO8 用于驱动 WS2812,据此把状态指示改成了 RGB 彩色输出;实测反馈是板载只有一颗单色蓝色 LED,那份例程驱动的是外接灯带。
这个误判引入了整个项目中最隐蔽的缺陷。在线状态下,旧代码每次 loop 都调用 neopixelWrite(),频率达每秒数千次。arduino-esp32 3.x 中该函数基于 RMT 外设,高频重复调用会耗尽 RMT 通道,系统随之停止响应。时间线吻合:进入在线状态约二十秒后彻底静默,此时注销自然不会触发重登。
发热来自 WiFi.setSleep(false),射频常开,代价是芯片温度持续偏高。改回 modem sleep 后温度正常,功耗约降三成;30 秒的探测周期完全可以接受 modem sleep 的唤醒延迟。
LED 最终回到 digitalWrite 加闪烁频率的方案:3 秒一短闪表示在线,1Hz 表示连接 WiFi,4Hz 表示认证中,8Hz 表示账号密码错误。选择频率而非亮灭,是因为闪烁模式不受 LED 极性影响。
五、HTTP 层:连接挂起与读超时
这一层耗时最长。加入分层诊断日志后,串口输出把阻塞点定位得很清楚:
DNS 在 2ms 内返回一个被劫持的 IP,这是未认证状态下的正常行为。我在 PC 上验证过,该 IP 本身可达,200ms 即可返回 204,因此问题不在网络,而在 HTTPClient:面对这类劫持网关,其连接状态机会永久挂起,setConnectTimeout 不生效。
修复方式是下移一层:不再使用 HTTPClient,改用 WiFiClient 手写 HTTP/1.0 请求,连接超时通过 connect(host, port, timeout_ms) 三参数重载显式传入。一处改动覆盖了探测、登录与登出。
改用裸客户端后请求可通,但每个请求固定耗时 8 秒,正好等于超时值。原因是劫持网关收到响应后不主动关闭连接,忽略 Connection: close,readString() 只能等待读超时。
改为按 Content-Length 读取固定字节后立即关闭连接;204 响应没有 body,直接返回。单次请求从 8 秒降到数百毫秒。
日志设计上的结论是:操作前后都要输出日志。只在返回后打印,请求挂起时系统没有任何输出,只能知道阻塞发生,无法知道阻塞位置。
六、JSONP 响应残留
功能基本正常后,日志中反复出现 unexpected portal response。对比请求与响应的 callback 名发现问题:登录请求发出的是 …64214,而打印出的响应以 …64116({"challenge" 开头,属于上一步 challenge 请求的残留。login() 中两次请求复用同一个 String body,httpGet 入口未清空,读取时又采用追加方式,登录响应被拼接到残留内容之后。
两次登录实际都成功了,该缺陷仅导致一次多余重试,但它在日志中表现为”门户返回异常”,容易把排查引向协议方向。修复是在 httpGet 入口增加一行 body = ""。
调试阶段把所有间隔参数调小,使完整链路在一分钟内跑完,验证通过后再恢复为探测 30 秒、门户核对 30 分钟、退避 5→10→30→60 秒。另有一项兜底:每 30 分钟强制查询一次门户状态,在线账号不符时执行重登,避免探测结果误判。
关于定时模式的讨论值得一提。宿舍 23 点断电、次日 4:30 恢复供电,最初考虑实现定时认证;实际上断电本身就是定时器,恢复供电后设备上电,固件启动即自动完成连接 WiFi、探测与认证。
发布到 GitHub 前做了凭证扫描。Config.h 中的 WiFi 密码、校园网密码与学号全部替换为占位符,调试期的 PlatformIO 副本与包含真实账号的对拍脚本均未纳入仓库。公开仓库发布硬件项目时这一步不能省略:凭证一旦进入提交历史,删除文件也无法清除。
代码位于 vg188/buct-srun-esp32,同样使用深澜认证系统的学校修改 Config.h 即可使用。
最终部署形态很普通:一块插在路由器 USB 口上的开发板,夜间随断电关机,早晨恢复供电后数秒内完成认证,之后全天保持守护。协议与加密大约只占三分之一的工作量,其余时间都花在实机问题的定位上。