cqu-login-password-reverse

概述

本文是一篇针对重庆大学统一身份认证系统(authserver.cqu.edu.cn)的前端密码加密逆向实战笔记。我们将通过浏览器开发者工具一步步找到加密入口,拆解其内部逻辑,最终用 Python 完整复现加密流程,并模拟登录请求。

目标系统采用了一种常见的 AES-128-CBC 模式进行密码加密,同时结合了自定义随机字符集生成的盐值前缀与随机 IV,使得同一明文密码每次加密产生的密文都不同,有效防止了彩虹表等攻击。

免责声明:本文仅用于学习与研究前端逆向技术,请勿将文中方法用于任何非法途径。使用时应遵守学校的网络与信息安全规范。


浏览器逆向分析:定位加密入口

首先,我们需要通过 Chrome DevTools(其他现代浏览器操作类似)找到密码加密的核心代码。

步骤1:打开登录页与开发者工具

访问登录页面: http://authserver.cqu.edu.cn/authserver/login
按下 F12 打开开发者工具,切换到 Network(网络) 面板,并勾选 Preserve log(保留日志),防止页面跳转后请求记录丢失。

步骤2:触发登录并抓包

输入一个任意的用户名和密码(不要使用真实密码,仅用于触发请求),点击“登录”按钮。此时在 Network 面板中会看到一个 POST 请求,过滤关键词 login 可以快速定位到它。

该请求的核心参数包括:

  • username:明文用户名
  • password:一串 Base64 编码的密文
  • ltdlltexecution 等一次性参数

重点关注 password 的生成方式。

步骤3:定位加密函数

跟踪 password 参数的生成位置有以下常用方法:

  1. Network 面板搜索:在搜索框中输入 encryptpwdDefaultEncryptSalt(常见的加密盐名),可以快速找到相关代码片段。
  2. XHR 断点 + 调用栈回溯:在 Sources(源代码)面板右侧添加一个 XHR/fetch Breakpoint,断点 URL 设置为 *login*,再次点击登录。请求发送前会暂停,此时通过右侧的 Call Stack 向上追溯,就可以找到发起加密调用的 JavaScript 代码。

最终,我们会在页面嵌入的 <script> 标签内定位到完整的加密逻辑。


加密流程核心拆解

定位到的加密代码主要由三个部分组成,下面逐一分析。

1. AES-128-CBC 底层实现

系统直接使用了经典的 CryptoJS 库来封装 AES 加密。核心函数如下:

const CryptoJS = require("crypto-js");

function getAesString(data, key0, iv0) {
  // 去除密钥两端可能存在的空格
  key0 = key0.replace(/(^\s+)|(\s+$)/g, "");
  var key = CryptoJS.enc.Utf8.parse(key0);
  var iv = CryptoJS.enc.Utf8.parse(iv0);
  var encrypted = CryptoJS.AES.encrypt(data, key, {
    iv: iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  });
  // 返回 Base64 格式的密文
  return encrypted.toString();
}

关键点

  • 密钥长度固定为 16 字节(对应 AES-128)
  • 加密模式为 CBC,填充方式为 Pkcs7
  • 加密结果直接转为 Base64 字符串,与抓包看到的 password 格式一致

2. 自定义随机字符串生成

为了让同一密码每次加密结果都不同,系统使用了一套去掉易混淆字符的字符集来生成随机盐值与 IV。

var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var aes_chars_len = $aes_chars.length;

function randomString(len) {
  var retStr = '';
  for (i = 0; i < len; i++) {
    retStr += $aes_chars.charAt(Math.floor(Math.random() * aes_chars_len));
  }
  return retStr;
}

可以看到,字符集中去掉了 oOLl9gqVvUuI1 这些容易混淆的字符,避免展示或调试时出错。

3. 密码加密主函数

主函数 encryptAES 是前端调用的入口,它将随机盐值前缀、原始密码、随机 IV 与固定密钥组合起来:

function encryptAES(data, aesKey) {
  if (!aesKey) {
    return data;
  }
  // 核心组合:64位随机前缀 + 原始密码,再用随机16位IV加密
  var encrypted = getAesString(randomString(64) + data, aesKey, randomString(16));
  return encrypted;
}

处理流程

  1. 生成 64 位随机字符串作为盐值前缀
  2. 随机前缀 + 明文密码 拼接得到待加密数据
  3. 生成 16 位随机字符串作为 IV
  4. 使用后台下发的固定密钥(aesKey)进行 AES-128-CBC 加密

登录流程完整复现

要模拟完整的登录,除了加密密码,还需要从登录页面获取服务器动态下发的加密密钥以及登录所需的一次性参数(如 ltexecution)。

前置准备

  1. 安装所需 Python 库:

    pip install requests beautifulsoup4 PyExecJS

    提示:PyExecJS 依赖本地 Node.js 环境,请确保已安装 Node.js。

  2. 将前端加密代码整理为独立 JS 文件,保存为 cqu_encrypt.js。注意:若在 Node.js 环境执行,需保留 require("crypto-js") 语句;若直接复制浏览器代码,可能需要做相应调整。建议先在同一目录安装 crypto-js:

    npm install crypto-js

Python 完整代码

以下代码会完成:获取登录页面 → 提取密钥和参数 → 调用 JS 加密 → 发送模拟登录请求。

import requests
from bs4 import BeautifulSoup
import re
import execjs

# ====================== 配置部分 ======================
ENCRYPT_JS_PATH = "./cqu_encrypt.js"          # 加密 JS 文件路径
LOGIN_URL = "http://authserver.cqu.edu.cn/authserver/login"
TEST_USERNAME = "test_user"                  # 测试账号
TEST_PASSWORD = "test_pwd_123"               # 测试密码(切勿使用真实密码)
# ====================== 配置部分 ======================

COMMON_HEADERS = {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
    "Content-Type": "application/x-www-form-urlencoded",
    "Origin": "http://authserver.cqu.edu.cn",
    "Pragma": "no-cache",
    "Referer": LOGIN_URL,
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
}

def get_login_params_and_cookies(session: requests.Session) -> tuple:
    """
    从登录页 HTML 中提取:
    1. 加密密钥 pwdDefaultEncryptSalt
    2. 一次性参数 lt、dllt、execution 等
    3. 登录页 cookies
    """
    response = session.get(LOGIN_URL, headers=COMMON_HEADERS, verify=False)
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    # 提取加密密钥
    pwd_salt = ""
    for script in soup.find_all("script"):
        if script.string:
            match = re.search(r'pwdDefaultEncryptSalt\s*=\s*"([^"]+)"', script.string)
            if match:
                pwd_salt = match.group(1)
                break
    if not pwd_salt:
        raise ValueError("未找到加密密钥 pwdDefaultEncryptSalt!")

    # 提取隐藏 input 参数
    login_params = {}
    for inp in soup.find_all("input", type="hidden"):
        name = inp.get("name")
        value = inp.get("value", "")
        if name:
            login_params[name] = value

    # 补充固定参数和测试账号
    login_params["username"] = TEST_USERNAME
    login_params["dllt"] = login_params.get("dllt", "userNamePasswordLogin")
    login_params["_eventId"] = login_params.get("_eventId", "submit")
    login_params["rmShown"] = login_params.get("rmShown", "1")

    return pwd_salt, login_params, session.cookies

def encrypt_password(password: str, salt: str) -> str:
    """调用 JS 加密函数,返回加密后的密码"""
    with open(ENCRYPT_JS_PATH, "r", encoding="utf-8") as f:
        js_code = f.read()
    ctx = execjs.compile(js_code)
    return ctx.call("encryptAES", password, salt)

if __name__ == "__main__":
    requests.packages.urllib3.disable_warnings()
    session = requests.Session()

    try:
        print("正在获取登录参数和加密密钥...")
        pwd_salt, login_params, cookies = get_login_params_and_cookies(session)
        print(f"获取到的加密密钥:{pwd_salt}")

        print("正在加密密码...")
        encrypted_pwd = encrypt_password(TEST_PASSWORD, pwd_salt)
        login_params["password"] = encrypted_pwd
        print(f"加密后的密码:{encrypted_pwd}")

        print("正在发送模拟登录请求...")
        login_response = session.post(
            LOGIN_URL,
            headers=COMMON_HEADERS,
            data=login_params,
            verify=False,
            allow_redirects=True
        )
        login_response.raise_for_status()

        if "统一身份认证" not in login_response.text:
            print("✅ 模拟登录请求成功!(可能已跳转)")
        else:
            print("❌ 模拟登录请求失败!(仍在登录页)")
    except Exception as e:
        print(f"❌ 出错了:{str(e)}")

配套前端 JS 代码(cqu_encrypt.js)

// 注意:若使用 Node.js 执行,需预先执行 npm install crypto-js
const CryptoJS = require("crypto-js");

function getAesString(data, key0, iv0) {
  key0 = key0.replace(/(^\s+)|(\s+$)/g, "");
  var key = CryptoJS.enc.Utf8.parse(key0);
  var iv = CryptoJS.enc.Utf8.parse(iv0);
  var encrypted = CryptoJS.AES.encrypt(data, key, {
    iv: iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  });
  return encrypted.toString();
}

var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var aes_chars_len = $aes_chars.length;
function randomString(len) {
  var retStr = '';
  for (i = 0; i < len; i++) {
    retStr += $aes_chars.charAt(Math.floor(Math.random() * aes_chars_len));
  }
  return retStr;
}

function encryptAES(data, aesKey) {
  if (!aesKey) {
    return data;
  }
  var encrypted = getAesString(randomString(64) + data, aesKey, randomString(16));
  return encrypted;
}

安全分析与注意事项

  1. 密钥暴露风险:加密密钥 pwdDefaultEncryptSalt 直接以明文形式写在登录页的 JavaScript 中,任何能够访问登录页的攻击者都能获取。这使得前端加密主要起到防明文日志泄露的作用,而无法抵御中间人攻击或主动提取密钥的威胁。
  2. 随机性设计:每次加密时在密码前拼接 64 位随机盐值,再使用随机 IV 进行 CBC 加密,保证了同一密码每次生成密文均不同,有效抵御了彩虹表攻击,同时增加了密文比对分析的难度。
  3. 传输层安全:目前访问使用的是 HTTP 协议,即使密码被加密,整个会话流仍暴露在非加密通道下,存在被中间人篡改或窃取的风险。建议系统升级为 HTTPS 以提供传输层保护。
  4. 学习目的:本文仅供技术交流,请勿将方法用于非法用途。未经授权对系统进行测试可能违反法律法规和校规。

总结

本文通过浏览器开发者工具定位了重庆大学统一身份认证系统的前端加密代码,详细拆解了 AES-128-CBC 加密模式、自定义随机盐值与 IV 的生成逻辑,并使用 Python + PyExecJS 完整复现了密码加密过程,最终实现了模拟登录请求。

这是一个非常典型的高校/企业统一身份认证前端加密案例,掌握了这种分析思路和复现方法后,可以快速应对类似系统的逆向需求。希望本教程能为你的前端逆向学习之旅提供一些帮助。

再次提醒:请在合法、合规的前提下学习和测试相关技术。