前言

之前深圳大学的校园网认证系统使用的是 drcom,根据 drcom 的认证流程,我曾写过一个登录脚本(github 地址:https://github.com/Caterpie771881/login_szu_network)主要用于在命令行环境下进行校园网的登陆

在 2025 年 1 月份,寒假期间,深圳大学将教学区的认证系统改为了深澜系统,也就是标题的 srun。该认证系统的工作流程和 drcom 有非常大的差别,原登录脚本在教学区已经彻底失效。而在 2025 年 2 月底,深大 Aurora 社团有一场面向校内学生的 CTF 比赛,我需要维护靶场。搭建靶场所用的机器是没有图形化环境的,为了让靶场能够正常连接上校园网,我需要重写一个适用于 srun 系统的登陆脚本

深澜系统认证流程分析

直接上 BurpSuit 来抓一下包,首先能抓到很多 js 文件,认证系统的前端逻辑基本上能从这里分析出来

img

直接啃这一堆 js 是很折磨人的,决定先尝试登录,从报文里分析

随便填入账号密码尝试登陆

img

能抓到三个关键的报文,这三条报文就是 srun 认证的全流程

img

获取图片验证码

第一条报文访问了 /v2/srun_portal_captcha_image_info,顾名思义是一个获取图片验证码的接口

img

下面是该接口的入参列表

查询参数 备注
user_name 登录用户的学号
ip 登录设备的ip

在登陆的过程中我似乎还没遇到过需要验证码的情况,这个可能是未来会加入的功能,或者说在多次密码错误后才会激活这个功能

暂时不测试更多的情况,目前该接口只会返回一个指示请求是否成功的 json 数据

获取加密 token

第二条报文访问了 /cgi-bin/get_challenge,这是一个用于获取加密 token 的接口

img

该接口的入参列表

查询参数 备注
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_iponline_ip 就是登录设备的ip

ecodeerrorerror_msgres 用于指示系统状态,供客户端判断此次请求是否成功

srun_ver 就是 srun version 的意思,指示认证服务的版本

st 是一个时间戳

比较关键的是 challengeexpire,challenge 目前用途不明(标题已经剧透了,是加密 token)expire 顾名思义,是用来表示有效期的,结合接口中使用了时间戳,猜测第三条报文要在有效期(60 s)以内发送才算有效,对登录脚本的撰写思路没什么影响

加密用户信息、正式认证

第三条报文是最关键的,访问了 /cgi-bin/srun_portal 接口,并且携带了大量敏感参数

img

该接口的入参列表

查询参数 备注
callback 用来做 jsonp 回调
action 没分析出来是什么框架,总之这个 Controller 有 loginlogout 两个 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 的加密逻辑

需要分析的入参:passwordchksuminfo

用户信息加密流程分析

首先从 password 的 {MD5} 头部入手,由于 srun 的前端代码几乎没有混淆(甚至保留了注释)很轻易就能找到加密逻辑

位于 /static/themes/pro/js/Portal.js

img

到这边一看,发现 chksum 和 info 的加密逻辑都在这里了。为了观感还是一点一点来分析

password 的加密逻辑

// 用户密码 MD5 加密
var hmd5 = md5(password, token); // 用户信息加密

md5 函数在 /static/themes/pro/lib/all.min.js

img

从入参来看,带了明文和 token,应该是 hmacmd5

img

那这个 token 是哪来的?请看深澜软件给我们准备的注释

img

结合前面对接口返回值的分析,大概能猜出接口 /cgi-bin/get_challenge 的出参 challenge 就是 token

获取 token 的具体逻辑在这里

img

chksum 的加密逻辑

可以看到这个字段就是一个 sha1 的签名

img

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,其他都已经有了。那几个常量在附近就能找到

img

求 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

然后加密用户信息

  1. hmd5 => hmac-md5 加密,msg 为用户密码,key 为前面获取的加密 token

  2. info => encode 加密一个包含用户信息的 json 数据,然后对加密结果进行换标 base64 编码

  3. chkstr => token、用户名、hmd5、ip、info、加密常量 的拼接

  4. chksum => sha1 哈希后的 chkstr

最后访问 /cgi-bin/srun_portal 进行正式认证

流程图:

img

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 的流量,哈哈哈