前几天有一个实训作业,突发奇想想去把智慧珞珈的API扒下来集成到agent里面当作业交了。然而事实上这个事情比我想象中的难一点,权且在此做个记录
教务系统
其实一开始做课表查询的时候用的是智慧珞珈首页的API,非常简便无需操作(https://zhlj.whu.edu.cn/whdxSchedule/getScheduleData)
然而这个API的缺点是无法查看非本学期的课表,甚至非本周的课表,于是只能用教务系统的课表
再次扒了一下教务系统的课表API是在https://jwgl.whu.edu.cn/kbcx/xskbcx_cxRjc.html?gnmkdm=N2151
然而有一个大难题,每次在教务系统进行操作时总需要进行验证码验证操作,这是个对于自动化查询很头疼的事情
我们最初的解决方案是playwright让用户自己操作
playwright有一个不便的地方:弹出浏览器只在本地环境下可用,如果以后部署到服务器上是难以交互的
当然最初我们的登录也完全依赖playwright,对此在后续探索中我们解决了这个问题
但是在一遍挖掘过后,发现一个非常有意思的事情
我们想知道验证码参与验证的地方在哪里,于是查看的请求源码
发现POST内其实只提交了表单数据,没有验证码参与的痕迹
于是查看请求头,可能与验证码有关的只有cookies里的两项
e.g.
_dx_uzZo5y=xxxxx
_dx_captcha_vid=xxxxx
我们尝试去掉该条目,发现在登录cookie有效的情况下,请求得到了完美的响应
也就是说验证码验证是完全没有必要的,那么通过简单的request就能得到我们想要的内容
哈哈哈哈草台班子
再加一些对返回json的处理和对需求的转义,我们完美实现了这个功能
进而以此类推,我们能够在无需验证码的情况下把教务系统中的很多内容扒下来,比如考试地点查询
当然由于时间问题,我们并没有加入很多的功能
图书馆系统
由于图书馆系统分别于智慧珞珈与教务系统,我们处理的难度非常大,这也是这个项目最难的地方
首先,第一个问题是在登录进入图书馆系统后,系统会分配一个时效很短的token,且每次请求都会验证这个该死的token,除此以外还有时间戳与HMAC验证,有防盗链的存在。当然这些在工作前期时截取请求头内容似乎也能进行请求重放
第二个问题是在预约时无法绕开的验证码。预约api必须附带一个验证码给的capToken才能通过,这又是一个麻烦的事情
其实在查询图书馆座位时并没有遇到太大的问题,就是扒下来实现请求与处理响应的过程有点困难,可能最大的挑战在验证码的绕过。
构造的验证码
在一开始着手解决这个问题时最先考虑的是验证码问题,因为其他验证信息是可获取并重放的(呃也许?)。当然验证码脚本是放在前端的,前端加载了这些脚本

以及一些验证码相关的API
稍微尝试了一下,难以绕过验证码的存在
作为一个小白,真是一点思路都没有。好在我们有AI
我们先分析了一下js文件。crypt相关的js是加密库,其他脚本也无关紧要,着重放在app.js与tac.min.js
然后照例看一下验证码请求
https://seat.lib.whu.edu.cn/jsq/static/cap/cg/gen/SLIDER 通过POST传了一个不明意义的custom和ki两个参数的乱码 ,大概是加密过,返回了验证码背景图和滑块图及其参数
https://seat.lib.whu.edu.cn/jsq/static/cap/cg/check 通过POST传几个加密过的参数,除了custom和ki还传了data和id,返回的是验证码的校验值,如果成功返回一个可用的captoken
第一个思路是希望验证码系统有没有什么纰漏,可以直接把完成验证码的过程绕过。事实上这个系统用的是魔改的开源TianaiCaptcha,然而经过一番尝试(也就扒了扒源码),有一些实现的细节并不能对上(比如验证码验证时的字段名称)
于是只能采用第二个思路:手动实现验证码自动化。然而构造的tac.min.js加了一点点混淆(不过没什么影响,app.js也加了一点模块路径混淆,我们主要集中考虑app.js,这里面把网站操作核心都表现出来了)
可惜了,我们有ai,防不住啊哎哎哎哎嘿嘿嘿嘿
ai一通分析,大致分析出了整个过程

同时我们也在app.js截取到了公钥,同时也抓出了payload构造值
最勾槽的事情发生了,check提交后返回值为500
这是什么意思,如果是OpenCV做的自动对齐有问题,也应该返回4000或者4001,怎么样都不可能是500
500说明我们传递的结构是有问题的,但具体是哪个参数并不清楚
事实上我们一开始传给服务端的custom和ki都是在浏览器截取的固定值,没有变动,理论上应该是没有问题的,也能够正常返回验证图片,那么问题大概就在check里面
其中我们知道custom传的是用户信息,而ki有sessionKey与iv两个随机值加密而来,data是用户操作轨迹,id是gen请求响应时给的
这个时候我其实已经昏了头了,一直在和ai扯皮,但就是不知道哪个环节出了问题,甚至把data结构改成了原生TianaiCaptcha形式(whu的data结构如下)

不行了实在撑不住了
最后冒出来的想法是把验证码嵌到网页里让用户帮帮忙我快死掉了
唉但想到ham的图书馆接口不需要验证码啊
一定有解法
本来想去蹭ham的图书馆接口,翻了半天ham仓库——他根本不开源
不过翻了翻commit日志,貌似ham也无法绕开验证码机制,调用了腾讯云的接口来解决(也许)
不知道是不是瞎解读,反正掐灭了我想绕开验证码的最后希望
怎么解决呢?如果是500的话,要么明文结构有问题,要么加密密钥出了问题
先看看密钥,稍微尝试一下,ai给我们的密钥确实是有问题的,貌似给的是tac的原生默认密钥,我们把app.js的密钥找了出来替换掉
———没有改变,依旧500
我错了?ai错了?代码错了?
ai怀疑是js加密库与py的加密库有些许的差别
又花了点时间来验证他的猜想,尽管他一再坚持,哪怕真的验证一遍,结果都一样,加密算法没有任何问题
同样的过程,检查了图片格式问题,也一无所获,图片格式在gen时就给的一清二楚
叹了口气,起来扒拉扒拉头发,喝了口水,舍友已经睡了几个小时了,我桌位的灯还是亮着,但我的思绪和他们一样空无
好吧,让我们看一下参数加密前的明文找找规律吧
ai花了点时间把插桩代码给我了,输进控制台看看结果
哦吼吼吼吼,问题出现了
在之前custom的明文结构,ai根据密文长度判断为
{"username": "...", "session": {"current_window_url": "..."}}
其实真的猜的大差不差,然而正确答案是
{"session": {"username": "...", "current_window_url": "..."}}
其中username是学号,url是图书馆网址
稍作改动
我操,通了,4000
妈的
那个时候我想哭
具体记不清了,可能还经历了一些问题,比如怀疑cookie相关,部分问题如下,但不重要了,重要的是已经做完了

好了,一个难点打通了
但是OpenCV算法成功率实在太低太低,低到我没有成功看到过success的响应
怎么办呢?
我又上TAC仓库一顿翻找,一无所获。正当我反复刷新着预约系统的验证码时
“我草,这图怎么一点变化都没有啊”
不会每次请求都只返回一种图吧
然而下一次刷新又变成了另一种
“他不会验证码图库只有两张吧”
正是如此
吗的
草台班子
接下来的事情就简单了,通过正确的已知的custom和ki反复请求gen,把挖去不同部分的背景图下载下来,聚类成两类,对每类的所有图,每个像素取众数,于是我们得到了完美的原图。(其实每类图返回三张应该就够了)
接下来的事更简单了,对于每次请求给出的背景图,与我们得到的原图差分找到坐标,我们甚至不需要用到滑块图就能解决。
然后再把之前硬编码的custom和ki改造一下
success
太好了太好了,封装封装,这个模块解决了
尽管展示起来一句话的事情他就解决了,废了我不少功夫啊啊啊啊啊啊
HMAC
然而验证码的模块相对主系统来说一直比较独立,我们成功地拿到了验证码的解决方案,但主业务系统还没有研究过
事实上api一直是比较好扒的,把payload格式化一下就行,如果token过期就通过CAS重新获取(最初用的playwright重新登录,非常麻烦)
但是hmac防盗链发力了,header必须包含如下字段
x-hmac-request-key:xxx(签名)
x-request-date:xxx(时间戳)
x-request-id:xxx(随机uuid)
如果没有,我们会得到 “鉴权失败” 的结果
当然,有了前面的经验,这部分简单很多
加密逻辑全在app.js里面(app.js也包含了一些api)
经过ai的还原,大致为
// app.js 中的 Axios 拦截器
axios.interceptors.request.use(function(config) {
if (config.url.indexOf('/frontApi/') !== -1) {
var uuid = generateUUID();
var ts = Date.now();
var signStr = "seat::" + uuid + "::" + ts + "::" + config.method.toUpperCase();
var hmacKey = I.decrypt(sessionStorage['jsq_p-systemInfo'].hmacKey);
var sig = CryptoJS.HmacSHA256(signStr, hmacKey).toString();
config.headers['X-request-id'] = uuid;
config.headers['X-request-date'] = ts;
config.headers['X-hmac-request-key'] = sig;
}
return config;
});
/*
签名输入 = "seat::" + UUID + "::" + 毫秒时间戳 + "::" + 大写HTTP方法
签名算法 = HMAC-SHA256
签名的密钥 = I.decrypt(sessionStorage['jsq_p-systemInfo'].hmacKey)
*/
然而我们仍未得到签名密钥hmacKey
尝试在sessionStorage内查找
sessionStorage.getItem('jsq_p-systemInfo')
就能得到密钥
但是我们得到的密钥仍是AES-CBC加密存储的Base64密文,因而仍然需要再次逆向
对于解密函数
I.decrypt = function(encryptedB64) {
var ct = CryptoJS.enc.Base64.parse(encryptedB64);
var key = CryptoJS.enc.Utf8.parse("server_date_time"); // 16 bytes
var iv = CryptoJS.enc.Utf8.parse("client_date_time"); // 16 bytes
var decrypted = CryptoJS.AES.decrypt(
{ciphertext: ct},
key,
{iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7}
);
return decrypted.toString(CryptoJS.enc.Utf8);
};
ds大人的结论

在浏览器控制台用页面自带的 CryptoJS 跑一遍,我们会发现一个很有趣的结果

whu2024lib
小巧思啊小巧思
有了密钥就简单了
# HMAC 签名生成
def _make_signed_headers(token, hmac_key_val="whu2024lib", method="POST"):
key = _get_hmac_key(hmac_key_val)
rid = str(uuid.uuid4())
ts = str(int(time.time() * 1000))
sign_str = f"seat::{rid}::{ts}::{method.upper()}"
sig = hmac.new(key, sign_str.encode(), hashlib.sha256).hexdigest()
return {
"token": token, "loginType": "PC",
"X-request-id": rid, "X-request-date": ts, "X-hmac-request-key": sig,
}
大功告成!
剩下的API照常扒一扒处理处理就行
有趣的是在展示之前测试时发现一个很致命的问题:只有我能够成功预约,其他人不行
构造的ai这个时候变傻子了,跟我说是验证码验证时cookie问题,我也是昏了头了试半天
然后翻了半天代码发现代码里传的默认值一直是我的学号,由于调用tool时没传进正确的学号,在请求时一直用的我的学号,所以只有我能成功,笑昏了XDXD
CAS登录
回到之前提到的一个问题:对于这个项目我希望能在服务器上部署,但是playwright处理登录的方式一直让这个想法无法实现
由于在考虑这个问题时时间已经所剩无几,所以又熬了一天打算用一个最简单的方法————纯HTTP代理。通过服务器转发流量把实时登录页面呈现给用户,用户手动登录后截取CAS返回的cookie和CASTGC凭证保存下来,然后用服务器无头playwright拿着CAS的凭证继续拿其他网站的cookie
但是不知道为什么老是登录失效,拿不到东西,每次点登录都重定向到cas页面。探索的时候我才知道CAS居然没有做验证码验证机制,免费进点
我的天哪
……牛逼
再熬一熬把登录网关做出来
CAS的难点在于登录流程HTTP交互环节,加密环节我们已经很熟悉了
通过DS的分析我们得知CAS登陆页面的HTML有两个隐藏字段

接着通过CAS加载的encrypt.js逆向密码加密逻辑
// encrypt.js 核心逻辑
function encryptPassword(password, salt) {
var random64 = randomString(64); // 64 位随机字符
var plaintext = random64 + password; // 随机前缀 + 真实密码
var key = utf8Encode(salt); // salt 当 AES key
var iv = randomString(16); // 16 位随机字符串当 IV
var encrypted = AES_CBC_PKCS7(plaintext, key, iv);
return base64(encrypted);
}
大致如此
接下来的分析都是由AI做出(其实大部分都是,除了一部分比较难的判断,对于这种代码与请求分析AI还是比人好用,更何况其实我根本看不出什么)

不过这么暴露是不是不大好……
看个乐呵就好,欢迎复现
当然我们没有实现短信登录,毕竟又是另一个版本的滑块验证码,而我已经烦了
到此为止,整个登录tool已经优化的差不多了
这个时候已经5点了,孩子们洗洗睡了
略微的总结一下
总的来说还是一次比较新颖的尝试的,不足的是还是留了不少playwright操作,比较占用资源,另一方面对话的缓存命中率实在低得惊人,烧大钱了,有机会的话把playwright与上下文优化了就好很多
当然,欢迎whuer来玩一下LuojiaAgent
尽量用电脑吧毕竟UI实现不是很完整,对手机端不是很友好
有机会的话把这个写成MCP让claude code用用试试哈哈哈
毕竟手写的agent比不过这么成熟的体系的,没那么好用和便捷
哈哈哈还是要吹吹牛逼,Ham上请求信息可是要验证码的哟哈哈哈
希望不要被制裁了,hmac或者验证码一变就完蛋了
仓库地址github-LuojiaAgent
图书馆api仓库地址github-whu-lib-api

Comments | NOTHING