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

2026-01-15T06:35:20.png

📋 目录


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 算法特征

差分编码的特点:

  1. 可逆性:解码函数可以完全恢复原始数据
  2. 依赖性:每个字符依赖前一个字符
  3. 错误传播:一个字符错误会影响后续所有字符
  4. 长度保持:编码后长度不变

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&amp;service=... ">adapter</a></li>
                <li><a href="clientredirect?client_name=corpWechat&amp;service=...">corpWechat</a></li>
                <li><a href="clientredirect?client_name=Wechat&amp;service=... ">Wechat</a></li>
                <li><a href="clientredirect?client_name=tyEduCloud&amp;service=...">tyEduCloud</a></li>
                <li><a href="clientredirect?client_name=dingDing&amp;service=...">dingDing</a></li>
                <li><a href="clientredirect?client_name=dingDingWlan&amp;service=...">dingDingWlan</a></li>
                <li><a href="clientredirect?client_name=singleWechatOauth&amp;service=...">singleWechatOauth</a></li>
                <li><a href="clientredirect? client_name=feishuOauth&amp;service=...">feishuOauth</a></li>
                <li><a href="clientredirect?client_name=zhilinOauth&amp;service=... ">zhilinOauth</a></li>
                <li><a href="clientredirect?client_name=isni&amp;service=...">isni</a></li>
                <li><a href="clientredirect?client_name=ssoOauth&amp;service=...">ssoOauth</a></li>
                <li><a href="clientredirect?client_name=corpWechatOauth2&amp;service=...">corpWechatOauth2</a></li>
                <li><a href="clientredirect?client_name=welinkOauth&amp;service=...">welinkOauth</a></li>
                <li><a href="clientredirect?client_name=ccwork&amp;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();
        }
    });
});

配置加载顺序:

  1. URL_CRYPTO_DATA - 获取需要加密的API列表
  2. pc.self.page.config - 获取页面自定义配置
  3. 根据页面类型执行相应初始化

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应用的认证机制,加密实现和前端架构设计。
  • 作者从未、也绝不会利用本内容进行任何非法活动。
  • 作者不对他人使用本报告内容产生的任何后果承担责任。
  • 任何人使用本报告内容从事违法活动,均与作者无关,由使用者自行承担全部法律责任。