南京工业大学统一身份认证平台(SSO)逆向分析

📋 目录
1. 概述
1.1 目标系统
- 系统名称: 南京工业大学统一身份认证平台
- 登录地址:
https://sfgl.njtech.edu.cn/cas/login - 认证协议: CAS (Central Authentication Service)
- 前端框架: Angular
- 加密库: CryptoJS
1.2 核心文件
| 文件名 | 作用 | 关键内容 |
|---|---|---|
https://sfgl.njtech.edu.cn/cas/login |
登录页面主体 | 密钥2、execution参数 |
https://sfgl.njtech.edu.cn/gate/public/deploy/deploy.js |
脚本加载器 | 密钥1混淆、配置请求 |
https://sfgl.njtech.edu.cn/gate/public/utils/loginNew.js |
设备检测 | PC/Mobile判断 |
crypto-js.min.js |
加密库 | DES/MD5/SHA256算法 |
1.3 加密技术栈
加密算法: DES
加密模式: ECB
填充方式: PKCS7
编码格式: Base64
2. 系统架构分析
2.1 页面加载流程
从 login 可以看到页面加载顺序:
<!-- 1. 首先加载设备检测脚本 -->
<script src="/public/utils/loginNew.js? 1768455907648">
<!-- 内容实际是 loginNew.js -->
<!-- 2. 然后加载主加载器 -->
<script src="/public/deploy/deploy.js?1768455907751">
<!-- 内容实际是 deploy.js -->
<!-- 3. 最后加载加密库 -->
<script src="/public/crypto/crypto-js.min.js">
2.2 脚本依赖关系
从 deploy.js 第52-58行可以看到完整的依赖加载:
var cptfile = [
baseUrl + '/public/convertBaseUrl.js',
baseUrl + '/public/crypto/md5.min.js',
baseUrl + '/public/crypto/core.min.js',
baseUrl + '/public/crypto/crypto-js.min.js',
baseUrl + '/public/crypto/enc-base64.min.js',
baseUrl + '/public/crypto/mode-ecb.min.js',
baseUrl + '/public/crypto/pad-pkcs7.min. js'
];
这些文件通过 loadScript() 函数(第38-50行)依次加载。
2.3 页面初始化流程
从 deploy.js 第175行的 _casPageInit() 函数可以看到:
function _casPageInit() {
var loadedJs = [];
var hasRun;
function nextLoad(jsPath) {
loadedJs.push(jsPath);
var hasRuntime;
var hasPolyfills;
// 等待runtime和polyfills都加载完成
for (var i = 0; i < loadedJs.length; i++) {
var path = loadedJs[i];
if (path.indexOf('runtime') !== -1) {
hasRuntime = true;
} else if (path.indexOf('polyfills') !== -1) {
hasPolyfills = true;
}
}
// 加载main脚本
if (hasRuntime && hasPolyfills && !hasRun) {
hasRun = true;
for (var i = 0; i < jsCssConfig.js.length; i++) {
var jsPath = jsCssConfig.js[i];
if (jsPath.indexOf('main') !== -1) {
loadOneJS(jsPath);
}
}
}
}
}
3. 双密钥体系原理
3.1 密钥1:API通信密钥
3.1.1 存储位置与混淆
在 deploy.js 第124行:
var url_mseAge = '_%C3%D8%AF%7C%A1%BD%BE%C2%C4%C4%92';
3.1.2 解混淆函数
在 deploy.js 第16-26行:
var uncompile = function (code) {
code = unescape(code);
let c = String.fromCharCode(code.charCodeAt(0) - code.length);
for (let i = 1; i < code.length; i++) {
c += String.fromCharCode(code. charCodeAt(i) - c.charCodeAt(i - 1));
}
return c;
};
算法分析:
输入: '_%C3%D8%AF%7C%A1%BD%BE%C2%C4%C4%92'
步骤1: unescape() URL解码
'_%C3%D8%AF%7C%A1%BD%BE%C2%C4%C4%92'
→ '_ÃØ¯|¡½¾ÂÄÄ''
步骤2: 差分解码
第一个字符: charCode(0) - length
'_'. charCodeAt(0) = 95
length = 12
第一个输出: 95 - 12 = 83 → 'S'
第二个字符: charCode(1) - charCode(result[0])
'Ã'. charCodeAt(0) = 195
result[0]. charCodeAt(0) = 83
第二个输出: 195 - 83 = 112 → 'p'
依此类推...
最终输出: 'SphG5lQmUoU='
这是一种差分编码(Delta Encoding),每个字符的ASCII值是前一个字符加上一个差值。
3.1.3 使用场景
从 deploy.js 第130-165行的 getSelfConfig 函数可以看到:
function getSelfConfig(key, cb) {
let rfurl = baseUrl + '/linkid/protected/api/dictconfig/get';
// 判断是否需要加密
let hasCrypto = false;
if (configUrl) {
const allconfigkey = Object.keys(configUrl);
for (let i = 0; i < allconfigkey.length; i++) {
for (let o = 0; o < configUrl[allconfigkey[i]].length; o++) {
const CurrentconfigUrl = configUrl[allconfigkey[i]][o];
if (isIncludeUrl(rfurl, CurrentconfigUrl)) {
hasCrypto = true;
break;
}
}
}
}
// 发送加密请求
if (hasCrypto) {
xhr.setRequestHeader('hasCrypto', 'true');
xhr.send(desEncrypt(uncompile(url_mseAge), JSON.stringify([key])));
} else {
xhr.send(JSON.stringify([key]));
}
// 处理加密响应
if (! isJSONTmp) {
var tmpgetunpackbody = getUnPackBody(uncompile(url_mseAge), jsonObj);
}
}
从测试结果可以看到,密钥1保护以下API:
{
"5G": [
"/data-sync/public/network/openUser/xxx",
"/data-sync/public/network/cancelUser/xxx",
"/data-sync/api/account/protected/tel/add",
"/data-sync/api/account/tel/add",
"/data-sync/api/thirdparty/tel/verify"
],
"SID": [
"/linkid/api/aggregate/app/protected/obtain/",
"/linkid/api/aggregate/app/protected/face/user/active/",
"/linkid/protected/api/user/userId/check/",
"/api/protected/realname/preQuery",
"/api/protected/realname/auth",
// ... 共23个接口
]
}
3.2 密钥2:密码加密密钥
3.2.1 存储位置
在 login 第77行:
<div style="display: none">
<p id="login-croypto">B0MCcDgT4Eo=</p>
</div>
实际访问时获得的值为 zgEBWBZtq5s=(动态生成)。
3.2.2 参数提取
从页面隐藏元素中可以看到完整的登录参数:
<div style="display: none">
<p id="current-login-type">UsernamePassword</p>
<p id="login-croypto">zgEBWBZtq5s=</p>
<p id="login-page-flowkey">edd59515-9448-42e3-8f17-cb1e8abb8d9e_H4sIAA... </p>
<p id="captcha-url">api/captcha/generate/DEFAULT</p>
<p id="redirect-uri">service=http%3A%2F%2Fjwgl.njtech.edu.cn%2Fsso%2Fktiotlogin%3Fstatus%3Dsuccess</p>
<p id="execution">3dc54c41-da03-4f06-8052-823888cc4e9d_H4sIAA...</p>
</div>
3.3 密钥独立性验证
从实际测试的登录请求可以看到:
POST /cas/login
username=202412345
password=TeDO/u3gzkxtgmHPJV47qA== # 使用密钥2加密
croypto=zgEBWBZtq5s= # 密钥2原样传回
type=UsernamePassword
_eventId=submit
execution=3dc54c41-da03-4f06-8052-823888cc4e9d_H4sIAA...
登录流程中没有使用密钥1,证明两个密钥完全独立。
4. 加密算法分析
4.1 加密函数定位
在 deploy.js 第7-15行:
var desEncrypt = function (key, content) {
const keyHex = CryptoJS.enc.Base64.parse(key);
const encString = CryptoJS.DES.encrypt(content, keyHex, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return encString.toString();
};
4.2 解密函数
在 deploy.js 第1-6行:
var desDecrypt = function (key, content) {
const keyHex = CryptoJS.enc. Base64.parse(key);
const decString = CryptoJS.DES.decrypt(content, keyHex, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad. Pkcs7
});
return decString.toString(CryptoJS.enc.Utf8);
};
4.3 DES算法特征
从 crypto-js.min.js 中可以看到DES实现的关键部分:
var d = u.DES = s.extend({
_doReset: function() {
// 密钥调度
for (var t = this._key.words, r = t.words, e = t.sigBytes / 4,
i = this._nRounds = e + 6, n = 4 * (i + 1), s = this._keySchedule = [],
a = 0; a < n; a++)
// ...
},
encryptBlock: function(t, r) {
this._doCryptBlock(t, r, this._keySchedule, a, c, h, l, o)
},
decryptBlock: function(t, r) {
var e = t[r + 1];
t[r + 1] = t[r + 3],
t[r + 3] = e,
this._doCryptBlock(t, r, this._invKeySchedule, f, u, d, v, s);
// ...
}
});
4.4 ECB模式特征
从加密配置可以看到使用了ECB模式:
{
mode: CryptoJS.mode. ECB,
padding: CryptoJS.pad.Pkcs7
}
ECB模式的工作原理:
明文: [Block1] [Block2] [Block3]
↓ ↓ ↓
DES-E DES-E DES-E
↓ ↓ ↓
密文: [Cipher1][Cipher2][Cipher3]
每个块独立加密,不依赖前后块。
4.5 PKCS7填充
从 crypto-js.min.js 可以看到填充实现:
var _ = p. Pkcs7 = {
pad: function(t, r) {
for (var e = 4 * r, i = e - t.sigBytes % e,
n = i << 24 | i << 16 | i << 8 | i, s = [], a = 0; a < i; a += 4)
s.push(n);
var c = o.create(s, i);
t.concat(c)
},
unpad: function(t) {
var r = 255 & t.words[t.sigBytes - 1 >>> 2];
t.sigBytes -= r
}
};
填充规则:
原始数据长度 % 8 == 剩余字节数:
0字节 → 填充 08 08 08 08 08 08 08 08
1字节 → 填充 07 07 07 07 07 07 07
2字节 → 填充 06 06 06 06 06 06
...
7字节 → 填充 01
4.6 加密过程验证
测试输出:
// 输入
密钥: zgEBWBZtq5s=
明文: test123
// 加密过程
1. Base64解码密钥: ce0101581656db9b (8字节)
2. UTF-8编码明文: 74657374313233 (7字节)
3. PKCS7填充: 7465737431323301 (8字节, 填充了0x01)
4. DES-ECB加密: 0acbdcc58742a8d4 (8字节)
5. Base64编码: CsvcxYdCqNQ=
// 输出
加密结果: CsvcxYdCqNQ=
5. 前端混淆破解
5.1 差分编码原理
5.1.1 编码过程推导
从 uncompile 函数的逆向分析可以推导出编码过程:
// 解码: result[i] = encoded[i] - result[i-1]
// 反推编码: encoded[i] = original[i] + original[i-1]
原始字符串: S p h G 5 l Q m U o U =
ASCII码: 83 112 104 71 53 108 81 109 85 111 85 61
长度: 12
编码过程:
位置0: 83 + 12 = 95 → '_'
位置1: 112 + 83 = 195 → 'Ã' (escape后 %C3)
位置2: 104 + 112 = 216 → 'Ø' (escape后 %D8)
位置3: 71 + 104 = 175 → '¯' (escape后 %AF)
位置4: 53 + 71 = 124 → '|' (escape后 %7C)
位置5: 108 + 53 = 161 → '¡' (escape后 %A1)
位置6: 81 + 108 = 189 → '½' (escape后 %BD)
位置7: 109 + 81 = 190 → '¾' (escape后 %BE)
位置8: 85 + 109 = 194 → 'Â' (escape后 %C2)
位置9: 111 + 85 = 196 → 'Ä' (escape后 %C4)
位置10: 85 + 111 = 196 → 'Ä' (escape后 %C4)
位置11: 61 + 85 = 146 → ''' (escape后 %92)
最终: '_%C3%D8%AF%7C%A1%BD%BE%C2%C4%C4%92'
5.1.2 算法特征
差分编码的特点:
- 可逆性:解码函数可以完全恢复原始数据
- 依赖性:每个字符依赖前一个字符
- 错误传播:一个字符错误会影响后续所有字符
- 长度保持:编码后长度不变
5.2 URL编码层
escape() 函数的作用:
// ASCII可见字符(不编码)
'_' → '_'
'|' → '|'
// 非ASCII字符(编码)
'Ã' (195) → '%C3'
'Ø' (216) → '%D8'
'¯' (175) → '%AF'
5.3 多层解密
从 deploy.js 第126-145行可以看到多层解密逻辑:
function getUnPackBody(key, body) {
let dec = '';
let isJSON = false;
const convertData = function (body) {
// 开始解密
dec = desDecrypt(key, body);
isJSON = false;
if (dec != '') {
try {
JSON.parse(dec);
isJSON = true;
return;
} catch (str_error) {
isJSON = false;
}
// 如果解密结果不是JSON,继续解密
if (!isJSON) {
convertData(dec);
}
}
};
convertData(body);
return JSON.parse(dec || null);
}
这说明服务器可能返回嵌套加密的数据,需要递归解密直到得到有效的JSON。
6. 动态参数分析
6.1 execution参数
从 login 可以看到execution的格式:
<input type="hidden" name="execution"
value="3dc54c41-da03-4f06-8052-823888cc4e9d_H4sIAAAAAAAAAO1ce2wcxRkfn9+OEzvBhKSUNE3j8Msdn... "/>
格式分析:
3dc54c41-da03-4f06-8052-823888cc4e9d_H4sIAAAAAAAAAO1ce2xcxRkfn9+...
组成部分:
├─ UUID部分: 3dc54c41-da03-4f06-8052-823888cc4e9d
│ • 长度: 36字符
│ • 格式: 8-4-4-4-12
│ • 作用: 唯一标识本次登录流程
│
└─ 数据部分: H4sIAAAAAAAAAO1ce2wcxRkfn9+...
• 格式: Base64编码
• 识别: H4sI 是Gzip压缩的Magic Number
• 内容: Gzip(JSON数据)
6.2 login-page-flowkey参数
从 login 第80行:
<p id="login-page-flowkey">edd59515-9448-42e3-8f17-cb1e8abb8d9e_H4sIAAAAAAAAALU6a2xb53lH1FuWLdmWnbhZXE... </p>
与execution格式相同:UUID_Base64GzipData
6.3 验证码URL
从 login 第79行:
<p id="captcha-url"></p>
该元素为空,但从 deploy.js 第285行可以看到检测逻辑:
var captchaUrlElem = doc.getElementById('captcha-url');
if (captchaUrlElem && captchaUrlElem.innerText. trim()) {
this.captchaUrl = `${this.gateUrl}/${captchaUrlElem.innerText.trim()}`;
}
从 loginNew.js 可以看到验证码相关代码:
var ruleList_content = document.body;
var d_style = null;
if (window.getComputedStyle) {
d_style = window.getComputedStyle(ruleList_content, null);
} else {
d_style = ruleList_content.currentStyle;
}
if (d_style. content. indexOf('x') !== -1) {
if(document.getElementById('recaptcha-invisible')){
document.getElementById('recaptcha-invisible').innerText = true;
document.getElementById('captcha-url').innerText = "api/captcha/generate/DEFAULT";
}
}
通过CSS的 content 属性判断是否需要验证码。
6.4 认证类型列表
从 login 第31-42行可以看到支持的认证方式:
<div style="display: none" class="ruleList">
<div class="code">UsernamePassword</div>
<div class="showName">用户名密码认证</div>
<div class="image">./images/login-page/UsernamePassword.png</div>
<div class="isRedirect">false</div>
</div>
<div style="display: none" class="ruleList">
<div class="code">smsLogin</div>
<div class="showName">手机验证码认证</div>
<div class="image">./images/login-page/smsLogin.png</div>
<div class="isRedirect">false</div>
</div>
<div style="display: none" class="ruleList">
<div class="code">corpwechatQr</div>
<div class="showName">企业微信扫码认证</div>
<div class="image"></div>
<div class="isRedirect">false</div>
</div>
6.5 第三方认证
从 login 第85-132行可以看到完整的第三方认证列表:
<div id="list-providers" class="card d-sm-none d-md-block bg-light">
<div class="card-body">
<h3 class="card-title">Or login with:</h3>
<div class="card-text">
<ul>
<li><a href="clientredirect? client_name=adapter&service=... ">adapter</a></li>
<li><a href="clientredirect?client_name=corpWechat&service=...">corpWechat</a></li>
<li><a href="clientredirect?client_name=Wechat&service=... ">Wechat</a></li>
<li><a href="clientredirect?client_name=tyEduCloud&service=...">tyEduCloud</a></li>
<li><a href="clientredirect?client_name=dingDing&service=...">dingDing</a></li>
<li><a href="clientredirect?client_name=dingDingWlan&service=...">dingDingWlan</a></li>
<li><a href="clientredirect?client_name=singleWechatOauth&service=...">singleWechatOauth</a></li>
<li><a href="clientredirect? client_name=feishuOauth&service=...">feishuOauth</a></li>
<li><a href="clientredirect?client_name=zhilinOauth&service=... ">zhilinOauth</a></li>
<li><a href="clientredirect?client_name=isni&service=...">isni</a></li>
<li><a href="clientredirect?client_name=ssoOauth&service=...">ssoOauth</a></li>
<li><a href="clientredirect?client_name=corpWechatOauth2&service=...">corpWechatOauth2</a></li>
<li><a href="clientredirect?client_name=welinkOauth&service=...">welinkOauth</a></li>
<li><a href="clientredirect?client_name=ccwork&service=...">ccwork</a></li>
</ul>
</div>
</div>
</div>
7. 登录流程解析
7.1 请求参数完整映射
从实际抓包数据:
POST /cas/login HTTP/1.1
username=202412345
type=UsernamePassword
_eventId=submit
geolocation=
execution=3dc54c41-da03-4f06-8052-823888cc4e9d_H4sIAA...
captcha_code=a3d2
croypto=zgEBWBZtq5s=
password=TeDO/u3gzkxtgmHPJV47qA==
对应关系:
| 表单字段 | 来源 | 说明 |
|---|---|---|
| username | 用户输入 | 学号/用户名 |
| password | 加密计算 | DES(密钥2, 明文密码) |
| croypto | #login-croypto |
密钥2原值 |
| type | #current-login-type |
固定值 UsernamePassword |
| _eventId | 固定值 | submit |
| execution | <input name="execution"> |
UUID_GzipBase64 |
| captcha_code | 用户输入 | 验证码(可选) |
| geolocation | 固定值 | 空字符串 |
7.2 加密时序
1. 页面加载
↓
2. 提取 login-croypto → 密钥2 (zgEBWBZtq5s=)
↓
3. 用户输入密码 → password (test123)
↓
4. Base64解码密钥2
zgEBWBZtq5s= → [0xce, 0x01, 0x01, 0x58, 0x16, 0x56, 0xdb, 0x9b]
↓
5. DES-ECB加密
plaintext: test123 (7字节)
padding: test123\x01 (8字节, PKCS7)
encrypted: [0x0a, 0xcb, 0xdc, 0xc5, 0x87, 0x42, 0xa8, 0xd4]
↓
6. Base64编码
CsvcxYdCqNQ=
↓
7. 提交表单
7.3 设备检测流程
从 loginNew.js 可以看到完整的设备检测逻辑:
// 1. 创建检测元素
var newElement = document.createElement('div');
newElement.classList.add('pl_box');
newElement.id ='pl_dom';
document.body.appendChild(newElement);
// 2. CSS媒体查询
// @media screen and (orientation: portrait) {
// .pl_box { width: 20px; height: 30px; }
// }
// @media screen and (orientation: landscape) {
// .pl_box { width: 30px; height: 20px; }
// }
// 3. 判断方向
const width = document.getElementById('pl_dom').clientWidth;
const height = document. getElementById('pl_dom').clientHeight;
if (parseInt(width) < parseInt(height)) {
neworientation. current = 'portrait'; // 竖屏
} else {
neworientation. current = 'landscape'; // 横屏
}
// 4. 根据方向加载不同页面
if (landscape && res == 'new') {
device = 'PC';
script. onload = function () {
window.casPageInit("loginnew");
};
} else if (landscape) {
device = 'PC';
script.onload = function () {
window.casPageInit("login");
};
} else if (portrait) {
device = 'PHONE';
script.onload = function () {
window.casPageInit("login");
};
}
7.4 自动重定向逻辑
从 login 第203-229行:
var list = document.getElementsByClassName('ruleList');
var isAdapterRedirect = false;
var isAutoRedirect = false;
var isAutoRedirectClientName = null;
// 检查是否有自动重定向的认证方式
for (var i = 0; i < list.length; i++) {
var ele = list[i]. getElementsByClassName('code')[0];
var code = ele.innerText;
var autoRedirect = list[i].getElementsByClassName('isRedirect')[0];
var autoRedirectCode = autoRedirect.innerText;
if (autoRedirectCode == 'true') {
isAutoRedirect = true;
isAutoRedirectClientName = code;
break;
}
if (code == 'adapter') {
isAdapterRedirect = true;
}
}
// 执行重定向
if (isAutoRedirect) {
url = url.replace('#client_name', isAutoRedirectClientName);
window.location. href = url;
}
if (isAdapterRedirect) {
url = url.replace('#client_name', 'adapter');
window.location.href = url;
}
7.5 配置请求流程
从 deploy.js 第267-283行:
getSelfConfig('URL_CRYPTO_DATA', function () {
console.log(configUrl, config);
getSelfConfig('pc. self.page.config', function () {
console.log(configUrl, config);
if (pageName === 'serviceerror') {
// 处理服务错误页面
} else if (pageName === 'logout' || pageName === 'propagatelogout' || pageName === 'error') {
// 处理登出页面
if (document.getElementById('logout-header-logo')) {
document.getElementById('logout-header-logo').src = window.convertHref(config.logo);
}
if (document.getElementById('logout-body-bg')) {
document.getElementById('logout-body-bg').style.backgroundImage = "url('" + window.convertHref(config.background) + "')";
}
} else {
// 处理登录页面
_casPageInit();
}
});
});
配置加载顺序:
URL_CRYPTO_DATA- 获取需要加密的API列表pc.self.page.config- 获取页面自定义配置- 根据页面类型执行相应初始化
7.6 错误处理
从 login 第234-252行:
var clientType = document.getElementById("current-login-type").innerText;
var errorCode = document.getElementById("login-error-code").innerText;
if (errorCode === "1410041" && (clientType == "dingDingWlan" || clientType == "adapter" || ... )) {
if (clientType == "") {
clientType = "appToken"
}
url = "./public/client/bind/fail?clientname=#clientname";
url = url.replace('#clientname', clientType);
window.location.href = url;
}
if (errorCode === "1030028" && (clientType == "dingDingWlan" || clientType == "adapter" || ...)) {
if (clientType == "") {
clientType = "appToken"
}
url = "./public/client/bind/error?clientname=#clientname";
url = url.replace('#clientname', clientType);
window.location.href = url;
}
错误码含义:
1410041- 绑定失败,跳转到bind/fail页面1030028- 绑定错误,跳转到bind/error页面
- 本报告的所有内容仅供学习和研究使用。作者通过分析公开可访问的前端代码和网络请求,学习和理解现代Web应用的认证机制,加密实现和前端架构设计。
- 作者从未、也绝不会利用本内容进行任何非法活动。
- 作者不对他人使用本报告内容产生的任何后果承担责任。
- 任何人使用本报告内容从事违法活动,均与作者无关,由使用者自行承担全部法律责任。