1. 项目概述:为什么我们需要Hook加密算法?
在Web安全、数据分析和逆向工程领域,我们经常会遇到一个棘手的问题:目标网站或应用的核心业务逻辑被一层或多层JavaScript加密算法严密保护。无论是登录时的密码加密、请求参数的签名,还是关键数据的加解密传输,这些算法就像一个个黑盒,阻碍了我们理解其内部逻辑、进行安全审计或实现自动化交互。传统的“人肉”逆向,即通过阅读混淆后的代码、手动跟踪执行堆栈来推断算法,不仅效率低下,而且面对日益复杂的代码混淆和动态加载技术时,常常力不从心。
这时,“Hook”技术便成为了一把利器。简单来说,Hook(钩子)就是在程序运行时,拦截并修改其正常的函数调用流程。在JS逆向的语境下,Hook加密算法意味着我们能够在JavaScript代码执行到关键的加密函数(如CryptoJS.AES.encrypt、window.btoa、自定义的sign函数等)时,自动“捕获”这次调用,并记录下其输入参数、输出结果,甚至修改其行为。这相当于在算法的“咽喉”位置安装了一个实时监控和调试器。
我之所以投入精力研究并实现“一键Hook主流加密算法”,是因为在实际项目中,重复性的手动定位和Hook操作消耗了大量时间。每次遇到新的目标,都需要重新分析代码结构、寻找函数入口、编写调试脚本。这个项目的核心目标,就是将这些重复劳动自动化、标准化,打造一个能够快速适配常见加密库和自定义加密函数,并能提供清晰调试信息的工具集。无论你是安全研究员、爬虫工程师,还是前端开发者想深入了解第三方库的行为,这套方法都能让你事半功倍。
2. 核心思路与方案选型:如何设计一个通用的Hook框架?
要实现“一键Hook”,我们不能针对每个网站写死代码,而是需要设计一个具备良好扩展性和适应性的框架。我的设计思路主要围绕以下几个核心原则展开:
2.1 核心原则:无侵入、动态性与可配置
首先,Hook必须是“无侵入”的。我们不能修改目标网站的源代码文件,所有操作都应在运行时通过浏览器开发者工具(如Chrome DevTools)或注入的脚本完成。这保证了我们分析过程的纯净性和可重复性。
其次,需要“动态性”。网站可能使用eval、Function构造函数或动态加载脚本的方式引入加密逻辑。我们的Hook机制必须能够应对这些情况,确保在目标函数被定义或执行的瞬间就能将其捕获。
最后,强调“可配置性”。不同的加密场景差异很大,可能是标准的Web Crypto API,也可能是第三方库如CryptoJS、forge,或是开发者自己写的匿名函数。我们的框架需要允许用户通过简单的配置(比如函数名、对象路径、特征字符串)来指定需要Hook的目标。
2.2 技术方案选型:基于对象属性劫持的Hook
在JavaScript中,实现Hook主要有几种方式:
- 重写函数:直接给
window.crypto.subtle.encrypt赋值一个新的函数。这是最简单粗暴的方法,但容易被检测(例如检查函数的toString结果是否被改变)。 - 使用
Object.defineProperty:重新定义对象的属性描述符,在getter中返回我们包装过的函数。这种方式更隐蔽,可以捕获通过属性访问触发的调用。 - 代理(Proxy)对象:ES6引入的
Proxy可以创建一个对象的代理,从而拦截并定义该对象的基本操作(如属性读取、函数调用)。这是功能最强大、最现代的方式。
为了兼顾兼容性(部分老旧环境可能不支持完整的Proxy)和功能的强大,我选择了组合方案:对于全局对象(如window)或已知的命名空间对象(如CryptoJS),优先使用Proxy进行深度拦截;对于具体的函数属性,使用Object.defineProperty进行精准Hook。同时,我们会保存原始函数的引用,确保在必要时可以调用原函数,避免破坏网站的正常功能。
2.3 调试信息输出策略
Hook的目的不仅是拦截,更是为了调试。我们需要清晰地输出信息。方案是构建一个独立的日志管理器,它能够:
- 格式化输出:将函数名、调用时间、传入的参数(进行安全的序列化,避免循环引用)、返回值、所在的调用栈(Stack Trace)等信息以结构化的方式(如表格)输出到控制台。
- 条件过滤:可以设置过滤条件,例如只记录包含特定参数的调用,或者当参数满足某个条件时才记录,避免控制台信息爆炸。
- 持久化与导出:允许将拦截到的所有调用记录导出为JSON文件,便于后续离线分析。
基于以上思路,整个项目的架构就清晰了:一个核心的Hook引擎,负责实施拦截;一个可配置的目标函数列表;一个强大的日志输出系统。
3. 核心实现:一步步构建Hook引擎
理论说再多不如一行代码。下面我将分步骤拆解核心实现。我们将创建一个名为UniversalCryptoHook的类。
3.1 基础Hook:使用Object.defineProperty拦截函数
这是最基础的单元操作。假设我们要Hookwindow.btoa这个Base64编码函数。
class UniversalCryptoHook { constructor() { this.hookedFunctions = new Map(); // 存储原始函数引用 } /** * 基础函数Hook方法 * @param {Object} obj - 包含目标函数的对象,如 window, CryptoJS * @param {string} funcName - 函数名 * @param {Function} callback - 拦截回调函数,接收(originalFunc, args, context)参数 */ hookFunction(obj, funcName, callback) { if (!obj || !obj[funcName] || typeof obj[funcName] !== 'function') { console.warn(`[Hook] 目标 ${funcName} 不存在或不是函数`); return false; } const originalFunc = obj[funcName]; this.hookedFunctions.set(`${obj}.${funcName}`, originalFunc); Object.defineProperty(obj, funcName, { configurable: true, enumerable: true, writable: true, // 注意:某些严格环境可能需要设为false,用getter/setter更稳妥 value: function(...args) { // 调用回调,并传入原始函数、参数和调用上下文(this) const result = callback.call(this, originalFunc, args, this); // 回调函数可以返回一个值作为“伪造”的结果,如果未返回,则调用原函数 if (result !== undefined) { return result; } return originalFunc.apply(this, args); } }); console.log(`[Hook] 成功Hook函数: ${funcName}`); return true; } }注意:直接重写
value在某些网站可能失效,因为目标代码可能在之后重新赋值该属性。更稳健的做法是使用getter和setter来拦截属性的读取和赋值操作。
3.2 高级Hook:使用Proxy拦截整个对象
对于像CryptoJS.AES这样的对象,里面有很多方法(encrypt,decrypt),一个个Hook太麻烦。我们可以用Proxy拦截整个对象。
/** * 使用Proxy深度Hook一个对象及其所有方法 * @param {Object} target - 目标对象 * @param {string} namespace - 对象命名空间,用于日志记录 * @returns {Proxy} 被代理后的对象 */ createDeepProxy(target, namespace = '') { const hook = this; return new Proxy(target, { get(obj, prop) { const value = obj[prop]; // 如果属性是函数,则返回一个包装函数 if (typeof value === 'function') { return function(...args) { const fullName = namespace ? `${namespace}.${prop}` : prop; console.group(`[Hook] 调用: ${fullName}`); console.table({ 参数: args.map(a => (typeof a === 'string' ? a.substring(0, 100) + '...' : a)), 上下文: this }); try { const result = value.apply(this, args); console.log('返回结果:', result); console.groupEnd(); return result; } catch (err) { console.error('调用出错:', err); console.groupEnd(); throw err; } }; } // 如果属性是对象,则递归创建Proxy if (value && typeof value === 'object') { return hook.createDeepProxy(value, namespace ? `${namespace}.${prop}` : prop); } // 其他类型属性直接返回 return value; }, set(obj, prop, newValue) { // 可以在这里拦截赋值操作,记录谁修改了这个属性 console.log(`[Hook] 设置属性 ${namespace}.${prop}`, newValue); obj[prop] = newValue; return true; // 表示设置成功 } }); }使用方式:window.CryptoJS = this.createDeepProxy(window.CryptoJS, 'CryptoJS');这样,CryptoJS及其所有子对象下的函数调用都会被自动记录。
3.3 自动识别与批量Hook
“一键”的关键在于自动识别。我们可以预设一个常见加密库和函数的清单。
const COMMON_CRYPTO_TARGETS = [ { obj: window, path: 'btoa' }, { obj: window, path: 'atob' }, { obj: window, path: 'crypto.subtle.encrypt' }, { obj: window, path: 'crypto.subtle.decrypt' }, { obj: window, path: 'CryptoJS' }, // 整个库用Proxy处理 { obj: window, path: 'JSEncrypt' }, // RSA库 { obj: window, path: 'md5' }, // 假设有全局md5函数 // 可以添加更多,如 forge, sjcl 等 ]; class UniversalCryptoHook { autoHookCommon() { COMMON_CRYPTO_TARGETS.forEach(target => { const { obj, path } = target; const pathSegments = path.split('.'); let currentObj = obj; let propName = pathSegments.pop(); // 最后一个属性名是函数或对象名 // 遍历路径,找到目标对象的父对象 for (const seg of pathSegments) { if (currentObj && currentObj[seg]) { currentObj = currentObj[seg]; } else { console.warn(`[Hook] 路径 ${path} 不存在,跳过`); return; } } if (propName && currentObj) { // 如果是明确指向一个函数名,用hookFunction // 如果是库名(如CryptoJS),用createDeepProxy if (typeof currentObj[propName] === 'function') { this.hookFunction(currentObj, propName, this.defaultCallback); } else if (currentObj[propName] && typeof currentObj[propName] === 'object') { // 替换为Proxy对象 currentObj[propName] = this.createDeepProxy(currentObj[propName], propName); } } }); console.log('[Hook] 常见加密目标自动Hook完成'); } defaultCallback(originalFunc, args, context) { // 默认回调:只记录,不修改行为 const funcName = originalFunc.name || 'anonymous'; console.log(`[Hook Call] ${funcName} 被调用`, { 参数: args, 上下文: context, 原始函数: originalFunc }); // 返回 undefined,让原函数继续执行 return undefined; } }现在,只需要const hook = new UniversalCryptoHook(); hook.autoHookCommon();就能一键Hook所有预设的常见加密函数。
4. 实战调试技巧与问题排查
工具做好了,怎么用才是关键。下面分享几个实战中总结的调试技巧和常见问题的解决方法。
4.1 如何定位需要Hook的加密函数?
“一键Hook”的前提是知道要Hook什么。如果目标函数不在预设清单里,你需要手动定位。
- 搜索关键词:在开发者工具的Sources面板,全局搜索(Ctrl+Shift+F)诸如
encrypt、decrypt、sign、hash、AES、RSA、CryptoJS、MD5等关键词。 - XHR/Fetch断点:在Network面板找到被加密的请求,右键选择“Break on” -> “XHR/Fetch”。当请求发起时,代码会暂停,此时查看调用堆栈(Call Stack),寻找堆栈中与加密相关的函数。
- 堆栈跟踪法:在Console中执行
debugger;语句,或者直接在疑似加密函数的第一行打上断点,然后触发加密操作。观察调用堆栈,向上追溯,找到加密的源头函数。
4.2 Hook过程中的常见问题与解决方案
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| Hook后网站功能异常或报错 | 1. Hook函数改变了this上下文。2. 原始函数被多次赋值,我们的Hook被覆盖。 3. Proxy或defineProperty的副作用影响了对象内部状态。 | 1. 在包装函数中使用originalFunc.apply(this, args)确保this正确。2. 使用 MutationObserver监听DOM脚本加载,或设置setInterval循环检查并重新Hook(防覆盖)。3. 简化Hook逻辑,只做记录,避免修改对象原型链或属性描述符。 |
| 控制台输出信息过多,找不到关键调用 | 目标函数被高频调用(如哈希函数在循环中)。 | 在回调函数中添加过滤条件,例如只有当参数包含特定字符串、参数长度大于某个值、或来自特定的调用栈时才输出日志。 |
无法Hook通过eval或new Function动态生成的函数 | 这些函数在动态代码执行时才创建,常规的Hook时机过早。 | 重写window.eval和Function构造函数,在代码执行前对其进行字符串层面的匹配和替换,注入我们的Hook代码。这是一个更高级的技巧,需谨慎使用。 |
| 网站检测到Hook并反调试 | 网站会检查关键函数是否被重写(如Function.prototype.toString的结果)、是否存在调试器(检查debugger语句执行时间)。 | 1. 使用更隐蔽的Hook方式,如通过Object.defineProperty的getter拦截,保持函数的toString输出不变。2. 在非阻塞模式下运行调试器,或使用 setTimeout延迟debugger语句来绕过时间检测。3. 考虑在无头浏览器(如Puppeteer)环境中运行Hook脚本,避免被前端检测。 |
4.3 实操心得:让Hook更稳健
- 保存原始引用:一定要把原始函数保存起来,这是“金科玉律”。不仅是为了能调用原功能,也是为了在需要时能恢复现场。
- 异步函数处理:如果要Hook的函数返回一个Promise(如
crypto.subtle.encrypt),你的包装函数也需要正确处理异步,最好也返回一个Promise,并在其中记录结果。value: async function(...args) { console.log(`[Hook] 异步调用开始: ${funcName}`); try { const result = await originalFunc.apply(this, args); console.log(`[Hook] 异步调用成功: ${funcName}`, result); return result; } catch (err) { console.error(`[Hook] 异步调用失败: ${funcName}`, err); throw err; } } - 性能考量:在生产环境或分析大型应用时,过于复杂的Proxy和日志输出可能影响性能。可以提供一个“采样”模式,随机记录一部分调用,或者只在特定条件下开启详细日志。
- 代码注入时机:确保你的Hook脚本在目标加密库加载之后,但在加密函数被调用之前执行。最稳妥的方式是将脚本作为浏览器插件的content script,设置为在
document_idle时运行,或者通过调试工具手动在页面加载后注入。
5. 案例实战:逆向一个模拟登录的加密参数
假设我们遇到一个登录页面,其密码在提交前被一个名为window.encryptPassword的函数处理。我们的目标是弄清这个函数的算法。
5.1 手动定位与初步分析
首先,在开发者工具中搜索encryptPassword,找到其定义。假设它看起来像这样(已被混淆):
function encryptPassword(t) { var e = "a1b2c3d4"; return CryptoJS.MD5(e + t).toString(); }很明显,它只是做了一个简单的MD5哈希,盐值是a1b2c3d4。但对于复杂的、调用了内部其他模块的函数,我们就需要Hook来动态观察。
5.2 使用我们的工具进行Hook
即使我们看到了源码,用Hook验证也是好习惯。我们在Console中执行:
const hook = new UniversalCryptoHook(); hook.hookFunction(window, 'encryptPassword', (originalFunc, args, context) => { console.groupCollapsed(`🔐 encryptPassword 被调用`); console.log('输入密码 (明文):', args[0]); const result = originalFunc.apply(context, args); // 调用原函数得到结果 console.log('输出密文:', result); console.groupEnd(); // 不修改结果,直接返回 return result; });然后,在登录框输入密码123456并点击登录。控制台会立即输出:
🔐 encryptPassword 被调用 输入密码 (明文): 123456 输出密文: e10adc3949ba59abbe56e057f20f883e同时,我们也可以一键HookCryptoJS.MD5,看看内部是否还有其他变换。
if (window.CryptoJS && window.CryptoJS.MD5) { window.CryptoJS.MD5 = hook.createDeepProxy(window.CryptoJS.MD5, 'CryptoJS.MD5'); }这样,任何对CryptoJS.MD5的调用也会被记录,我们可以确认盐值拼接的逻辑。
5.3 算法还原与模拟
通过Hook,我们确认了算法是MD5(固定盐值 + 密码)。那么,在Python或其他语言中模拟这个登录请求就非常简单了:
import hashlib def encrypt_password(password): salt = "a1b2c3d4" data = salt + password return hashlib.md5(data.encode('utf-8')).hexdigest() print(encrypt_password("123456")) # 输出应与Hook捕获的一致这个简单的案例演示了从定位、Hook、分析到最终还原算法的完整流程。对于更复杂的、涉及多次加密、随机数、时间戳的算法,Hook的价值会更加凸显,它能帮你理清调用链和数据流转。
6. 安全、伦理与工具边界
在分享如此强大的技术时,我必须强调其使用边界。JS逆向和Hook是一把双刃剑。
- 仅用于合法授权目的:这项技术应仅用于对自己拥有权限的网站进行安全评估、学习研究、或解决合法的自动化需求(如数据聚合分析,需遵守
robots.txt和服务条款)。任何未经授权对他人系统进行攻击、窃取数据或绕过付费墙的行为都是非法且不道德的。 - 尊重知识产权:分析代码是为了理解原理和解决问题,而不是为了抄袭或盗用他人的核心算法和商业逻辑。
- 注意法律风险:许多网站的用户协议明确禁止逆向工程。在实际操作前,请务必了解相关法律法规和网站政策。
- 工具本身的防护:本文讨论的Hook方法同样可能被网站用来检测和反制。在实际对抗中,网站可能会检查关键函数是否被代理、是否包含调试语句等。因此,更高级的逆向往往需要在无头浏览器、修改过的运行时环境中进行,这是一场持续的技术博弈。
最后,我个人的体会是,Hook技术是打开JS黑盒的钥匙,但它考验的不仅仅是编程技巧,更是耐心、细心和对系统运行原理的深刻理解。每一次成功的逆向,都像是完成一次精密的解密游戏。希望这套“一键Hook”的思路和实现,能为你提供一条清晰的路径,让你在应对JS加密时更加从容。记住,最好的工具是你不断积累的调试经验和系统化的思考方式。