绵阳城市学院教务处密码加密算法分析

3003 字
15 分钟
绵阳城市学院教务处密码加密算法分析

绵阳城市学院教务处密码加密算法分析#

1. 逆向定位与前端代码分析#

通过抓包与调试,我们定位到了前端 Webpack 打包生成的处理登录逻辑的核心代码。整体逆向路径如下:

flowchart TD A[打开浏览器 DevTools] --> B[Network 面板监听请求] B --> C{发现 POST /mcauth/v1/tickets} C --> D[定位到 Initiator:scriptcat-inject.js] D --> E[Sources 面板搜索加密函数] E --> F[找到 Webpack 模块 #18] F --> G[提取 public_exponent / modulus / private_exponent]

在 Webpack 打包产物的第 18 号模块中,直接暴露了所有加密所需参数。可以看到该系统采用了基于原生 BigInt 手动实现的无填充 RSA 加密算法(Textbook RSA)

模块18——核心加密参数硬编码
模块18——核心加密参数硬编码

// 逆向出的前端核心配置字典(模块 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 请求。登录整体流程如下:

flowchart TD A([用户点击登录]) --> B[去除用户名/密码中的空格] B --> C{是否为明文登录模式?} C -- 否 --> D[调用 encryptPassword 加密密码] C -- 是 --> E[直接使用明文密码] D --> F[构造 Payload] E --> F F --> G["生成 loginUserToken<br/>encrypt:'lyasp' + 时间戳"] G --> H["POST /mcauth/v1/tickets<br/>携带 loginToken & loginUserToken 请求头"] H --> I{服务端返回状态码} I -- 201/200 + code字段 --> J{解析响应 code} I -- 其他 --> K[登录成功,跳转 service] J -- NOUSER --> L[提示:用户名或密码错误] J -- CODEFALSE --> M[提示:验证码错误] J -- USERLOCK --> N[提示:账号已锁定] J -- ISMODIFYPASS --> O[跳转:强制修改密码] J -- TWOVERIFY --> P[跳转:二次验证页] J -- NOAUTHORIZATION --> Q[跳转:未授权页面]

登录请求抓包——DevTools Network 面板
登录请求抓包——DevTools Network 面板

以下是逆向还原的完整登录核心函数,涵盖加密、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 字符/块
serviceURL 参数登录成功后的跳转地址,如 http://jwgl.mycc.edu.cn/caslogin
loginType登录模式普通账号密码登录时为空或 0
id验证码组件图形验证码对应的 UID
code用户输入验证码识别结果

以下是浏览器 DevTools Network → Payload 面板捕获到的真实请求数据,可以看到 password 字段已是 RSA 加密后的十六进制密文:

真实登录请求 Payload 抓包
真实登录请求 Payload 抓包

隐藏的请求头校验:

该接口会校验两个特殊 Header:

  • loginToken:固定字符串或会话相关 Token
  • loginUserToken:使用同一套 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,用于幂模运算的初始累乘值
}
变量类型作用
qnumber密钥分组位数,控制 digits 数组长度
Unumber[]工作数组,存放中间运算结果
R自定义大数对象 o暂存寄存器,用于模乘过程
B自定义大数对象 o累乘基数,初始为 1,即 B=P0=1B = P^0 = 1
底层实现说明

该系统并未使用浏览器原生的 BigInt,而是用 o 类自行实现了一套基于数组的大数运算(digits[] 存放每位数值)。B.digits[0] = 1 对应的是快速幂初始状态 result=1result = 1,之后通过反复调用模乘将 PEmodNP^E \mod N 的结果累积到 B 中。

3.2 加密执行流程#

flowchart TD A([输入明文字符串]) --> B["预处理:去除所有空白字符<br/>text.replace(/\s+/g, '')"] B --> C[逐字符转为 ASCII 编码数组] C --> D{"数组长度是否为<br/>126 的整数倍?"} D -- 否 --> E["末尾补 0,直到对齐<br/>chunkSize = 126 字节"] D -- 是 --> F E --> F[按 126 字节分块] F --> G["逆序遍历每块<br/>左移 8 位并按位或当前字节<br/>拼合为一个超大 BigInt P"] G --> H["执行大数幂模运算<br/>C = P^E mod N<br/>E=65537, N=modulus"] H --> I["转为 16 进制字符串<br/>.padStart(256, '0') 补齐至 256 位"] I --> J{还有下一块?} J -- 是 --> G J -- 否 --> K([拼接所有块结果,输出密文])

加密执行的具体步骤:

  1. 预处理:过滤掉输入字符串的所有空白字符。
  2. 字符转码:将字符串转换为 ASCII 编码数组。
  3. 零填充 (Zero Padding):分块大小为 126 字节,不足时在末尾补 0 对齐。
  4. 分块与大数合并:按每 126 字节逆向遍历,每次左移 8 位并加上当前字节值,拼合为一个大 BigInt
  5. 大数幂模运算:对大数执行 C=PE(modN)C = P^E \pmod N(即 Textbook RSA 加密)。
  6. 格式化输出:转为十六进制字符串,.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 = 12OCR 自动识别图形验证码
密码加密plainLength: 10encryptedLength: 25610 字符密码经 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):

浏览器真实请求 Payload
浏览器真实请求 Payload

两者的十六进制字符串完全一致,这证明:

  1. 还原出的 encryptPassword() 函数与前端原始加密逻辑行为相同
  2. 相同的明文密码在相同时刻必然产生相同的密文(Textbook RSA 无随机盐),这也从侧面再次印证了该算法的确定性缺陷。
分类
标签
站点统计
文章
14
分类
4
标签
24
总字数
23,486
运行时长
0
最后活动
0 天前

文章目录