绵阳城市学院校园网 Srun Portal 认证逆向分析
本文记录一次基于浏览器抓包与前端 JavaScript 分析,使用 Go 语言模拟 Srun Portal 认证流程的实现过程。内容仅适用于本人已授权使用的校园网 / 内网 Portal 登录环境。
1. 背景
Portal 认证页面在登录时,并不是直接提交明文密码,而是通过前端 JavaScript 生成多个加密参数后,再向后端接口发起 JSONP 请求。
打开浏览器开发者工具的 Network 面板,可以看到登录时会依次触发两个关键接口:

GET /cgi-bin/get_challenge # 获取 challenge / tokenGET /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) → 生成 hmd53. xEncode(JSON, token) + 自定义 Base64 → 生成 info4. SHA1(拼接字符串) → 生成 chksum,提交 srun_portal流程图:
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| 参数 | 说明 |
|---|---|
callback | JSONP 回调函数名 |
username | 登录账号 |
ip | Portal 识别的客户端 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。
注意:
challenge与username、ip有绑定关系。后续 login 请求中的 IP 必须与此处完全一致,否则会出现challenge_expire_error或auth_info_error。
4. srun_portal 登录接口
抓包可以看到登录请求携带的完整参数:

action=loginusername=23131313@test_slpassword={MD5}xxxxos=Mac OSname=Macintoshdouble_stack=0chksum=xxxxinfo={SRBX1}xxxxac_id=0ip=10.103.26.135n=200type=1其中最重要的三个参数是 password、info、chksum,下面逐一分析生成方式。
5. password 参数生成(HMAC-MD5)
在前端 JavaScript 源码中可以找到:

var hmd5 = md5(password, token);这里的 md5(password, token) 实际上是:
HMAC-MD5(key=token, message=password)登录请求中的 password 参数拼接前缀:
password = {MD5} + hmd5Go 实现:
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 的生成分 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}xxxxxxxxGo 实现:
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() 完整代码:

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 中的 strToLong 和 longToStr:

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 rangeGo 实现:
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)
前端拼接逻辑:

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_ip 和 online_ip 不一致的情况:
client_ip: 10.103.26.135online_ip: 10.103.26.136 ← 不一致这会导致 challenge 与 login 参数不匹配。修正后保持所有 IP 一致:
client_ip: 10.103.26.135online_ip: 10.103.26.135login ip: 10.103.26.135info 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", "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 是明文;字段顺序固定 |
chksum | SHA1(token 分隔的拼接串) | 顺序固定,不能乱 |
| IP | — | get_challenge、info、login 三处必须完全一致 |
| Base64 | 自定义字符表 | 不能用标准 Base64 |
15. 完整实现代码
选择 Go 实现的原因:编译为单一可执行文件,无需运行时依赖;相比 Python/Node.js 启动更快、内存占用更低;且支持交叉编译,同一份代码可直接编译为 Windows、macOS、Linux 各平台可执行文件,开箱即用。
将以下代码保存为 main.go,修改顶部常量中的 username、password、ip 后直接运行即可。
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 / tokenfunc 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) }}运行方式:
go run main.go成功时会输出包含 "error":"ok" 的 JSON 响应。