现在一些网站对 JavaScript 代码采取了一定的保护措施,比如变量名混淆、执行逻辑混淆、反调试、核心逻辑加密等,有的还对数据接口进行了加密,这次的案例就是对一种 AES 加密方式的破解。
AES 是对称加密,对称加密是指加密和解密时使用同一个密钥,这种加密方式加密速度非常快,适合经常发送数据的场合,缺点是密钥的传输比较麻烦。
AES 相关资料可参考:对称加密及 AES 加密算法
很推荐阅读这篇博客:一文彻底搞懂加密、数字签名和数字证书
本文章中所有内容仅供学习交流,相关链接做了脱敏处理,若有侵权,请联系我立即删除!
网址:aHR0cHM6Ly9hY2NvdW50LnhpYW9taS5jb20vZmUvc2VydmljZS9sb2dpbi9wYXNzd29yZD9fbG9jYWxlPXpoX0NO
登录接口:aHR0cHM6Ly9hY2NvdW50LnhpYW9taS5jb20vcGFzcy9zZXJ2aWNlTG9naW5BdXRoMg==
以上均做了脱敏处理,Base64 编码及解码方式:
import base64
# 编码
# result = base64.b64encode('待编码字符串'.encode('utf-8'))
# 解码
result = base64.b64decode('待解码字符串'.encode('utf-8'))
print(result)
一般情况下,JavaScript 逆向分为三步:
接下来开始正式进行案例分析:
进入到某米商城的登录页面,F12 打开开发者人员工具,切换到 network 准备查看网络抓包请求,随便输入一个账号、密码,查看网络抓包请求情况,如下图,可以看到抓包到的这条数据,请求方式为 POST,Form Data 中存在一些表单 json 格式提交的参数信息,同时在 Preview 响应预览中可以看到验证失败提示,证明这里就是点击登录返回响应的位置,即找到了登录的入口:
接下来进一步分析响应头中的信息,可以看到请求方式是 POST,响应状态码为 200,不存在以前的 302 重定向情况:
那么我们直接观察 Headers 中响应的参数情况(Form Data),直接观察得不出结论,我们可以再输入一个账号、密码,将新抓包到的接口数据来与之前的作对比:
由上图可知,hash 和 user 的值是变化的,其他的都是定值,可以初步推断这两个值是用户名和密码经过加密后得到的结果,接下来我们就需要寻找这两个参数的加密位置,先来定位 hash 的位置,CTRL+SHIFT+F 全局搜索 hash + 冒号,会出现好几个结果,点击 DHome~DSNS.ddb02494.chunk.js 进入,再 CTRL+F 局部搜索 hash + 冒号,可以看到只有一条结果:
以上 2865 行 hash: 后 j()(a.password).toUpperCase() 这个方法看起来就很像字符串经过加密之后转换成了大写,我们在这打断点进一步调试分析看看,在这行打下断点后,再次点击登录按钮,会发现成功断住了,证明这里就是登录响应的位置:
鼠标悬停在 a.password 上会出现一串明文结果:123456,这就是我们输入的密码内容,至此为密码加密的位置,a.password 处是明文密码内容,那么加密过程肯定是在 j() 函数中进行的,整体选中 j() 进入到其的构造位置 DHome~DSNS.ddb02494.chunk.js:formatted:3821:
在 3852 行 return 处打下断点进行调试,F8 或点击以下按钮(resume script execution)执行到下一个断点位置,可以看到成功断住,e 为明文密码:
return 中的值存在着一定的混淆,这里是个三目运算方法,由上图可知,n 是未定义的,所以前面的值皆为 false,最后 return 的值为 t.bytesToHex(r),鼠标悬停在它上面,可以看到是密码加密后的值:
鼠标悬停在 t.bytesToHex 上,进入到这个方法的构造位置,DHome~DSNS.ddb02494.chunk.js:formatted:921:
这里先调用 wordsToBytes() 方法将明文密码字符串转为 byte 数组,无论密码的长度如何,最后得到的 byte 数组都是 16 位的,然后调用 bytesToHex() 方法,循环遍历生成的 byte 类型数组,让其生成 32 位字符串,无论密码长度如何,最终得到的密文都是 32 位的,而且都由字母和数字组成,这里可以推测为 MD5 加密,接下来进行验证:
123456 加密后的值为:e10adc3949ba59abbe56e057f20f883e,以下用 MD5 对 123456 进行加密测试,可以看到结果是一样的,验证了我们的猜想:
# python 复现
import hashlib
password = '123456'
encrypted_password = hashlib.md5(password.encode(encoding='utf-8')).hexdigest().upper()
print(encrypted_password)
# e10adc3949ba59abbe56e057f20f883e
以上思路参考:K哥爬虫
之前某米商城的登录参数中 user 的值是明文显示的,现在对其进行了加密处理,现在对这个参数的加密方式进行调试分析:
在刚刚打断点的地方上面 2863 行,鼠标悬停后可以看到,user 的值在这里已经被加密过了:
一步步跟踪 v 到 f 到 u 的构造位置,可以看到 u = l.encryptAes,‘AES’?这是暗示还是明示呢,我们进一步跟踪进去看看,鼠标悬停跟踪进:
在函数末尾打断点调试看看,可以看到在 2982 行,user 已经被加密完毕了:
从 2975 行开始,这是个很明显的 AES 加密结构:
iv:偏移量或初始向量,与密钥结合使用,作为加密数据的手段,它是一个固定长度的值,iv 的长度取决于加密方法,通常与使用的加密密钥或密码块的长度相当,一般在使用过程中会要求它是随机数或拟随机数
padding:填充方式,块密码只能对确定长度的数据块进行处理,而消息的长度通常是可变的,因此部分模式最后一块数据在加密前需要进行填充
底下这一部分,看起来很像 RSA 加密算法中的公钥内容,但是仔细观察会发现,只有 EUI 对象调用了 h 这个变量,而且通过逐行调试会发现 user 的结果在 g[e] = i 的时候就加密生成了,所以可以忽略这部分的内容:
接下来可以通过 JavaScript 对其加密过程进行复现:
var CryptoJS = require('crypto-js');
function Pt(t) {
t = t || {};
var i = function(t) {
for (var e = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*", r = "", i = 0; i < t; i++) {
var n = Math.floor(Math.random() * e.length);
r += e.substring(n, n + 1)
}
return r
}(16)
var u = CryptoJS.enc.Utf8.parse("0102030405060708")
, f = CryptoJS.enc.Utf8.parse(i);
var result;
Object.keys(t).forEach(function(e) {
var r = t[e]
, i = CryptoJS.AES.encrypt(r, f, {
iv: u,
padding: CryptoJS.pad.Pkcs7
});
result = i.toString()
});
return result
}
console.log(Pt({user: "123333"}));
以下及成功复现:
我们可以进一步对其进行校验,比如将断点打在 2966 行,就会看到 i 的值在加密之前的值,再将这个值写入到 f = CryptoJS.enc.Utf8.parse(i); 这行代码中,即将密钥 f 的值写死,看最后生成的加密结果是否和调试过程中显示的一样,改写后的代码如下:
var CryptoJS = require('crypto-js');
function Pt(t) {
t = t || {};
var i = function(t) {
for (var e = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*", r = "", i = 0; i < t; i++) {
var n = Math.floor(Math.random() * e.length);
r += e.substring(n, n + 1)
}
return r
}(16)
var u = CryptoJS.enc.Utf8.parse("0102030405060708")
, f = CryptoJS.enc.Utf8.parse("%9eGpDwU*VkBQTBV");
var result;
Object.keys(t).forEach(function(e) {
var r = t[e]
, i = CryptoJS.AES.encrypt(r, f, {
iv: u,
padding: CryptoJS.pad.Pkcs7
});
result = i.toString()
});
return result
}
console.log(Pt({user: "123456"}));
然后在 2967 行开始逐行调试,直到得出 i 加密后的值,对比验证可以发现加密结果值一样,逻辑复现正确,user 参数逆向解决:
import json
import hashlib
import urllib.parse
import execjs
import requests
login_url = 'aHR0cHM6Ly9hY2NvdW50LnhpYW9taS5jb20vcGFzcy9zZXJ2aWNlTG9naW5BdXRoMg=='
headers = {
'Host': '去 login_url 的请求头中复制即可',
'Origin': '去 login_url 的请求头中复制即可',
'Referer': '去 login_url 的请求头中复制即可',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
session = requests.session()
def get_encrypted_password(password):
encrypted_password = hashlib.md5(password.encode(encoding='utf-8')).hexdigest().upper()
return encrypted_password
def get_encrypted_uesr(username):
with open('js_xiaomi.js', 'r', encoding='utf-8') as f:
js_xm = f.read()
user_param = execjs.compile(js_xm).call('Pt', username)
return user_param
def get_parameter():
referer_url = 'Referer 的值'
urlparse = urllib.parse.urlparse(referer_url)
query_dict = urllib.parse.parse_qs(urlparse.query)
return query_dict
def login(username, encrypted_password, query_dict):
data = {
'bizDeviceType': '',
'needTheme': query_dict['needTheme'][0],
'theme': '',
'showActiveX': query_dict['showActiveX'][0],
'serviceParam': query_dict['serviceParam'][0],
'callback': query_dict['callback'][0],
'qs': query_dict['qs'][0],
'sid': query_dict['sid'][0],
'_sign': query_dict['_sign'][0],
'user': username,
'cc': '+86',
'hash': encrypted_password,
'_json': True,
'policyName': 'miaccount',
'captCode': ''
}
response = session.post(url=login_url, data=data, headers=headers)
response_json = json.loads(response.text.replace('&&&START&&&', ''))
print(response_json)
return response_json
def main():
username = '你的用户名'
password = '你的密码'
encrypted_password = get_encrypted_password(password)
encrypted_username = get_encrypted_uesr(username)
parameter = get_parameter()
login(encrypted_username, encrypted_password, parameter)
if __name__ == '__main__':
main()
在结果验证的时候,发现了一个神奇的现象,user 参数的值明显经过加密,我们刚刚也测试了代码的加密逻辑是正确的,但是我们将 user 加密后的值传入到参数字典中,运行结果显示验证失败:
而直接将明文 user 传递到参数字典中,则能验证成功,以下链接部分点进去是安全验证,这里可以通过对发送验证码过程的抓包,过短信验证码实现登陆:
好的,接下来更神奇的事情出现了,我写了个循环登录:
for _ in range(3):
login(username, encrypted_password, parameter)
按道理每次都应该返回的是安全验证页面,但是经过多次尝试,循环登录三次只有第一次会出现安全验证,后面几次都能登陆成功,点击链接能成功跳转到个人账号信息页面:
以上是对某米商城最新加密参数的逆向分析,最后实验结果很令人迷惑,明明是加密的,却只需要传输明文即可,而且循环几次即可过安全验证
如有任何见解欢迎评论区或私信指正交流~