绵阳城市学院教务处密码加密算法分析
绵阳城市学院教务处密码加密算法分析
1. 逆向定位与前端代码分析
通过抓包与调试,我们定位到了前端 Webpack 打包生成的处理登录逻辑的核心代码。整体逆向路径如下:
在 Webpack 打包产物的第 18 号模块中,直接暴露了所有加密所需参数。可以看到该系统采用了基于原生 BigInt 手动实现的无填充 RSA 加密算法(Textbook RSA):

// 逆向出的前端核心配置字典(模块 18)var n = { LOGIN: "/auth/login", CAS: "/mcauth", TAG: "lyasp", // 用于混淆生成请求头 Token public_exponent: "010001", // 公钥指数 65537 private_exponent: "413798867d69babed22e0dd3d4031c635f3e9dbca0fa50a32974a0e230787b7f...", modulus: "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5..."}前端开发人员将 private_exponent(RSA 私钥) 直接硬编码在了公开的 JS 文件中!这意味着任何拿到这段 JS 的人都可以解密被截获的密文。此外,无填充的 Textbook RSA 存在严重的模式识别漏洞——相同密码产生的密文永远一致,极易遭受重放攻击。
2. 登录请求构造与抓包分析
点击登录后,前端脚本会向 /mcauth/v1/tickets 端点发起 POST 请求。登录整体流程如下:

以下是逆向还原的完整登录核心函数,涵盖加密、Payload 构造、OAuth2 分支及所有服务端响应处理逻辑:
x = function e(t, n, o, l, p, m, g, h, f, _) { // t=username, n=password, o=service, l=loginType(明文标志) // p=router, m=rememberMe, g=change, h=验证码id, f=验证码code, _=callback return function(w) { var y = a(5); // qs 库,用于序列化表单 t = t.replace(/\s+/g, ""); n = n.replace(/\s+/g, "");
// 若非明文模式,则对密码进行 RSA 加密 var b = ""; if (!l) { a.i(u.a)(131); var v = a.i(u.b)(r.a.public_exponent, "", r.a.modulus); b = a.i(u.c)(v, n) }
// 构造基础 Payload var E = !1, x = {}, k = { username: t, password: l ? n : b, // 明文 or 密文 service: o, loginType: l, id: h, code: f };
// OAuth2 分支:检测路径或 localStorage 标志 if (window.location.pathname.indexOf("oauth2login") > -1 || localStorage.getItem("isOAuth2")) { E = !0; var S = a.i(c.a)("response_type") || localStorage.getItem("response_type"), O = a.i(c.a)("client_id") || localStorage.getItem("client_id"), A = a.i(c.a)("redirect_uri") || localStorage.getItem("redirect_uri"), D = a.i(c.a)("scope") || localStorage.getItem("scope"), Q = a.i(c.a)("state") || localStorage.getItem("state"); // 持久化 OAuth2 参数 localStorage.setItem("response_type", S); localStorage.setItem("client_id", O); localStorage.setItem("redirect_uri", A); localStorage.setItem("scope", D); localStorage.setItem("state", Q); k.service = A; x = { response_type: S, client_id: O, redirect_uri: A, scope: D, state: Q }; k = Object.assign(k, x) }
// 发起 POST 请求:普通登录 or OAuth2 授权码 s.a.post("/mcauth" + (E ? "/oauth2/code" : "/v1/tickets"), y.stringify(k)) .then(function(r) { if (201 == r.status || 200 == r.status) { if ("object" == d(r.data.data) && r.data.data.code) { var s = r.data.data; if (s.code) { var u = s.code, f = s.uid, y = { type: l, service: E ? k.service : o, userName: t, passWord: n, rememberMe: m, change: g, vcodes: f };
if ("CODEFALSE" == u) { // 验证码错误 var v = window.locale.zh_CN.code_false || "验证码错误"; i.a.error({ title: "登录失败", content: v }); w(T("block", "none", "none")); a.i(c.c)("CODEFALSE", h)
} else if ("NOREGISTER" == u) { // 账号未注册 sessionStorage.setItem("unAuthorizeInfo", JSON.stringify({ unAuthorizeUrl: o, unAuthorizeType: "1" })); p.push("/loginUnAuthorize")
} else if ("NOAUTHORIZATION" == u) { // 无权限访问该服务 sessionStorage.setItem("unAuthorizeInfo", JSON.stringify({ unAuthorizeUrl: o, unAuthorizeType: "2", unAuthorizeName: s.data })); p.push("/loginUnAuthorize")
} else if ("NETWORKCOMMITMENT" == u) { // 需要签署网络承诺书 y.code = u; w(C(y)); p.push("/loginNetcommit")
} else if ("ISMODIFYPASS" == u) { // 首次登录,强制修改密码 y.code = u; w(C(y)); p.push("/loginFirst")
} else if ("ISPHONEOREMAILORANSWER" == u) { // 需要绑定手机/邮箱/密保问题 var O = s.data; if (O.indexOf("userName") >= 0) { var A = O.split(","), D = A[A.length - 1].split("=")[1]; y.name = D } O.indexOf("answer") >= 0 ? (y.checkType = "1") : O.indexOf("phone") >= 0 ? (y.checkType = "2") : O.indexOf("email") >= 0 && (y.checkType = "3"); w(C(y)); p.push({ pathname: "/loginCheck" })
} else if ("ISBINDWX" == u) { // 需要绑定微信 y.code = u; w(C(y)); p.push("/loginBindWx")
} else if ("PEOPLEMOREACCOUNT" == u) { // 一人多账号:若只有一个账号则自动选择重新登录 var N = s.data; y.content = JSON.parse(N); if (1 == y.content.users.length) { var L = y.content.users[0]; return w(e(L.userId, L.passWord, o, 15, p, m, g)) } w(C(y)); p.push("/userSelect")
} else if ("NOUSER" == u) { // 用户名或密码错误 var I = window.locale.zh_CN.nouser || "用户名或密码错误。"; i.a.error({ title: "登录失败", content: I }); w(T("block", "none", "none"))
} else if ("USERNOTONLY" == u) { // 用户名不唯一 i.a.error({ title: "登录失败", content: s.tips }); w(T("block", "none", "none"))
} else if ("USERDISABLED" == u) { // 账号已停用 var M = window.locale.zh_CN.user_disabled || "账号被停用,请联系相关管理员处理。"; i.a.error({ title: "登录失败", content: M }); w(T("block", "none", "none"))
} else if ("USERLOCK" == u) { // 账号被锁定,显示锁定到期时间 var P = s.data; P.length > 0 && (P = P.substring(11, P.length)); i.a.error({ title: "账号锁定", content: "账号锁定至:" + P + ",请稍后重新登录或联系管理员处理。" }); w(T("block", "none", "none"))
} else if ("TWOVERIFY" == u) { // 需要二次验证(MFA) y.code = u; y.content = JSON.parse(s.data); w(C(y)); E && localStorage.setItem("isOAuth2", !0); p.push("/loginAgain")
} else { // 密码连续错误 / 其他未知错误 var q = "请检查账号或密码是否正确"; if (s.data.indexOf(",") > 0) { var U = s.data.split(",")[0], R = s.data.split(",")[1]; sessionStorage.setItem("errorNum", R); q = U == R ? "密码已连续错误【" + R + "】次,账号锁定。" : "密码已连续错误【" + R + "】次,连续错误【" + U + "】次账号将被锁定。" } else if (!isNaN(Number(s.data))) { sessionStorage.setItem("errorNum", s.data) } i.a.error({ title: "登录失败", content: q }); w(T("block", "none", "none")) }
_ && _() // 执行回调 } } } }) }}Payload 字段说明:
| 字段 | 来源 | 说明 |
|---|---|---|
username | 用户输入 | 去除空格后的明文账号 |
password | 加密处理 | RSA 加密后的密文(十六进制),长度固定 256 字符/块 |
service | URL 参数 | 登录成功后的跳转地址,如 http://jwgl.mycc.edu.cn/caslogin |
loginType | 登录模式 | 普通账号密码登录时为空或 0 |
id | 验证码组件 | 图形验证码对应的 UID |
code | 用户输入 | 验证码识别结果 |
以下是浏览器 DevTools Network → Payload 面板捕获到的真实请求数据,可以看到 password 字段已是 RSA 加密后的十六进制密文:

隐藏的请求头校验:
该接口会校验两个特殊 Header:
loginToken:固定字符串或会话相关 TokenloginUserToken:使用同一套 RSA 逻辑加密,明文规则为TAG + 当前毫秒时间戳
loginUserToken = encryptPassword("lyasp" + Date.now())// 例如:encryptPassword("lyasp1715423891000")3. RSA 加密算法详细流程
3.1 密钥对象初始化(u.b 内部)
调用 u.b(public_exponent, "", modulus) 时,实际进入的是一套自定义大数实现的初始化逻辑,而非浏览器原生 BigInt。其核心初始化函数如下:
function n(e) { q = e; // q = 密钥位数(决定 digits 数组长度) U = new Array(q); // U = 工作用数字数组,长度为 q for (var t = 0; t < U.length; t++) U[t] = 0; // 初始化全为 0 R = new o; // R = 暂存寄存器(自定义大数对象) B = new o; // B = 累乘基数(自定义大数对象) B.digits[0] = 1 // B 初始化为 1,用于幂模运算的初始累乘值}| 变量 | 类型 | 作用 |
|---|---|---|
q | number | 密钥分组位数,控制 digits 数组长度 |
U | number[] | 工作数组,存放中间运算结果 |
R | 自定义大数对象 o | 暂存寄存器,用于模乘过程 |
B | 自定义大数对象 o | 累乘基数,初始为 1,即 |
该系统并未使用浏览器原生的 BigInt,而是用 o 类自行实现了一套基于数组的大数运算(digits[] 存放每位数值)。B.digits[0] = 1 对应的是快速幂初始状态 ,之后通过反复调用模乘将 的结果累积到 B 中。
3.2 加密执行流程
加密执行的具体步骤:
- 预处理:过滤掉输入字符串的所有空白字符。
- 字符转码:将字符串转换为 ASCII 编码数组。
- 零填充 (Zero Padding):分块大小为
126字节,不足时在末尾补0对齐。 - 分块与大数合并:按每 126 字节逆向遍历,每次左移 8 位并加上当前字节值,拼合为一个大
BigInt。 - 大数幂模运算:对大数执行 (即 Textbook RSA 加密)。
- 格式化输出:转为十六进制字符串,
.padStart(256, '0')确保每块输出严格 256 字符,最后拼接所有块。
4. 核心还原代码 (JavaScript)
可以直接复用以下代码来模拟加密行为:
// 快速幂取模运算const modPow = (base, exponent, modulus) => { let result = 1n; base = base % modulus; while (exponent > 0n) { if (exponent & 1n) result = (result * base) % modulus; base = (base * base) % modulus; exponent >>= 1n; } return result;};
// RSA 密码加密函数(还原自前端 Webpack 模块)const encryptPassword = (text) => { const modulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"; const exponentHex = "010001"; const N = BigInt(`0x${modulusHex}`); const E = BigInt(`0x${exponentHex}`); const chunkSize = 126;
const charCodes = Array.from(text).map(char => char.charCodeAt(0)); while (charCodes.length % chunkSize !== 0) charCodes.push(0);
const result = []; for (let i = 0; i < charCodes.length; i += chunkSize) { const chunk = charCodes.slice(i, i + chunkSize); let P = 0n; for (let j = chunk.length - 1; j >= 0; j--) P = (P << 8n) | BigInt(chunk[j]); result.push(modPow(P, E, N).toString(16).padStart(256, '0')); } return result.join('');};5. 实战验证
以下是基于上述逆向分析实现的自动化登录工具的完整调试输出,完整演示了从 OCR 识别验证码、RSA 加密、换取 CAS Ticket、获取 Cookie,到最终拉取课表数据的全流程:

关键阶段说明:
| 阶段 | 输出字段 | 说明 |
|---|---|---|
| 验证码识别 | 解析为数字是: 5 * 7 = 12 | OCR 自动识别图形验证码 |
| 密码加密 | plainLength: 10 → encryptedLength: 256 | 10 字符密码经 RSA 加密后为 256 字符十六进制串,chunkCount: 1 |
| Token 加密 | label: loginUserToken plainLength: 18 | "lyasp" + 13位时间戳 = 18 字符,同样加密为 256 字符 |
| 换票 | Ticket: ST-xxxxx | 服务端返回 CAS Service Ticket |
| 会话 | JSESSIONID: xxxxx | 携带 Ticket 访问教务系统后获取会话 Cookie |
| 课表数据 | courseName / teacher / rawTime ... | 成功解析第 2 周完整课表 JSON |
算法还原正确性验证
对比调试输出中的 encryptedText 与浏览器 Network 面板抓到的 password 字段:
调试输出(encryptedText):

浏览器真实请求 Payload(password):

两者的十六进制字符串完全一致,这证明:
- 还原出的
encryptPassword()函数与前端原始加密逻辑行为相同。 - 相同的明文密码在相同时刻必然产生相同的密文(Textbook RSA 无随机盐),这也从侧面再次印证了该算法的确定性缺陷。