1. 项目概述:从一次登录请求开始的逆向之旅
最近在分析一个企业级应用时,遇到了一个典型的场景:需要模拟登录流程,但提交的密码是经过前端加密处理的。目标系统是致远OA,一个广泛使用的协同办公平台。当你打开登录页面,输入密码点击登录,浏览器会向服务器发送一个POST请求。如果你用开发者工具查看这个请求的载荷,会发现密码字段并不是你输入的明文,而是一长串看似无规律的字符。这就是前端JavaScript在提交前对密码进行了加密处理。我们的目标,就是逆向分析出这个加密算法,从而能够用脚本模拟登录。这不仅是爬虫或自动化测试中的常见需求,更是理解现代Web应用安全机制的一个绝佳切入点。今天,我就以“致远OA”的前端密码加密为例,带你走一遍完整的JS逆向分析流程,从定位加密代码到还原算法,再到Python复现,分享其中踩过的坑和总结的技巧。
2. 逆向环境准备与初步抓包分析
2.1 工具链选择与配置
工欲善其事,必先利其器。对于JS逆向,一套顺手的工具能极大提升效率。我的核心工具组合是Chrome DevTools + Node.js环境。
首先,确保你的Chrome浏览器是最新版本,开发者工具功能最全。打开目标登录页面(例如http://oa.example.com/seeyon/),直接按F12。这里有个关键设置:在开发者工具的设置(右上角齿轮图标)中,找到“Preferences”下的“Sources”,确保“Enable JavaScript source maps”是勾选的。这能让我们在调试混淆代码时,有机会看到更友好的映射信息,虽然对于强混淆的代码可能帮助有限,但养成好习惯很重要。
其次,我强烈建议在本地安装Node.js。不是为了运行目标网站的JS,而是为了搭建一个干净、可控的测试环境。我们可以把关键的、疑似加密函数的JS代码片段抠出来,在Node里运行和调试,这比在浏览器控制台里反复刷新页面测试要高效得多。你可以使用npm init -y初始化一个项目,然后安装一个辅助模块crypto-js,有时目标站点可能使用了这个库,我们可以用它来做算法验证对比。
2.2 网络请求抓包与关键定位
一切就绪后,在登录页面随意输入账号(如test)和密码(如123456),先不要点击登录。打开开发者工具的Network(网络)面板,勾选上“Preserve log”(保留日志),防止页面跳转后请求记录被清空。然后点击登录按钮。
瞬间,你会看到Network面板里刷出一系列请求。我们的目标是找到那个提交登录信息的请求。通常,它可能叫login.do、login.jsp、ajaxLogin或者就是一个单纯的/seeyon/main.do。请求方法一般是POST。点击这个请求,查看它的Headers和Payload。
在Headers里,注意Content-Type,通常是application/x-www-form-urlencoded或application/json。这决定了数据格式。
重点看Payload(如果是表单数据,则查看Form Data标签页)。你会看到类似这样的结构:
login_username: test login_password: 7C4A8D09CA3762AF61E59520943DC26494F8941B或者是一个JSON:
{ "username": "test", "password": "aBcDeFgHiJkL...(很长一串)" }这里的login_password或password对应的值,就是加密后的密文。记下你输入的明文密码(如123456)和这个密文,这是后续验证算法是否正确的最直接证据。
另一个容易被忽略但极其重要的细节是Initiator(发起者)列。它显示了是哪个JS文件发起了这个网络请求。点击它旁边的链接,可以直接跳转到Sources(源代码)面板对应的JS代码行。这往往是定位加密函数的“快速通道”。如果这里显示的是一个很通用的JS文件(如jquery.min.js),那说明加密逻辑可能在别处,但至少它告诉了我们请求触发的准确位置。
3. 加密代码定位与关键函数追踪
3.1 全局搜索与断点调试
如果通过“Initiator”没有直接找到加密点,我们就得用更通用的方法:关键词搜索。在开发者工具的Sources面板,按Ctrl+Shift+F(Windows)或Cmd+Opt+F(Mac)打开全局搜索。
搜索什么关键词呢?这需要一些经验。首先,尝试搜索密文中的片段。比如你得到的密文是7C4A8D09CA3762AF61E59520943DC26494F8941B,可以取前6-8个字符7C4A8D09去搜索,看是否有JS代码直接包含这个字符串(可能是硬编码的盐值或测试用例)。不过这种方式成功率不高。
更有效的是搜索与密码字段相关的参数名。在Payload里,密码的参数名是login_password,那么就在所有JS文件里搜索login_password。你会找到所有对这个参数进行赋值或操作的地方。通常,加密发生在表单提交(submit)事件处理函数里,或者是在某个按钮的onclick事件里,也可能是在像$.ajax的beforeSend函数或data序列化过程中。
找到可疑的代码行后,毫不犹豫地打上断点。在行号左侧点击即可添加一个蓝色的断点标记。然后回到页面,再次点击登录。执行流会立即暂停在断点处。
3.2 调用栈分析与变量监控
当代码在断点处暂停时,不要只看当前一行。右侧的Call Stack(调用栈)面板是宝藏。它显示了当前函数是被谁调用的,层层回溯,可以帮你理清整个加密的执行路径。你可以点击调用栈中的上一级函数,查看当时的上下文和变量状态。
同时,在Scope(作用域)面板,你可以看到当前作用域下的所有变量(Local, Closure, Global等)。找到那个存储着明文密码的变量(可能叫password、pwd、inputPwd等)。然后逐步执行(F10是Step Over,F11是Step Into进入函数)。
我们的目标是找到那个将明文密码转换成密文的函数。当你执行到类似encryptedPwd = someFunction(plainPwd);或data.password = encrypt(data.password);这样的语句时,就接近核心了。用F11键“步入”这个someFunction或encrypt函数内部。
3.3 致远OA的典型加密定位
根据我对多个致远OA版本的分析,其前端密码加密常见于一个名为login.js或包含rsa、encrypt关键字的JS文件中。加密函数可能被命名为encryptPassword、RSAEncrypt或者直接内联在提交逻辑里。
一个典型的模式是:代码会引入一个RSA公钥(通常是一个很长的16进制字符串或Base64编码的字符串),然后使用这个公钥对密码进行加密。你可能会看到类似new JSEncrypt()、setPublicKey(key)、encrypt(str)这样的调用。这就是使用了常见的jsencrypt库进行RSA非对称加密。
另一种可能是使用自定义的哈希算法,比如看到CryptoJS.MD5、CryptoJS.SHA1或者hex_md5这样的函数调用。这时就需要跟进这些函数的具体实现了。
注意:有些站点会对核心的加密JS代码进行混淆,函数和变量名都变成了
a、b、c、_0x123abc这种形式。这增加了阅读难度,但基本逻辑不会变。我们的策略是:不追求读懂每一行混淆后的代码,而是通过调试,观察输入(明文)和输出(密文)的对应关系,并尝试在Node环境中复现这个转换过程。
4. 加密算法分析与Python复现
4.1 算法识别与参数提取
假设我们通过调试,确定了加密函数入口。比如,我们跟进去发现核心代码是:
function encryptPwd(pwd) { var key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...(很长一串)"; var encryptor = new JSEncrypt(); encryptor.setPublicKey(key); return encryptor.encrypt(pwd); }这很明显是RSA加密。我们需要提取两个关键信息:1. 公钥key;2. 使用的RSA填充方案(Padding)。JSEncrypt库默认使用的是RSA-OAEP填充(具体是RSAES-OAEPwithSHA-1MGF1)。这在后续Python复现时必须保持一致。
如果看到的是哈希,比如:
function encryptPwd(pwd) { return hex_md5(pwd + 'a1b2c3d4'); }那么算法是MD5,并且使用了盐值(Salt)'a1b2c3d4',是明文拼接后取哈希。
4.2 Node.js环境验证算法
在将算法移植到Python前,最好先在Node.js环境验证我们的理解是否正确。在开发者工具的Console(控制台)中,或者将抠出的相关函数代码(包括依赖的jsencrypt.min.js或md5.js)复制到一个本地JS文件中,用Node运行。
例如,对于RSA情况,创建一个test.js:
// 假设我们抠出了JSEncrypt库的源码,保存为jsencrypt.js const JSEncrypt = require('./jsencrypt.js').JSEncrypt; // 或者使用npm安装的jsencrypt库 const publicKey = `MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...`; function encrypt(password) { const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); const encrypted = encryptor.encrypt(password); console.log(`明文: ${password}`); console.log(`密文: ${encrypted}`); return encrypted; } // 测试 encrypt('123456');运行node test.js,看输出的密文是否和抓包抓到的一致。如果一致,恭喜你,算法完全正确。如果不一致,检查公钥是否正确、是否还有其他的预处理(比如密码是否先被转成了UTF-8字节?是否加了时间戳?)。
4.3 使用Python进行算法复现
验证成功后,就可以用Python来实现了。Python拥有强大的密码学库cryptography和pycryptodome。
情况一:RSA加密复现如果目标使用RSA,我们需要用Python模拟JSEncrypt的加密过程。JSEncrypt默认使用PKCS#1 OAEP填充方案。
from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP import base64 def rsa_encrypt_password(password, public_key_pem): """ 使用RSA公钥加密密码,模拟JSEncrypt行为。 :param password: 明文密码字符串 :param public_key_pem: PEM格式的公钥字符串 :return: Base64编码的加密结果 """ # 加载公钥 key = RSA.import_key(public_key_pem) # 创建加密器,使用默认的SHA-1哈希算法(与JSEncrypt默认一致) cipher = PKCS1_OAEP.new(key) # 加密。密码需要编码为bytes encrypted_bytes = cipher.encrypt(password.encode('utf-8')) # JSEncrypt默认输出Base64 encrypted_b64 = base64.b64encode(encrypted_bytes).decode('utf-8') return encrypted_b64 # 使用抓取到的公钥(注意是PEM格式,通常以-----BEGIN PUBLIC KEY-----开头) public_key = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... -----END PUBLIC KEY-----""" plain_pwd = "123456" cipher_text = rsa_encrypt_password(plain_pwd, public_key) print(f"加密结果: {cipher_text}")实操心得:有时从网页JS中提取的公钥可能是去掉头尾和换行的“裸”Base64字符串。你需要手动为其加上
-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----头尾,并确保格式正确(每64字符换行)。这是PythonRSA.import_key方法所要求的PEM格式。
情况二:MD5带盐哈希复现如果算法是MD5加盐,那就简单多了。
import hashlib def md5_salt_encrypt(password, salt): """ MD5(密码+盐值) 的Hex输出。 """ # 将密码和盐值拼接 s = password + salt # 创建MD5对象,更新数据 m = hashlib.md5() m.update(s.encode('utf-8')) # 返回十六进制字符串 return m.hexdigest() plain_pwd = "123456" salt = "a1b2c3d4" # 从JS代码中提取的盐值 cipher_text = md5_salt_encrypt(plain_pwd, salt) print(f"MD5加盐结果: {cipher_text}")确保这里的cipher_text与抓包数据一致。
5. 逆向过程中的常见陷阱与解决方案
5.1 动态密钥与时间戳混淆
有些系统的加密并非静态。你可能会发现公钥或者盐值不是硬编码在JS里的,而是通过一个额外的Ajax请求从服务器获取的。或者,加密前的字符串拼接了一个动态生成的时间戳或随机数。
应对策略:在Network面板仔细查找登录请求之前发生的其他请求。可能有一个获取密钥或初始化参数的请求。你需要用脚本先模拟这个请求,拿到动态的密钥或盐值,然后再进行加密计算。这相当于将“登录”这个动作,拆解成了“获取加密参数”和“提交加密凭证”两个步骤。
5.2 代码混淆与反调试手段
现代网站会采用各种手段增加逆向难度。
- 变量名混淆:将
encryptPassword变成_0x12ab3c。这并不影响执行逻辑,但让代码难以阅读。调试时,不要纠结于变量名,而是关注输入流和输出流。在关键函数入口和出口设置断点,观察参数和返回值。 - 控制流扁平化:将简单的
if-else或switch语句打散成用一个大switch调度执行的代码块,破坏可读性。对付这个,依然是调试。跟着断点一步步走,记录下真实的执行路径。 - 反调试:有些代码会检测开发者工具是否打开,如果打开则跳转到错误页面或进入死循环。常见手段有检查
console.log的引用、检测代码执行时间差等。- 解决方案:可以尝试使用“无头浏览器”如Puppeteer或Playwright进行自动化,它们通常不受前端反调试影响。或者在Chrome中,可以尝试在开发者工具打开的状态下,先刷新页面,再快速设置断点(条件断点有时能绕过)。也有浏览器插件可以禁用反调试。
5.3 编码与填充细节差异
这是导致Python复现结果与JS不一致的最常见原因。
- 字符串编码:JS中字符串是UTF-16吗?加密前是否调用了
unescape(encodeURIComponent(str))这类函数进行UTF-8转换?在Python中,我们通常直接str.encode('utf-8'),但必须和JS端保持一致。 - Base64编码:JS的
btoa函数对非Latin1字符处理有问题,所以常用Base64.encode或自己实现的函数。Python的base64.b64encode输出是bytes,需要.decode('ascii')才是字符串。注意是否有URL安全的Base64(将+/替换为-_)。 - RSA填充:除了PKCS1_OAEP,还有PKCS1_v1_5。必须确认JS库使用的是哪一种。
JSEncrypt默认是OAEP。
排查技巧:在JS加密函数的入口和出口,分别用console.log打印出中间变量的类型和值(比如,加密前的字节数组、加密后的字节数组、Base64编码前的字节数组)。然后在Python中,在对应步骤也打印出来,进行逐字节比对。这是最笨但最有效的方法。
6. 构建健壮的自动化登录脚本
6.1 脚本结构设计
当我们成功逆向出加密算法后,目标就是写一个稳定的脚本。脚本不应该只是硬编码加密函数,而应该具备一定的健壮性。
一个健壮的登录脚本结构如下:
- 会话维持:使用
requests.Session()对象,它会自动处理Cookies,模拟浏览器会话。 - 参数获取:如果加密需要动态密钥,先写一个函数
get_encryption_params()来获取。 - 加密模块:将验证通过的加密算法封装成一个独立的函数或类,如
PasswordEncryptor。 - 登录执行:构造请求头(特别是User-Agent、Content-Type),发送POST请求。
- 状态验证:检查返回的响应。成功登录后,服务器通常会设置一个会话Cookie(如
JSESSIONID),并可能跳转或返回特定的JSON状态码。后续的请求都需要携带这个Cookie。
6.2 请求头模拟与错误处理
网站可能会检查请求头。至少需要模拟以下头部:
headers = { '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', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', # 根据实际情况调整 'Referer': 'http://oa.example.com/seeyon/index.jsp', # 登录页地址 'Origin': 'http://oa.example.com', # 协议+域名 'X-Requested-With': 'XMLHttpRequest' # 如果是Ajax请求 }错误处理至关重要。网络请求可能超时、服务器可能返回4xx/5xx错误、登录可能因密码错误/账号锁定而失败。使用try...except包裹请求代码,并对响应状态码和内容进行判断。
import requests from requests.exceptions import Timeout, ConnectionError session = requests.Session() login_url = "http://oa.example.com/seeyon/login.do" try: # 1. 获取动态参数(如果需要) # params = get_dynamic_params(session) # 2. 加密密码 encrypted_pwd = encrypt_password('your_password', public_key) # 或 params['key'] # 3. 构造数据 data = { 'login_username': 'your_username', 'login_password': encrypted_pwd, # 可能还有其他隐藏字段,如CSRF token # 'lt': params['lt'], # 'execution': params['execution'] } # 4. 发送请求 resp = session.post(login_url, data=data, headers=headers, timeout=10) resp.raise_for_status() # 如果状态码不是200,抛出HTTPError # 5. 判断登录结果 if "登录成功" in resp.text or "main.jsp" in resp.url: print("登录成功!") # 保存session,用于后续请求 # 例如:session.get('http://oa.example.com/seeyon/main.do') else: print("登录失败,响应内容:", resp.text[:500]) # 打印前500字符用于调试 except Timeout: print("请求超时") except ConnectionError: print("网络连接错误") except Exception as e: print(f"发生未知错误: {e}")6.3 应对登录验证码与风控
一些系统在多次失败登录或检测到异常行为(如高频请求、陌生IP)后,会触发验证码(图片、滑块、点选等)或更强的风控。
- 验证码:如果是简单图片验证码,可以考虑使用OCR库(如
ddddocr、tesseract,但识别率需测试)进行识别。对于复杂验证码,可能需要接入打码平台。更“友好”的做法是,在脚本中识别到验证码后,暂停并提示用户手动输入。 - 风控:避免高频请求,在请求间加入随机延时(如
time.sleep(random.uniform(1, 3)))。使用高质量的代理IP池来切换IP地址。模拟更真实的浏览器指纹(通过selenium或playwright这类自动化浏览器工具可以更好地做到这一点,但资源消耗更大)。
7. 进阶:算法通用化与经验抽象
完成一个案例后,我们应该尝试将经验抽象成通用方法,这样下次遇到类似问题就能更快上手。
7.1 建立JS逆向分析 checklist
每次分析,都可以按以下清单进行:
- 抓包定位:找到登录请求,确认密码被加密。
- 搜索关键词:搜索密码参数名、
encrypt、password、RSA、MD5、SHA等。 - 断点调试:在可疑函数设断点,跟踪明文到密文的转换过程。
- 定位核心函数:找到执行加密的最终函数。
- 提取关键参数:提取公钥、盐值、模式、IV(对于AES)等。
- 算法识别:判断是标准算法(RSA、AES、MD5、SHA)还是自定义算法。
- 环境验证:在Node.js或浏览器控制台复现加密过程。
- Python移植:使用对应密码学库复现算法。
- 集成测试:将加密函数嵌入到登录脚本中进行端到端测试。
7.2 常见加密模式速查与应对
- RSA(非对称):特征是有公钥。使用
JSEncrypt或node-rsa库。Python对应cryptography或pycryptodome的PKCS1_OAEP/PKCS1_v1_5。 - AES(对称):特征是有密钥(Key)和可能有的初始化向量(IV)。JS常用
CryptoJS.AES.encrypt。Python使用Crypto.Cipher.AES,注意模式(CBC、ECB等)和填充(PKCS7)。 - 哈希(MD5、SHA1、SHA256):特征是不可逆,输出固定长度。JS可能用
CryptoJS.MD5或自己实现的函数。Python用hashlib。 - 自定义算法:可能是几种算法的组合(如先MD5,再Base64,再反转字符串)。只能通过调试,一步步记录转换过程,然后用Python忠实还原每一步。
7.3 保持学习与工具更新
前端安全技术在不断演进,新的混淆技术(如WebAssembly用于加密)、新的反调试手段层出不穷。保持对以下方面的关注:
- 新工具:如
AST(抽象语法树)反混淆工具(虽然可能被滥用,但了解其原理有助于理解混淆)、浏览器自动化框架(Puppeteer, Playwright)。 - 标准协议:深入了解HTTPS、WebSocket、JWT等,有时加密信息会藏在协议头或Token里。
- 社区:关注安全社区和逆向爱好者的分享,了解最新的对抗案例和解决方案。
逆向分析就像解谜,需要耐心、细致的观察和逻辑推理。每一次成功的逆向,不仅解决了一个具体的技术问题,更深化了对Web应用前后端交互、数据安全传输的理解。记住,技术的价值在于应用,在合法合规的前提下,利用这些技能去提升工作效率、进行安全测试或学习研究,才是正道。