绵阳城市学院校园网 Srun Portal 认证逆向分析

2844 字
14 分钟
绵阳城市学院校园网 Srun Portal 认证逆向分析

本文记录一次基于浏览器抓包与前端 JavaScript 分析,使用 Go 语言模拟 Srun Portal 认证流程的实现过程。内容仅适用于本人已授权使用的校园网 / 内网 Portal 登录环境。


1. 背景#

Portal 认证页面在登录时,并不是直接提交明文密码,而是通过前端 JavaScript 生成多个加密参数后,再向后端接口发起 JSONP 请求。

打开浏览器开发者工具的 Network 面板,可以看到登录时会依次触发两个关键接口:

抓包概览:get_challenge 和 srun_portal 两个接口
抓包概览:get_challenge 和 srun_portal 两个接口

GET /cgi-bin/get_challenge # 获取 challenge / token
GET /cgi-bin/srun_portal # 提交登录认证

最终 Go 脚本成功返回:

{
"error": "ok",
"res": "ok",
"suc_msg": "ip_already_online_error"
}

其中 ip_already_online_error 表示该 IP 已经在线,说明认证流程本身已经成功。


2. 整体流程#

整个认证流程可以拆成 4 步:

1. GET /cgi-bin/get_challenge → 获取当前 IP 和 challenge(token)
2. HMAC-MD5(password, token) → 生成 hmd5
3. xEncode(JSON, token) + 自定义 Base64 → 生成 info
4. SHA1(拼接字符串) → 生成 chksum,提交 srun_portal

流程图:

flowchart TD A([浏览器 / 脚本]) -->|GET /cgi-bin/get_challenge\nusername=xxx & ip=xxx| B[Portal Server] B -->|返回 challenge / client_ip / online_ip| C[/"token = challenge\nip = client_ip"/] C --> D["hmd5 = HMAC-MD5(password, token)"] C --> E["info = {SRBX1} + srunBase64(xEncode(JSON, token))"] C --> F["chksum = SHA1(token+username+token+hmd5+…+info)"] D & E & F -->|GET /cgi-bin/srun_portal\naction=login & password & info & chksum| G[Portal Server] G -->|error=ok / res=ok| H([认证成功])

3. get_challenge 接口#

请求示例:

GET http://10.8.8.117/cgi-bin/get_challenge
?callback=jQuery1124...
&username=23131313@test_sl
&ip=10.103.31.95
&_=1778481035189
参数说明
callbackJSONP 回调函数名
username登录账号
ipPortal 识别的客户端 IP
_时间戳,防止缓存

返回示例(JSONP 格式):

jQuery1124xxx({
"challenge": "006fe3212db9d430...",
"client_ip": "10.103.26.136",
"ecode": 0,
"error": "ok",
"expire": "60",
"online_ip": "10.103.31.95",
"res": "ok"
})

核心字段是 challenge,它是后续所有加密计算的 token。

注意challengeusernameip 有绑定关系。后续 login 请求中的 IP 必须与此处完全一致,否则会出现 challenge_expire_errorauth_info_error


4. srun_portal 登录接口#

抓包可以看到登录请求携带的完整参数:

srun_portal 请求的完整 Payload 参数
srun_portal 请求的完整 Payload 参数

action=login
username=23131313@test_sl
password={MD5}xxxx
os=Mac OS
name=Macintosh
double_stack=0
chksum=xxxx
info={SRBX1}xxxx
ac_id=0
ip=10.103.26.135
n=200
type=1

其中最重要的三个参数是 passwordinfochksum,下面逐一分析生成方式。


5. password 参数生成(HMAC-MD5)#

在前端 JavaScript 源码中可以找到:

前端 sendAuth 函数:HMAC-MD5 生成 hmd5
前端 sendAuth 函数:HMAC-MD5 生成 hmd5

var hmd5 = md5(password, token);

这里的 md5(password, token) 实际上是:

HMAC-MD5(key=token, message=password)

登录请求中的 password 参数拼接前缀:

password = {MD5} + hmd5

Go 实现:

func hmacMD5(pass, token string) string {
h := hmac.New(md5.New, []byte(token))
h.Write([]byte(pass))
return hex.EncodeToString(h.Sum(nil))
}

6. info 参数生成#

前端对应代码:

info 编码:{SRBX1} + base64.encode(encode(info, token))
info 编码:{SRBX1} + base64.encode(encode(info, token))

info 的生成分 4 步:

1. 构造 JSON(password 为明文,不是 MD5 后的值)
2. xEncode(JSON, token) → XXTEA 加密
3. srunBase64(bytes) → 自定义 Base64 编码
4. 拼接前缀 {SRBX1}

原始 JSON:

{"username":"23131313@test_sl","password":"12345","ip":"10.103.26.135","acid":"0","enc_ver":"srun_bx1"}

最终格式:

{SRBX1}xxxxxxxx

Go 实现:

func srunInfo(token string) string {
info := fmt.Sprintf(
`{"username":"%s","password":"%s","ip":"%s","acid":"%s","enc_ver":"srun_bx1"}`,
username, password, ip, acID,
)
encoded := xEncode(info, token)
return "{SRBX1}" + srunBase64(encoded)
}

7. xEncode / encode 算法分析(XXTEA)#

前端的 encode() 完整代码:

前端 encode() 函数完整实现(XXTEA 变体)
前端 encode() 函数完整实现(XXTEA 变体)

function encode(str, key) {
if (str === '') return '';
var v = s(str, true); // 字符串 → uint32 数组,追加长度
var k = s(key, false); // key → uint32 数组
if (k.length < 4) k.length = 4;
var n = v.length - 1,
z = v[n], y = v[0],
c = 0x86014019 | 0x183639A0, // delta = 0x9E3779B9
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);
}

其中常量:

0x86014019 | 0x183639A0 = 0x9E3779B9 (XXTEA 标准 delta 常量)

关键点:Go 实现时需严格按照 JS 逐行翻译,直接合并公式容易因运算符优先级不同导致结果错误,进而产生 auth_info_error


8. s() 和 l() 辅助函数#

前端的 s()l() 对应 Go 中的 strToLonglongToStr

前端 A(n,t,r) 函数,即 s()/l() 辅助函数
前端 A(n,t,r) 函数,即 s()/l() 辅助函数

s() — 字符串转 uint32 数组(小端序,4 字节一组):

function s(a, b) {
var c = a.length, 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;
}

l() — uint32 数组转回字节字符串:

function l(a, b) {
var d = a.length, c = (d - 1) << 2;
if (b) { var m = a[d - 1]; 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('');
}

9. 自定义 Base64 字符表#

排查过程中最关键的发现:Srun 使用的不是标准 Base64 字符表

前端代码明确设置:

base64.setAlpha('LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA');

标准字符表(不能用):

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

Srun 自定义字符表(必须使用):

LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA

该字符表长度必须恰好为 64,少一位就会出现:

panic: runtime error: index out of range

Go 实现:

func srunBase64(data []byte) string {
alphabet := "LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA"
pad := byte('=')
var result strings.Builder
for i := 0; i < len(data); i += 3 {
b1 := data[i]
var b2, b3 byte
if i+1 < len(data) { b2 = data[i+1] }
if i+2 < len(data) { b3 = data[i+2] }
result.WriteByte(alphabet[b1>>2])
result.WriteByte(alphabet[((b1&0x03)<<4)|(b2>>4)])
if i+1 < len(data) {
result.WriteByte(alphabet[((b2&0x0f)<<2)|(b3>>6)])
} else {
result.WriteByte(pad)
}
if i+2 < len(data) {
result.WriteByte(alphabet[b3&0x3f])
} else {
result.WriteByte(pad)
}
}
return result.String()
}

10. chksum 生成(SHA1)#

前端拼接逻辑:

前端 JSONP 请求构造,包含 chksum 拼接方式
前端 JSONP 请求构造,包含 chksum 拼接方式

var str = token + username;
str += token + hmd5;
str += token + ac_id;
str += token + ip;
str += token + n;
str += token + type;
str += token + i; // i 即 info
chksum = sha1(str);

Go 实现:

chkStr := token + username +
token + hmd5 +
token + acID +
token + ip +
token + n +
token + typ +
token + info
chksum := sha1Hex(chkStr)
func sha1Hex(s string) string {
h := sha1.Sum([]byte(s))
return hex.EncodeToString(h[:])
}

11. IP 一致性问题#

调试过程中曾出现 client_iponline_ip 不一致的情况:

client_ip: 10.103.26.135
online_ip: 10.103.26.136 ← 不一致

这会导致 challenge 与 login 参数不匹配。修正后保持所有 IP 一致:

client_ip: 10.103.26.135
online_ip: 10.103.26.135
login ip: 10.103.26.135
info ip: 10.103.26.135

错误变化路径:

challenge_expire_error → auth_info_error → 认证成功

IP 问题解决后,剩余的 auth_info_error 最终定位到自定义 Base64 字符表使用错误。


12. 常见错误分析#

challenge_expire_error#

原因:challenge 过期,或 challenge 与 ip 不匹配
常见场景:
1. get_challenge 和 login 使用了不同的 IP
2. token 已过期(有效期 60 秒)
3. 脚本中途自动切换了 ip 变量
解决:保持 get_challenge、info、login 三处 IP 完全一致

auth_info_error#

原因:info 参数生成错误
常见场景:
1. info 中的 password 误用了 MD5 后的值(应为明文)
2. JSON 字段顺序不固定(Go map 无序,应用 fmt.Sprintf 硬编码顺序)
3. xEncode 翻译时运算符优先级与 JS 不同
4. Base64 字符表使用了标准表而非 Srun 自定义表

ip_already_online_error#

含义:该 IP 已经在线(不是失败)
返回中同时有 error=ok、res=ok,可视为认证成功

13. 最终结果#

Go 脚本运行成功,返回 error=ok、res=ok
Go 脚本运行成功,返回 error=ok、res=ok

Go 脚本最终返回:

{
"error": "ok",
"res": "ok",
"suc_msg": "ip_already_online_error",
"username": "23131313@test_sl",
"client_ip": "10.103.26.135",
"online_ip": "10.103.26.135"
}

验证通过的各环节:

✓ get_challenge 正常
✓ HMAC-MD5 正常
✓ xEncode + 自定义 Base64 正常
✓ chksum 正常
✓ srun_portal 认证通过

14. 总结#

这次实现的核心不是简单模拟 HTTP 请求,而是完整还原前端 JavaScript 的加密逻辑。

关键要点:

参数算法注意事项
password{MD5} + HMAC-MD5(password, token)key 是 token,不是密码
info{SRBX1} + srunBase64(xEncode(JSON, token))JSON 中 password 是明文;字段顺序固定
chksumSHA1(token 分隔的拼接串)顺序固定,不能乱
IPget_challenge、info、login 三处必须完全一致
Base64自定义字符表不能用标准 Base64

15. 完整实现代码#

Tip

选择 Go 实现的原因:编译为单一可执行文件,无需运行时依赖;相比 Python/Node.js 启动更快、内存占用更低;且支持交叉编译,同一份代码可直接编译为 Windows、macOS、Linux 各平台可执行文件,开箱即用。

将以下代码保存为 main.go,修改顶部常量中的 usernamepasswordip 后直接运行即可。

package main
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
baseURL = "http://10.8.8.117"
username = "23131313@test_sl"
password = "12345"
acID = "0"
n = "200"
typ = "1"
)
var ip = "10.103.26.135"
type ChallengeResp struct {
Challenge string `json:"challenge"`
ClientIP string `json:"client_ip"`
OnlineIP string `json:"online_ip"`
Error string `json:"error"`
Ecode int `json:"ecode"`
}
// HMAC-MD5:password 参数使用
func hmacMD5(pass, token string) string {
h := hmac.New(md5.New, []byte(token))
h.Write([]byte(pass))
return hex.EncodeToString(h.Sum(nil))
}
// SHA1:chksum 参数使用
func sha1Hex(s string) string {
h := sha1.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
// 获取 challenge / token
func getChallenge(useIP string) (ChallengeResp, error) {
callback := fmt.Sprintf("jQuery%d", time.Now().UnixMilli())
v := url.Values{}
v.Set("callback", callback)
v.Set("username", username)
if useIP != "" {
v.Set("ip", useIP)
}
v.Set("_", strconv.FormatInt(time.Now().UnixMilli(), 10))
resp, err := http.Get(baseURL + "/cgi-bin/get_challenge?" + v.Encode())
if err != nil {
return ChallengeResp{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
text := strings.TrimSpace(string(body))
start := strings.Index(text, "(")
end := strings.LastIndex(text, ")")
if start < 0 || end <= start {
return ChallengeResp{}, fmt.Errorf("解析 JSONP 失败: %s", text)
}
var cr ChallengeResp
if err := json.Unmarshal([]byte(text[start+1:end]), &cr); err != nil {
return ChallengeResp{}, err
}
if cr.Challenge == "" {
return ChallengeResp{}, fmt.Errorf("未获取到 challenge: %s", text)
}
return cr, nil
}
// 构造 info 参数:{SRBX1} + srunBase64(xEncode(JSON, token))
func srunInfo(token string) string {
info := fmt.Sprintf(
`{"username":"%s","password":"%s","ip":"%s","acid":"%s","enc_ver":"srun_bx1"}`,
username, password, ip, acID,
)
return "{SRBX1}" + srunBase64(xEncode(info, token))
}
// Srun 自定义 Base64(字符表与标准不同)
func srunBase64(data []byte) string {
alphabet := "LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA"
pad := byte('=')
var result strings.Builder
for i := 0; i < len(data); i += 3 {
b1 := data[i]
var b2, b3 byte
if i+1 < len(data) {
b2 = data[i+1]
}
if i+2 < len(data) {
b3 = data[i+2]
}
result.WriteByte(alphabet[b1>>2])
result.WriteByte(alphabet[((b1&0x03)<<4)|(b2>>4)])
if i+1 < len(data) {
result.WriteByte(alphabet[((b2&0x0f)<<2)|(b3>>6)])
} else {
result.WriteByte(pad)
}
if i+2 < len(data) {
result.WriteByte(alphabet[b3&0x3f])
} else {
result.WriteByte(pad)
}
}
return result.String()
}
// XXTEA 加密(xEncode)
func xEncode(str, key string) []byte {
if str == "" {
return []byte{}
}
v := strToLong(str, true)
k := strToLong(key, false)
for len(k) < 4 {
k = append(k, 0)
}
nn := len(v) - 1
z := v[nn]
y := v[0]
c := uint32(0x86014019 | 0x183639A0)
mask := uint32(0x8CE0D9BF | 0x731F2640)
q := uint32(6 + 52/(nn+1))
d := uint32(0)
for q > 0 {
q--
d = (d + c) & mask
e := (d >> 2) & 3
var p int
for p = 0; p < nn; p++ {
y = v[p+1]
m := (z>>5 ^ y<<2) + ((y>>3 ^ z<<4) ^ (d ^ y)) + (k[(p&3)^int(e)] ^ z)
v[p] = (v[p] + m) & mask
z = v[p]
}
y = v[0]
m := (z>>5 ^ y<<2) + ((y>>3 ^ z<<4) ^ (d ^ y)) + (k[(p&3)^int(e)] ^ z)
v[nn] = (v[nn] + m) & mask
z = v[nn]
}
return longToStr(v, false)
}
func strToLong(s string, includeLength bool) []uint32 {
b := []byte(s)
v := make([]uint32, (len(b)+3)/4)
for i, c := range b {
v[i>>2] |= uint32(c) << uint((i&3)*8)
}
if includeLength {
v = append(v, uint32(len(b)))
}
return v
}
func longToStr(v []uint32, includeLength bool) []byte {
length := len(v) * 4
if includeLength {
length = int(v[len(v)-1])
v = v[:len(v)-1]
}
b := make([]byte, len(v)*4)
for i, val := range v {
b[i*4] = byte(val)
b[i*4+1] = byte(val >> 8)
b[i*4+2] = byte(val >> 16)
b[i*4+3] = byte(val >> 24)
}
if length > len(b) {
length = len(b)
}
return b[:length]
}
func login() error {
useIP := ip
// 未手动填写 IP 时,先自动获取 client_ip
if useIP == "" {
cr, err := getChallenge("")
if err != nil {
return err
}
if cr.ClientIP == "" {
return fmt.Errorf("自动获取 IP 失败")
}
useIP = cr.ClientIP
fmt.Println("自动获取 IP:", useIP)
}
cr, err := getChallenge(useIP)
if err != nil {
return err
}
token := cr.Challenge
ip = useIP
hmd5 := hmacMD5(password, token)
info := srunInfo(token)
chkStr := token + username +
token + hmd5 +
token + acID +
token + ip +
token + n +
token + typ +
token + info
chksum := sha1Hex(chkStr)
v := url.Values{}
v.Set("callback", fmt.Sprintf("jQuery%d", time.Now().UnixMilli()))
v.Set("action", "login")
v.Set("username", username)
v.Set("password", "{MD5}"+hmd5)
v.Set("os", "Mac OS")
v.Set("name", "Macintosh")
v.Set("double_stack", "0")
v.Set("chksum", chksum)
v.Set("info", info)
v.Set("ac_id", acID)
v.Set("ip", ip)
v.Set("n", n)
v.Set("type", typ)
v.Set("_", strconv.FormatInt(time.Now().UnixMilli(), 10))
resp, err := http.Get(baseURL + "/cgi-bin/srun_portal?" + v.Encode())
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(strings.TrimSpace(string(body)))
return nil
}
func main() {
if err := login(); err != nil {
fmt.Println("登录失败:", err)
}
}

运行方式:

Terminal window
go run main.go

成功时会输出包含 "error":"ok" 的 JSON 响应。

分类
标签
站点统计
文章
14
分类
4
标签
24
总字数
23,486
运行时长
0
最后活动
0 天前

文章目录