JS逆向实战:从AES加密到Python复现的完整流程解析

发布时间:2026/8/26 10:58:55
JS逆向实战:从AES加密到Python复现的完整流程解析 1. 项目概述从“黑盒”到“白盒”的逆向工程实战最近在分析一个前端交互逻辑比较复杂的网站时遇到了一个典型的场景它的核心业务请求参数被一层厚厚的JavaScript混淆代码包裹着所有关键数据在发送前都经过了一套自定义的加密流程。浏览器能正常跑但你想用脚本模拟登录或者爬取数据时直接提交表单是行不通的服务端会毫不留情地返回一个“参数错误”。这就是典型的JS逆向应用场景——我们得把前端这套“黑盒”加密逻辑在Python或其他后端语言里完整地复现出来生成服务端能认的合法参数。这个项目的核心就是针对一个使用了混淆和复杂加密的网站进行完整的JS逆向分析并最终用Python实现其加密、解密以及密钥生成的整套流程。关键词锁定在JS逆向、AES加密、密钥生成。很多朋友一听到“逆向”、“加密”就觉得头大感觉是黑客的领域。其实没那么玄乎你可以把它理解为一次“翻译”工作把浏览器能执行的JavaScript逻辑“翻译”成Python能执行的逻辑。过程中你会像侦探一样去追踪每一个变量的来源还原被混淆的代码结构最终理解其加密算法的本质。这次我们就拿一个模拟的、但极具代表性的案例来走通全流程你会发现一旦掌握了方法和工具链很多看似坚固的防线都是有迹可循的。2. 逆向环境搭建与核心工具链解析工欲善其事必先利其器。在开始逆向之前搭建一个高效的调试和分析环境至关重要。这个环境的核心目标是能够像浏览器一样执行目标JS同时又能让我们以“上帝视角”去观察和干预每一行代码的执行。2.1 浏览器开发者工具第一现场Chrome DevTools 或 Firefox Developer Tools 是我们的主战场。关键面板的使用有讲究Sources/调试器面板这是核心。不仅要会下断点更要熟练使用“Event Listener Breakpoints”。比如当你想知道一个按钮点击后触发了哪些加密函数可以勾选“Mouse - click”点击后代码会自动在事件处理函数入口处暂停。对于XHRAjax请求勾选“XHR/Fetch - XHR”的“当发送请求时”断点能精准捕获到发起网络请求前的那一刻此时正是加密参数生成的关键时刻。Network/网络面板记录所有网络请求。重点关注“Initiator”列它指明了是哪个JS文件、哪一行代码发起了这个请求。点击它可以直接跳转到Sources面板的对应位置这是逆向寻找入口函数最快捷的方式之一。记得勾选“Preserve log”保留日志防止页面跳转后请求记录被清空。Console/控制台不仅仅是输出日志。你可以在这里直接执行当前页面上下文中的任何JavaScript函数或变量用于动态测试。例如当你怀疑某个函数window.encryptData(data)是加密函数时可以在Console里直接调用它传入测试数据观察输出这是验证猜想最快的方法。2.2 Node.js 与本地调试剥离环境依赖浏览器的环境毕竟复杂且网站可能会检测调试器。将关键JS代码剥离出来在本地用Node.js运行和调试是更干净、更可控的方案。提取代码在Sources面板找到核心的JS文件通常是经过Webpack等打包的app.xxxxxx.js或vendor.xxxxxx.js将包含加密逻辑的片段可能是一个IIFE立即执行函数或一个导出的模块复制出来。补全环境浏览器提供了window、document、navigator等大量对象而被提取的代码可能依赖其中某些属性。你需要分析代码用Node.js模拟这些环境。例如如果代码用到了window.btoaBase64编码在Node.js中就需要用Buffer.from(str).toString(base64)来替代如果用了Crypto.getRandomValues则需要引入crypto模块。使用VS Code等IDE调试在Node.js环境中你可以像调试后端代码一样设置断点、单步执行、查看调用栈这比在浏览器里追混淆代码要清晰得多。这是理解复杂逻辑的利器。2.3 反混淆与代码美化工具让代码“说人话”现代网站普遍会对JS代码进行混淆Obfuscation比如变量名替换成_0x1a2b3c字符串被编码控制流被扁平化等。直接阅读犹如天书。通用美化Chrome DevTools 自带的代码格式化{}按钮是第一步它能将压缩成一行的代码展开。专用反混淆工具对于专业混淆器如obfuscator.io, javascript-obfuscator生成的代码可能需要借助de4js、jsnice等在线工具或本地库进行初步的反混淆尝试还原变量名和逻辑结构。但要注意完全自动化还原很难工具更多是辅助核心逻辑的梳理依然依赖人工分析。AST抽象语法树分析对于极其复杂的混淆可以借助Babel、Esprima等库将代码解析成AST然后编写脚本进行针对性的转换如还原字符串解密函数、简化控制流这是高阶逆向技能。注意在实际逆向中务必遵守法律法规和网站的服务条款。本案例仅用于学习加密技术和安全研究思路请勿用于非法爬取、攻击或侵犯他人权益。3. 逆向实战层层剥开加密逻辑的面纱假设我们目标网站的登录接口api/login的请求体是这样的{ username: userexample.com, password: U2FsdGVkX1oHx3k...很长一串密文, timestamp: 1647856123456, sign: a1b2c3d4e5f67890... }显然password被加密了并且多了一个sign签名参数。我们的任务就是找出它们是如何生成的。3.1 定位加密入口从网络请求出发捕获请求打开Network面板清空日志进行登录操作。找到api/login这个POST请求。查看Initiator点击该请求的Initiator列通常会跳转到一个JS文件的某一行类似xhr.send(JSON.stringify(encryptedData))。这里就是加密数据被发送的地方。向上查看调用栈Call Stack找到加密函数被调用的位置。下断点在可疑的加密函数调用处例如var encryptedPwd encrypt(password)下断点。重新触发登录代码会在此处暂停。追踪函数定义在Console中查看encrypt这个函数或者点击跳转到它的定义处。你可能会进入一个被混淆的、变量名毫无意义的函数体。3.2 分析加密函数与密钥生成进入encrypt函数内部我们可能会看到类似这样的混淆代码已简化function _0x12ab34(_0x5cde82) { var _0x1a2b3c _0x2e1f(); var _0x3d4e5f CryptoJS[_0x1a2b3c[0x0]]; var _0x6f7g8h _0x1a2b3c[0x1]; var _0x9i0j1k _0x1a2b3c[0x2]; var _0xkey _0xgenerateKey(_0x6f7g8h, _0x9i0j1k); return _0x3d4e5f[_0x1a2b3c[0x3]](_0x5cde82, _0xkey); }这段代码虽然乱但结构清晰_0x2e1f()返回一个数组包含了算法名、密钥相关参数、模式等字符串。我们需要知道这个函数具体返回什么。CryptoJS[...]表明使用了 CryptoJS 这个前端加密库。[0x0]可能是AES。_0xgenerateKey是一个密钥生成函数它接收两个参数可能是盐Salt和迭代次数。最后调用加密方法encrypt。关键操作动态执行在Console中复制_0x2e1f()这个函数体然后直接调用它查看其返回值。假设返回[AES, mySalt123, 1000, encrypt]。追踪密钥生成同样在Console中尝试调用_0xgenerateKey(mySalt123, 1000)观察其返回的密钥对象。它很可能是一个CryptoJS.lib.WordArray对象。我们需要知道它的生成逻辑是否基于用户密码是否结合了固定字符串是否用到了PBKDF2密钥派生函数确定加密模式查看_0x3d4e5f.encrypt的调用方式。在CryptoJS中AES.encrypt(plaintext, key, { iv: iv, mode: mode, padding: padding })。我们需要找出iv初始化向量、mode如CBC、ECB、padding如PKCS7是什么。它们可能来自_0x1a2b3c数组的其他项或者是全局变量。3.3 还原核心算法逻辑经过一番追踪我们可能得出以下结论模拟案例密钥生成使用CryptoJS.PBKDF2(password, salt, { keySize: 256/32, iterations: 1000 })派生密钥。其中salt是固定字符串“mySalt123”iterations是1000。注意这里的password是用户输入的明文密码用于派生加密密钥但派生出的密钥并不是最终加密密码的密钥这很关键有时网站会用用户密码派生出密钥A再用密钥A去加密一个随机生成的会话密钥B再用B加密数据。这里我们假设是直接用派生密钥加密。加密算法AES-256-CBC填充方式为PKCS7。IV处理IV可能是固定的如全零也可能是随机生成并拼接在密文前CryptoJS默认方式。签名生成sign字段可能是对username encryptedPassword timestamp aSecretKey的字符串进行MD5或SHA256哈希。需要找到aSecretKey和哈希算法。4. Python复现构建与服务端一致的加密引擎理清逻辑后我们在Python中复现。首选pycryptodome库它是PyCrypto的继任者功能强大且维护良好。4.1 环境准备与依赖安装pip install pycryptodome4.2 复现密钥生成PBKDF2在JS中CryptoJS.PBKDF2默认使用SHA1作为哈希函数。在Python中需要对应。from Crypto.Protocol.KDF import PBKDF2 from Crypto.Hash import SHA1 import base64 def generate_key(password, salt, iterations1000, key_length32): 模拟 CryptoJS.PBKDF2 生成 AES-256 密钥。 CryptoJS.PBKDF2 默认使用 SHA1输出一个 WordArray。 key_length32 字节对应 AES-256。 # 注意password 和 salt 需要转换为字节串 password_bytes password.encode(utf-8) salt_bytes salt.encode(utf-8) # 使用 PBKDF2 派生密钥 key PBKDF2(password_bytes, salt_bytes, dkLenkey_length, countiterations, hmac_hash_moduleSHA1) return key # 测试 password “MyPassword123” salt “mySalt123” derived_key generate_key(password, salt) print(“派生密钥Hex:”, derived_key.hex())4.3 复现AES加密CBC模式PKCS7填充CryptoJS的AES.encrypt在CBC模式下默认会随机生成一个IV并将其拼接到密文前面在Base64编码后前16位解码后就是IV。我们需要模拟这一行为。from Crypto.Cipher import AES from Crypto.Util.Padding import pad import os import base64 def encrypt_aes_cbc(plaintext, key, ivNone): 模拟 CryptoJS.AES.encrypt(plaintext, key) 在 CBC 模式下的行为。 如果 iv 为 None则随机生成并拼接到密文前。 plaintext_bytes plaintext.encode(utf-8) # PKCS7 填充 padded_data pad(plaintext_bytes, AES.block_size) if iv is None: # 随机生成 16 字节 IV iv os.urandom(16) cipher AES.new(key, AES.MODE_CBC, iv) ciphertext cipher.encrypt(padded_data) # 将 IV 拼接到密文前CryptoJS 格式 encrypted_bytes iv ciphertext else: # 使用指定的 IV cipher AES.new(key, AES.MODE_CBC, iv) ciphertext cipher.encrypt(padded_data) encrypted_bytes ciphertext # 注意如果指定IV通常不拼接需与JS逻辑一致 # CryptoJS 默认以 Base64 字符串输出 encrypted_b64 base64.b64encode(encrypted_bytes).decode(utf-8) return encrypted_b64 def decrypt_aes_cbc(ciphertext_b64, key): 解密 CryptoJS 生成的密文IV拼接在前的格式。 encrypted_bytes base64.b64decode(ciphertext_b64) # 前16字节是IV iv encrypted_bytes[:16] actual_ciphertext encrypted_bytes[16:] cipher AES.new(key, AES.MODE_CBC, iv) padded_plaintext cipher.decrypt(actual_ciphertext) # 去除 PKCS7 填充 from Crypto.Util.Padding import unpad plaintext_bytes unpad(padded_plaintext, AES.block_size) return plaintext_bytes.decode(utf-8) # 测试加密解密 key derived_key # 使用上面派生的密钥 plaintext “Hello, AES!” encrypted encrypt_aes_cbc(plaintext, key) print(“加密后 (Base64):”, encrypted) decrypted decrypt_aes_cbc(encrypted, key) print(“解密后:”, decrypted) assert plaintext decrypted4.4 整合并模拟完整登录参数生成现在我们将密钥派生和加密整合并加入时间戳和签名模拟完整的请求体构建。import time import hashlib def generate_login_params(username, password): # 1. 生成密钥 salt “mySalt123” iterations 1000 aes_key generate_key(password, salt, iterations, 32) # AES-256 需要32字节密钥 # 2. 加密密码模拟前端使用用户密码派生出的密钥加密密码本身这里需要根据实际JS逻辑调整 # 假设实际JS逻辑是用用户密码派生出密钥然后直接用这个密钥加密密码明文。 encrypted_password_b64 encrypt_aes_cbc(password, aes_key) # 3. 生成时间戳 timestamp str(int(time.time() * 1000)) # JS的Date.now()返回毫秒 # 4. 生成签名假设是 MD5(username encryptedPassword timestamp secret) secret “fixedSecretKeyFromJS” sign_string f”{username}{encrypted_password_b64}{timestamp}{secret}” sign_md5 hashlib.md5(sign_string.encode(utf-8)).hexdigest() # 5. 组装请求参数 params { “username”: username, “password”: encrypted_password_b64, # 注意这里前端可能传的是加密后的密码 “timestamp”: timestamp, “sign”: sign_md5 } return params # 模拟请求 username “userexample.com” password “MyPassword123” login_params generate_login_params(username, password) print(“模拟登录参数:”, login_params)5. 逆向过程中的常见陷阱与调试技巧即使逻辑清晰复现过程中也极易出错。以下是我踩过的一些坑和应对技巧5.1 字符编码与字节转换这是最大的坑没有之一。JavaScript的字符串是UTF-16而CryptoJS内部操作的是WordArray本质上是32位整数数组Python操作的是字节串bytes。现象Python加密结果和JS加密结果Base64后前几位一样后面完全不同。排查确保在密钥派生阶段password和salt在JS和Python中转换成字节序列后的结果完全一致。在JS中CryptoJS.enc.Utf8.parse(password)得到WordArray在Python中password.encode(‘utf-8’)得到bytes。对于ASCII字符两者一致对于中文等非ASCII字符必须确保都使用UTF-8。确保加密的明文一致。同样检查明文到字节的转换。使用Hex 或 Base64 中间态对比。在JS调试中在关键步骤如PBKDF2输出、加密前明文、生成的IV将WordArray通过toString(CryptoJS.enc.Hex)转成16进制字符串输出。在Python中将对应的bytes或key用.hex()输出。逐段对比能精确定位差异点。5.2 加密模式与填充的细微差别AES-256还是AES-128这取决于派生密钥的长度。keySize: 256/32表示派生出的WordArray长度为 32 个字每个字4字节总共 128 字节不对这里容易混淆。在CryptoJS中keySize参数的单位是字4字节。keySize: 256/32表示密钥大小是 256 位即 32 字节。对应到PythondkLen32。CBC模式的IV处理务必确认JS代码中IV是固定的、传参的、还是随机生成并拼接的。我们的示例模拟了最常见的情况。如果JS代码中显式传递了iv参数则Python中必须使用相同的IV且不拼接。Padding对齐PKCS7是标准填充pycryptodome的pad/unpad函数默认就是PKCS7。确保和JS端一致。5.3 签名算法的秘密签名sign通常用于防止篡改。除了MD5也可能是SHA256、HMAC-SHA256等。查找签名密钥这个密钥secret可能硬编码在JS里全局变量也可能通过某个接口动态获取需要额外请求。在JS中搜索sign、secret、key等关键词或在生成签名的函数附近下断点查看拼接字符串的组成部分。注意大小写和顺序签名拼接字符串的顺序、键名的大小写必须与前端完全一致。有时甚至会在拼接后进行一次URL编码。5.4 对抗反调试与代码动态加载一些网站会部署反调试技巧比如在控制台打开时延迟执行、检测debugger关键字、代码动态混淆加载。禁用断点可以右键行号选择“Never pause here”来绕过debugger语句。重写检测函数在Console提前重写如console.log、setTimeout或某些检测函数使其失效。请求拦截与修改使用Fiddler、Charles等代理工具或浏览器的“Override”功能将线上混淆的JS文件替换成本地已美化/修改的版本便于静态分析。6. 从逆向到封装构建健壮的加密模块当所有逻辑都调试通过后不应该把代码散落在脚本里。一个好的实践是将其封装成一个模块或类提高复用性和可维护性。# website_crypto.py import time import hashlib import base64 import os from Crypto.Protocol.KDF import PBKDF2 from Crypto.Hash import SHA1 from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad class WebsiteCrypto: def __init__(self, pbkdf2_salt“mySalt123”, pbkdf2_iterations1000, aes_key_size32, sign_secret“fixedSecretKeyFromJS”): self.pbkdf2_salt pbkdf2_salt self.pbkdf2_iterations pbkdf2_iterations self.aes_key_size aes_key_size # bytes self.sign_secret sign_secret def derive_key(self, password): 派生 AES 密钥 key PBKDF2( password.encode(‘utf-8’), self.pbkdf2_salt.encode(‘utf-8’), dkLenself.aes_key_size, countself.pbkdf2_iterations, hmac_hash_moduleSHA1 ) return key def encrypt_password(self, plain_password): 加密密码返回Base64字符串 key self.derive_key(plain_password) # 注意这里模拟的逻辑是用密码派生的密钥加密密码本身。 # 实际逻辑可能不同这里是核心需根据JS调整。 iv os.urandom(16) cipher AES.new(key, AES.MODE_CBC, iv) padded_data pad(plain_password.encode(‘utf-8’), AES.block_size) ciphertext cipher.encrypt(padded_data) encrypted_bytes iv ciphertext return base64.b64encode(encrypted_bytes).decode(‘utf-8’) def generate_signature(self, username, encrypted_password_b64, timestamp): 生成请求签名 sign_string f”{username}{encrypted_password_b64}{timestamp}{self.sign_secret}” return hashlib.md5(sign_string.encode(‘utf-8’)).hexdigest() def build_login_payload(self, username, password): 构建完整的登录请求参数 encrypted_pwd self.encrypt_password(password) timestamp str(int(time.time() * 1000)) sign self.generate_signature(username, encrypted_pwd, timestamp) return { “username”: username, “password”: encrypted_pwd, “timestamp”: timestamp, “sign”: sign } # 使用示例 if __name__ “__main__”: crypto WebsiteCrypto() payload crypto.build_login_payload(“userexample.com”, “MyPassword123”) print(payload) # 可以将payload用于requests.post封装好后在业务代码中只需几行即可完成加密参数的准备逻辑清晰且易于后续维护。逆向工程不仅是破解更是理解。通过这样一次完整的实战你不仅学会了如何复现一个加密流程更重要的是掌握了分析复杂前端逻辑的方法论。这套方法在面对不同的混淆和加密方案时具有很强的通用性。记住耐心和细致的观察是逆向工程中最宝贵的品质。每一次成功的逆向都是对系统设计理解的一次深化。

相关新闻