前言
之前深圳大学的校园网认证系统使用的是 drcom,根据 drcom 的认证流程,我曾写过一个登录脚本(github 地址:https://github.com/Caterpie771881/login_szu_network)主要用于在命令行环境下进行校园网的登陆
在 2025 年 1 月份,寒假期间,深圳大学将教学区的认证系统改为了深澜系统,也就是标题的 srun。该认证系统的工作流程和 drcom 有非常大的差别,原登录脚本在教学区已经彻底失效。而在 2025 年 2 月底,深大 Aurora 社团有一场面向校内学生的 CTF 比赛,我需要维护靶场。搭建靶场所用的机器是没有图形化环境的,为了让靶场能够正常连接上校园网,我需要重写一个适用于 srun 系统的登陆脚本
深澜系统认证流程分析
直接上 BurpSuit 来抓一下包,首先能抓到很多 js 文件,认证系统的前端逻辑基本上能从这里分析出来
直接啃这一堆 js 是很折磨人的,决定先尝试登录,从报文里分析
随便填入账号密码尝试登陆
能抓到三个关键的报文,这三条报文就是 srun 认证的全流程
获取图片验证码
第一条报文访问了 /v2/srun_portal_captcha_image_info
,顾名思义是一个获取图片验证码的接口
下面是该接口的入参列表
查询参数 | 备注 |
---|---|
user_name | 登录用户的学号 |
ip | 登录设备的ip |
在登陆的过程中我似乎还没遇到过需要验证码的情况,这个可能是未来会加入的功能,或者说在多次密码错误后才会激活这个功能
暂时不测试更多的情况,目前该接口只会返回一个指示请求是否成功的 json 数据
获取加密 token
第二条报文访问了 /cgi-bin/get_challenge
,这是一个用于获取加密 token 的接口
该接口的入参列表
查询参数 | 备注 |
---|---|
callback | 用来做 jsonp 回调的,经测试,这里随便填都行,对认证结果没啥影响,反正登录脚本又不需要 jsonp 回调 |
username | 登录用户的学号 |
ip | 登录设备的ip |
_ | 一眼盯真,鉴定为时间戳(保留三位小数并去掉小数点) |
相比于获取图片验证码的接口,这个接口的返回值就很值得分析了
jQuery112404438938228446587_1740044202618({
"challenge":"99e8c72f372b6d6628cbca5ede21f3b8cb8bcee63db64e531ef3c4c190a50cb2",
"client_ip":"192.168.*******",
"ecode":0,
"error":"ok",
"error_msg":"",
"expire":"60",
"online_ip":"192.168.*******",
"res":"ok",
"srun_ver":"SRunCGIAuthIntfSvr V1.18 B20241024",
"st":1740044376
})
很经典的 jsonp 回调格式,使用 jQuery112404438938228446587_1740044202618
包裹整个 json 对象
client_ip
和 online_ip
就是登录设备的ip
ecode
、error
、error_msg
、res
用于指示系统状态,供客户端判断此次请求是否成功
srun_ver
就是 srun version 的意思,指示认证服务的版本
st
是一个时间戳
比较关键的是 challenge
和 expire
,challenge 目前用途不明(标题已经剧透了,是加密 token)expire 顾名思义,是用来表示有效期的,结合接口中使用了时间戳,猜测第三条报文要在有效期(60 s)以内发送才算有效,对登录脚本的撰写思路没什么影响
加密用户信息、正式认证
第三条报文是最关键的,访问了 /cgi-bin/srun_portal
接口,并且携带了大量敏感参数
该接口的入参列表
查询参数 | 备注 |
---|---|
callback | 用来做 jsonp 回调 |
action | 没分析出来是什么框架,总之这个 Controller 有 login 和 logout 两个 action |
username | 登录用户的学号 |
password | {MD5} + 一串密文 |
os | 登录设备的os |
name | 登录设备的os |
nas_ip | 测试过程中一直置空,没用过 nas,不知道什么时候会带上这个参数 |
double_stack | 用来标识是否使用“双栈认证”(注释里说的,至于什么是双栈认证我就不知道了) |
chksum | 一串 sha1 签名,用来保证消息可靠性的常用手段 |
info | {SRBX1} + 一串密文 |
ac_id | 一个常量 |
ip | 登录设备的ip |
n | 一个常量 |
type | 一个常量 |
captchaVal | 验证码(如果不需要输入验证码的话,这里为空) |
_ | 时间戳 |
可以看到这次网络请求携带了 password
这类敏感信息,并且从响应来看,这次就是真正的认证请求了
那这堆入参是怎么来的,除了明文内容和一些常量,剩下的就要看 js 的加密逻辑
需要分析的入参:password
、chksum
、info
用户信息加密流程分析
首先从 password 的 {MD5}
头部入手,由于 srun 的前端代码几乎没有混淆(甚至保留了注释)很轻易就能找到加密逻辑
位于 /static/themes/pro/js/Portal.js
到这边一看,发现 chksum 和 info 的加密逻辑都在这里了。为了观感还是一点一点来分析
password 的加密逻辑
// 用户密码 MD5 加密
var hmd5 = md5(password, token); // 用户信息加密
md5 函数在 /static/themes/pro/lib/all.min.js
从入参来看,带了明文和 token,应该是 hmacmd5
那这个 token 是哪来的?请看深澜软件给我们准备的注释
结合前面对接口返回值的分析,大概能猜出接口 /cgi-bin/get_challenge
的出参 challenge
就是 token
获取 token 的具体逻辑在这里
chksum 的加密逻辑
可以看到这个字段就是一个 sha1 的签名
str 从下面的逻辑得到
var str = token + username; // token + 登录用户的学号
str += token + hmd5; // token + hmac-md5 加密后的 Password
str += token + ac_id; // token + 常量
str += token + ip; // token + 登录设备的ip
str += token + n; // token + 常量
str += token + type; // token + 常量
str += token + i; // token + info 字段的值
这里除了 i
,其他都已经有了。那几个常量在附近就能找到
求 chksum 前,还要先把 info 也求出来
info 的加密逻辑
这个地方的加密稍微复杂,逻辑如下:
var i = _classPrivateFieldGet(
_assertThisInitialized(_this),
_encodeUserInfo
).call(_assertThisInitialized(_this), {
username: username, // 登录用户的学号
password: password, // 登录用户的密码(明文)
ip: ip, // 登录设备的ip
acid: ac_id, // 常量
enc_ver: enc // 常量
}, token); // token
_encodeUserInfo.set(_assertThisInitialized(_this), {
writable: true,
value: function value(info, token) {
var base64 = _this.clone($.base64);
base64.setAlpha('LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA');
info = JSON.stringify(info);
function encode(str, key) {
if (str === '') return '';
var v = s(str, true);
var k = s(key, false);
if (k.length < 4) k.length = 4;
var n = v.length - 1,
z = v[n],
y = v[0],
c = 0x86014019 | 0x183639A0,
m,
e,
p,
q = Math.floor(6 + 52 / (n + 1)),
d = 0;
while (0 < q--) {
d = d + c & (0x8CE0D9BF | 0x731F2640);
e = d >>> 2 & 3;
for (p = 0; p < n; p++) {
y = v[p + 1];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF);
}
y = v[0];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD);
}
return l(v, false);
}
function s(a, b) {
var c = a.length;
var v = [];
for (var i = 0; i < c; i += 4) {
v[i >> 2] = a.charCodeAt(i) | a.charCodeAt(i + 1) << 8 | a.charCodeAt(i + 2) << 16 | a.charCodeAt(i + 3) << 24;
}
if (b) v[v.length] = c;
return v;
}
function l(a, b) {
var d = a.length;
var c = d - 1 << 2;
if (b) {
var m = a[d - 1];
if (m < c - 3 || m > c) return null;
c = m;
}
for (var i = 0; i < d; i++) {
a[i] = String.fromCharCode(a[i] & 0xff, a[i] >>> 8 & 0xff, a[i] >>> 16 & 0xff, a[i] >>> 24 & 0xff);
}
return b ? a.join('').substring(0, c) : a.join('');
}
return '{SRBX1}' + base64.encode(encode(info, token));
}
});
这里用到了换表 base64 和 srun 自创的 encode 函数,登录脚本需要实现这个莫名奇妙的 encode 加密
不需要细讲这里的加密逻辑,反正我们只需要知道加密过程然后模仿即可
求出 info 后,chksum 也就能求出来了。然后正式认证需要的参数就齐全了
认证流程总结
由于目前获取验证码的接口没有意义,所以只针对 获取 token -> 加密用户信息 -> 正式认证 进行总结
用户输入明文账号密码
访问 /cgi-bin/get_challenge
,返回值 challenge 作为加密 token
然后加密用户信息
-
hmd5 => hmac-md5 加密,msg 为用户密码,key 为前面获取的加密 token
-
info => encode 加密一个包含用户信息的 json 数据,然后对加密结果进行换标 base64 编码
-
chkstr => token、用户名、hmd5、ip、info、加密常量 的拼接
-
chksum => sha1 哈希后的 chkstr
最后访问 /cgi-bin/srun_portal
进行正式认证
流程图:
python 登录脚本
省流:已经将脚本整理到 github 了:https://github.com/Caterpie771881/szu_srun_client
获取 token
这一步使用 requests 简单发包即可
import requests
from urllib.parse import urlencode
username='你的学号'
password='你的密码'
ip='你的ip'
callback = "jQueryCallback"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
params = urlencode({
"callback": callback,
"username": username,
"ip": ip
})
resp = requests.get(
url=f"http://net.szu.edu.cn/cgi-bin/get_challenge?{params}",
headers={"User-Agent": UA}
)
get_challenge = resp.text[len(callback)+1:-1]
token = json.loads(get_challenge)['challenge']
加密用户信息
这里比较复杂,需要调用几种加密算法
# 加密常量
TYPE = "1"
N = "200"
ENC = 'srun_bx1'
ACID = "12"
# 加密 password
hmd5_password = hmd5(password, token)
# info 字段
info = info_({
"username": username,
"password": password,
"ip": ip,
"acid": ACID,
"enc_ver": ENC
}, token)
# chksum 字段
chksum = sha1(chkstr(token, username, hmd5_password, ACID, ip, N, TYPE, info))
hmac-md5 和 sha1
import hashlib
def hmd5(msg: str, key: str) -> str:
import hmac
msg = msg.encode()
key = key.encode()
return hmac.new(key, msg, hashlib.md5).hexdigest()
def sha1(msg: str) -> str:
return hashlib.sha1(msg.encode()).hexdigest()
chkstr 的实现
def chkstr(token: str, username: str, hmd5: str, ac_id: str, ip: str, n: str, type_: str, info: str) -> str:
result = token + username
result += token + hmd5
result += token + ac_id
result += token + ip
result += token + n
result += token + type_
result += token + info
return result
换表 base64 的实现
import base64
from typing import Union
def trans_b64encode(s: str, alpha: Union[str, None] = None) -> str:
result = base64.b64encode(s.encode(encoding='latin-1')).decode()
if not alpha:
return result
assert len(alpha) == 64, "base64字母表的长度必须为64"
table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
trans_table = str.maketrans(table, alpha)
return result.translate(trans_table)
深澜自创 encode 的实现
这个部分稍微有些麻烦(其实照着 js 的逻辑改改就行)
class String(str):
def charCodeAt(self, i: int) -> int:
if len(self) > i:
return ord(self[i])
return 0
@classmethod
def fromCharCode(cls, *charCodes: list[int]) -> str:
result = ''
for c in charCodes:
result += chr(c)
return result
def s(a: String, b: bool) -> list[int]:
c = len(a)
v = []
for i in range(0, c, 4):
v.append(a.charCodeAt(i)
| a.charCodeAt(i + 1) << 8
| a.charCodeAt(i + 2) << 16
| a.charCodeAt(i + 3) << 24)
if b: v.append(c)
return v
def l(a: list[int], b: bool):
d = len(a)
c = (d - 1) << 2
if b:
m = a[d - 1]
if m < c - 3 or m > c:
return None
c = m
for i in range(0, d):
a[i] = String.fromCharCode(
a[i] & 0xff,
a[i] >> 8 & 0xff,
a[i] >> 16 & 0xff,
a[i] >> 24 & 0xff
)
return "".join(a)[0:c] if b else "".join(a)
def encode(str_: str, key: str) -> str:
"""srun 发明的一种莫名奇妙的加密方式"""
import math
str_, key = String(str_), String(key)
if str_ == "": return ""
v = s(str_, True)
k = s(key, False)
if len(k) < 4:
k = k + [0] * (4 - len(k))
n = len(v) - 1
z = v[n]
y = v[0]
c = 0x86014019 | 0x183639A0
m = 0
e = 0
p = 0
q = math.floor(6 + 52 / (n + 1))
d = 0
while 0 < q:
d = d + c & (0x8CE0D9BF | 0x731F2640)
e = d >> 2 & 3
p = 0
while p < n:
y = v[p + 1]
m = z >> 5 ^ y << 2
m += y >> 3 ^ z << 4 ^ (d ^ y)
m += k[p & 3 ^ e] ^ z
z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF)
p += 1
y = v[0]
m = z >> 5 ^ y << 2
m += y >> 3 ^ z << 4 ^ (d ^ y)
m += k[p & 3 ^ e] ^ z
z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD)
q -= 1
return l(v, False)
info_ 的实现
import json
def info_(info: dict, token: str) -> str:
alpha = 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA'
json_data = json.dumps(info).replace(' ', '')
# 这里要用的前面写的换表 base64 和 encode
result = trans_b64encode(encode(json_data, token), alpha)
return f"{{SRBX1}}{result}"
进行正式认证
一样是使用 requests 发包,把加密好的用户数据包装到查询参数即可
params = urlencode({
"callback": callback,
"action": 'login',
"username": username,
"password": '{MD5}' + hmd5_password,
"os": 'Windows',
"name": 'Windows',
"nas_ip": '',
"double_stack": 0,
"chksum": chksum,
"info": info,
"ac_id": ACID,
"ip": ip,
"n": N,
"type": TYPE,
"captchaVal": '',
'_': int(time.time() * 1000)
})
resp = requests.get(
url=f"http://net.szu.edu.cn/cgi-bin/srun_portal?{params}",
headers={"User-Agent": UA}
)
result = json.loads(resp.text[len(callback)+1:-1])
if result.get('res') == 'ok':
print("登录成功")
后记
1、drcom 系统使用的是单步认证,账号密码在查询参数中是明文传输的。srun 传输的是加密过后的用户信息,妈妈再也不用担心我的密码在传输过程中被泄露了。但是为何还是用查询参数来传输敏感数据,能不能用一下 POST,然后上一个 https,更能保证安全
2、srun 居然支持多设备同时在线,泪目了;srun 居然还支持基于 mac 地址的无感认证,更感动了。一个校园网认证系统能做到这个地步我已经很满足了(
3、srun 和 drcom 一样拦不住 IPv6 的流量,哈哈哈