每个校园网最终都会甩给你一个网页,然后把这件事叫做「安全」。你打开浏览器, 被弹到认证门户,输入账号密码,最后落在一个写着「已上线」的页面上。这本来没什么, 直到那台真正需要上网的机器没有浏览器:路由器、无头机器、NAT 后面的构建机。 这时候登录页就不再是一道手续,而是一堵墙。
我们实验室的上行链路,由一套 锐捷 ePortal 认证门户认证,前面套着一层 CAS 单点登录流程。我想要的是校园网自动登录:不需要人、不需要浏览器、 不需要显示器——一个纯 Python 程序,把 Chromium 走的同一串请求重走一遍, 再用一个 systemd 单元把它养着。这篇文章讲的就是:如何靠观察做逆向工程, 把门户变成一个状态机;以及这套守护进程如何在一次强制断线后约 16 秒内重新上线。
那个所有人都在写的老接口,以及它为什么在这里 404
你在网上能找到的「校园网自动登录」脚本,绝大多数都冲着锐捷的经典接口去:
/eportal/InterFace.do?method=login。POST 几个表单字段,网关就放你过去。
确实有成熟项目整套都建立在这个契约上。
但在我们的网关上,这个接口返回 404。这是较新的锐捷 ESS 门户,真正的认证被
委托给一个集中认证服务,浏览器会被重定向着走完一轮 CAS 握手。门户页面甚至带着
__PLATFORM_SSO__='cas-sso' 这个标记。你怎么调那些老参数都没用,因为登录面根本
就不是同一个。
我先看了现有生态。像
lololowe/ruijie-auto-login
这样的工具把老版三态状态机实现得很好,但它说的不是这种「方言」。
Polnareff258/openwrt-campus-auth 针对的是同一类「新版 Portal + CAS」形态,
在单点登录这个思路上是很有价值的参考,但它绑定某一所大学,而且是 OpenWrt/ash 写的。
没有现成能直接用的,于是我针对自己的网关重新把流程反推了一遍,再用 Python 标准库
(外加 openssl 命令行做 AES)重新实现。
靠观察做逆向工程
方法刻意做得很无聊:先让浏览器登录一次,抓下每一跳,然后用脚本重放,哪里断了修哪里。 真正关键的只有六跳。
- 探测一个外部 URL。 未认证时,BRAS 拦截这条 HTTP 请求,返回一小段脚本,
把浏览器重定向到 ePortal —— 一个带客户端 IP 和接入控制器名字的
index.jsp地址。 - 跟进入口。
GET /eportal/index.jsp?...会以 302 回答到/portal/portal-main?...,而查询串里带着全新的sessionId、userIp、nasIp、customPageId和终端信息。这个sessionId由 BRAS 每次认证即时生成, 不能复用,所以程序每次都是先探测、再登录。 - 取回 CAS 挑战。
GET /cas-sso/login?flowSessionId=...返回登录页。有两个元素 很重要:id 为login-page-flowkey的那个(CAS 的 execution 令牌),以及 id 为login-croypto的那个(一段 base64 编码的 16 字节 AES 密钥,每次加载页面都重新生成)。 - 提交凭据。 表单 POST 到
/cas-sso/login,带上type=UsernamePassword、_eventId=submit、execution、croypto、明文用户名,以及用那把密钥加密的密码 ——AES-128-ECB,PKCS7 padding 之后再 base64。成功是一个指向认证成功页的 302; 失败则返回带错误码的页面。 - 走完工作流。 单点登录成功未必就是终点。门户随后会要求客户端走完一个小工作流:
POST /eportal/workFlow/getCurrentNode返回当前节点路径,客户端据此 POST 到/eportal/network/deviceAuthentication,轮询/eportal/network/deviceAuthResult,在serviceSelection节点选择运营商服务 (ctcc、cmcc或unicom),再调用/eportal/network/serviceLogin。 - 验证。 重新探测那个外部 URL。如果拦截页消失了,我们就在线了。
这就是经典的黑盒逆向:不反编译、不对网关做模糊测试——只是读重定向链和表单体, 直到形态足够清晰,再把那个形态编码成数据。
CAS 单点登录里真正要紧的细节
关于 CAS 这一跳,有几点得老实说,因为大部分移植都是栽在这里:
- 那把 AES 密钥不是秘密。
login-croypto每次加载页面重新生成,并直接写进 HTML 发给客户端。加密密码是为了让凭据不能以明文被简单重放,它并不是传输加密。 用户名仍然明文发送,密钥本身也能从页面里读出来。这也正是整套东西只在门户自己的 通道里才说得通的原因。 - 挑战是有状态的。
execution和croypto属于同一次页面加载。如果你取一次页面、 过一会儿再重试 POST,execution 已经过期,CAS 会拒绝——所以客户端每次尝试都重新取挑战。 - 有一道验证码闸门。 提交前,客户端会问一个
captchaCount/validate接口是否需要 验证码。在我们的部署里正常密码认证从不触发它,但这个检查留着,是为了让守护进程 「响亮地失败」,而不是对着门户死循环猛敲。 - 不需要浏览器。
urllib加http.cookiejar就够处理 cookie 和重定向了;AES 交给openssl enc -aes-128-ecb。第三方依赖数量因此是零。
状态机
我不想要一个假设每步都会成功的线性脚本。客户端是一台状态机,每一轮循环顶部都先做一个 明确的「我在线吗?」探测:
- probe → 在线?休眠、心跳、继续。离线 → 进入发现流程。
- discover → 抓取
sessionId、userIp、nasIp、customPageId。 - cas → 取挑战、加密、提交;出现 3xx 即视为被接受。
- workflow → 在有界次数内循环
getCurrentNode,把设备节点和服务节点分发出去, 直到路径走到finish。 - verify → 再次探测;若仍离线,就把整次尝试判为失败。
有界循环和显式失败不是过度谨慎。对着一个要求验证码的门户做无界重试,正是账号被锁的 标准姿势。
# 1) 只做发现——从不发送凭据,任何时候都能安全运行
$ sudo python3 ruijie_login.py --capture
{
"sessionId": "…",
"userIp": "10.19.x.x",
"nasIp": "10.254.x.x",
"customPageId": "…"
}
CAS login page: http 200, 48xxx bytes
login-page-flowkey: <execution 令牌> …
login-croypto : <base64 编码的 16 字节 AES 密钥>
# 2) 真实登录一次,然后退出
$ sudo python3 ruijie_login.py --once
[…] captive sessionId=… userIp=… nasIp=…
[…] CAS accepted (302 -> /portal/assets/auth-success.html?ticket=ST-…)
[…] workflow node: serviceSelection
[…] serviceSelection candidates: ['ctcc', 'cmcc', 'unicom']
[…] workflow node: finish
[…] LOGIN OK - now online
注意 --capture 这个动词。留一个只走发现、只打印挑战、绝不发送凭据的模式,
值得花那十来行代码:它是你在门户悄悄改版时安全调试的方式,也是你确认自己读对了
页面的方式。
把它部署成 systemd 服务
客户端就是一个文件,带 --status、--capture、--dry-run、--once、--logout
以及守护模式。部署就是一个很小的单元:
[Unit]
Description=Ruijie ePortal auto-login (campus network keep-alive)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/ruijie-login/ruijie_login.py \
--config /etc/ruijie-login/config.json
Restart=always
RestartSec=15
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
守护进程的行为刻意保守。每 20 秒一次心跳,而且只有在探测判定为离线时才真正尝试登录
——它不会去拆掉一条正好好工作着的会话。失败时按指数退避(30s → 60s → … 封顶 30 分钟),
所以密码输错也不会把账号锁死。配置权限是 0600,因为它存着明文的校园网密码。
日志进 journal,另外有一个手机端控制脚本,让我在实验室想用自己的账号时,能通过 overlay
网络从手机上停掉或重启这个服务。
16 秒重连
一个声称能重连的守护进程,必须拿一次真实断线来验证。我用最老实的方式制造了一次: 改掉校园上行网口的 WAN MAC。
这会作废 BRAS 上的 MAC 绑定,路由器拿到一个新的校园 IP,网关把它踢回认证状态。 服务随后大致打出:
[0] status before: ONLINE
[1] new WAN MAC -> 新的校园 IP,绑定失效 -> 离线
captive sessionId=… userIp=10.19.x.x nasIp=10.254.x.x
CAS accepted (302 -> /portal/assets/auth-success.html?ticket=ST-…)
workflow node: serviceSelection
serviceSelection candidates: ['ctcc', 'cmcc', 'unicom']
workflow node: finish
LOGIN OK - now online
从强制断线到重新上线,全程约 16 秒。这个数字不是什么魔法常数——它就是下一个 20 秒
检查周期,加上登录往返的开销;同时配置里把服务选择显式钉成 ctcc,所以工作流不会停在
那里等人输入。如果你把 service 留空,客户端就取门户给出的第一个候选。
把合法性说清楚
这套东西自动化的是我自己对一张我有权使用的网络的认证访问,用的就是我会敲进浏览器的 同一套凭据。它不绕过付费、额度或任何访问控制,也从不碰别人的账号,对网关的访问不超出 浏览器自己会调用的那些接口。它没有凭据就拒绝运行,被要求验证码就拒绝循环,动手之前 先探测。如果你们校园的政策禁止无人值守客户端或改 MAC,请遵守政策。不要把这类工具指向 你不拥有、也未被授权使用的网络。
我会对下一个人说的话
如果 /eportal/InterFace.do?method=login 返回 404,别再调你的参数了——你在 SSO 变体上,
那份老契约根本不存在。去读重定向链。把 sessionId 当成一次性的,每次重新获取。
先把「我在线吗?」这个探测写出来,因为其它一切都挂在它上面。以及,在写登录模式之前,
先写只做发现的模式;你会更常用到它。
支撑这一切的基础设施——自建的 Git 服务、overlay 网络、那台构建并伺服本站的机器—— 正是这个博客如何发布它自己的主题,如果你想要 更大的图景,那里是最好的起点。一个无头登录守护进程,不过是 同一条流水线上的又一个服务:读、跑、验证、重复。