2021-09-13

前端密码 AES-CBC 加密方式的分析

本成果已经成功在 XMU-CheckNJU-Check 打卡系统中成功应用。

背景

如果访问 ids.xmu.edu.cn 这个页面,登录抓包,可以发现密码是加密后的 Base64 编码。

登录抓包

所以如果要模拟登录,直接 POST 密码是不行的(事实上这个页面服务端可能都考虑了,所以不加密密码直接 POST 目前也是可以的),因此自然想到研究一下这个密码的加密方式。

JS 逆向分析

首先,可以定位到表单提交事件位于login—wisedu_v1.0.jsdoLogin函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var casLoginForm = $("#casLoginForm");
casLoginForm.submit(doLogin);
function doLogin() {
    var username = casLoginForm.find("#username");
    var password = casLoginForm.find("#password");
    if (!checkRequired(username, "usernameError")) {
        username.focus();
        return false;
    }

    if (!checkRequired(password, "passwordError")) {
        password.focus();
        return false;
    }

    var captchaResponse = casLoginForm.find("#captchaResponse");
    if (!checkRequired(captchaResponse, "cpatchaError")) {
        captchaResponse.focus();
        return false;
    }

    _etd2(password.val(), casLoginForm.find("#pwdDefaultEncryptSalt").val());
}

函数最后通过_etd2对密码进行加密,可以看到加密时用到了pwdDefaultEncryptSalt字段的值。这个字段是表单中的一个隐藏属性:

salt 值

接下来我们寻找_etd2函数,可以发现在该文件中有这样一段混淆后的代码,格式化之后如下:

1
2
3
4
5
6
7
8
function _etd2(_p0, _p1) {
    try {
        var _p2 = encryptAES(_p0, _p1);
        $("#casLoginForm").find("#passwordEncrypt").val(_p2);
    } catch (e) {
        $("#casLoginForm").find("#passwordEncrypt").val(_p0);
    }
}

可以看到加密后的密码_p2是通过encryptAES这一函数得到。在encrypt.js中发现以下几个相关的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function _gas(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();
}

function encryptAES(data, _p1) {
    if (!_p1) {
        return data;
    }
    var encrypted = _gas(_rds(64) + data, _p1, _rds(16));
    return encrypted;
}

var $_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var _chars_len = $_chars.length;

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

其中,真正的加密操作在_gas函数中进行,encryptAES调用了_gas函数。_rds函数用于生成随机的字符串,因此最后加密的内容为:

  • 数据:_rds(64) + data,即 64 个随机字符+原来的密码
  • 密钥:_p1,即之前的 pwdDefaultEncryptSalt
  • IV(初始向量):_rds(16),即 16 个随机字符

加密方式为 AES-CBC,填充方式为 Pkcs7。这样我们就把密码的加密流程分析清楚了。

密码学分析

第一反应是,这样加密之后,服务端是没有办法解密的!因为 AES-CBC 解密时,除了用到密钥,也要知道 IV 才可以。但是这里的 IV 是随机生成的,也没有在第一节的抓包中看到有把 IV 发送给服务端。如果我们在前端调试这个encryptAES函数,可以发现每次生成的加密密码都不一样:

生成的加密密码

但是如果了解 AES-CBC 的加解密过程1,可以发现,AES-CBC 以 128bit,即 16 字节为一个 Block 进行加解密。前一组的加密结果被用作后一组的初始向量:

所以在解密的时候,如果不知道初始 IV,也可以根据前一个 Block 的密文作为这一个 Block 的 IV 进行解密,因此可以得到除了第一个 Block 以外其他所有 Block 的解密结果:

而 AES-CBC 恰好以 16 字节为一个 Block,所以加密时,原先的密码前方填充了 64 个随机字符,也即多了 4 个没用的 Block,通过第四个 Block 的密文作为第五个 Block 的 IV,即可解密之后的密码。或者随机选取 IV 进行解密,然后丢弃前四个 Block 的结果,也能获得解密后的密码2

实现

按照上述分析的思路,使用pycryptodome,加密和解密的算法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import base64
import random

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

def random_bytes(length: int) -> bytes:
    result = ''
    chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
    for i in range(length):
        result += random.choice(chars)
    return result.encode('utf-8')

def encrypt(password: str, salt: bytes):
    iv = random_bytes(16)
    plain_text = pad(random_bytes(64) + password.encode('utf-8'), 16, 'pkcs7')
    aes_cipher = AES.new(salt, AES.MODE_CBC, iv)
    cipher_text = aes_cipher.encrypt(plain_text)
    return base64.b64encode(cipher_text).decode('utf-8')

def decrpyt(encrypted_password: str, salt: bytes):
    iv = random_bytes(16)
    cipher_text = base64.b64decode(encrypted_password.encode('utf-8'))
    aes_cipher = AES.new(salt, AES.MODE_CBC, iv)
    plain_text = unpad(aes_cipher.decrypt(cipher_text), 16, 'pkcs7')
    return plain_text[64:].decode('utf-8')

用 123456 作为测试,结果如下:

1
2
3
4
5
6
password = '123456'
salt = b'tls6AJdDsockG4az'
for i in range(10):
    encrypted_password = encrypt(password, salt)
    print('Encrpyed:', encrypted_password)
    print('Original:', decrpyt(encrypted_password, salt))

测试

可以看到,每次加密的结果都不一样,但是仅通过密钥 (salt) 都能解密得到相同的数据。

这种做法可以在一定程度上提高安全性,不过因为是对称加密,仍然无法防止中间人攻击,因为中间人可以同时获得服务端事先传来的 salt 和加密后的密码,从而通过上面的算法得到解密后的密码。

更安全的加密方式可以考虑非对称加密,前端通过公钥将密码加密,服务器通过私钥解密。这样中间人在仅获得公钥和密文的情况下也难以得到明文。