一、前言
在日常研究中,我们经常碰到大量JavaScript代码,我们首先要深入分析才能了解这些代码的功能及具体逻辑。这些代码代码可能会被恶意注入到页面中,可能是客户送过来需要我们帮忙分析的脚本,也可能是我们的安全团队在网页上找到的引用了我们服务的某些资源。这些脚本通常代码量不大、经过混淆处理,并且我们总是需要经过多层修改才能继续深入分析。
到目前为止,最简单的分析方法就是使用支持手动编辑的本地缓存环境或者使用代理来动态重写内容。本地方案最为方便,但并不是所有的网站都能完美适应其他环境,并且有时候人们会在提高效率的路上越走越远。代理非常灵活,但通常配置起来非常麻烦且不便携,每个人都有自己定制的环境,并且大家对各种代理的熟悉程度也不相同。我个人使用的是Chrome及devtools协议,以便hook请求及响应数据、动态修改数据内容。这种方式可以移植到搭载Chrome的任意平台,规避一系列问题,也能与常见的JavaScript工具完美集成。在本文中,我将介绍如何使用Chrome的devtools协议动态拦截并修改JavaScript。
本文使用的是node环境,但其中许多内容适用于各种语言,以便让大家能够轻松使用devtools。
如果大家之前没尝试过脚本化的Chrome,可以先参考下Eric Bidelman写的关于headless Chrome的入门指南,其中提到的技巧适用于headless以及GUI版本的Chrome(其中有一个小技巧会在下文给出解决方案)。
二、启动Chrome
我们可以使用npm中的chrome-launcher库来启动Chrome:
npm install chrome-launcher
我们从名字上就能猜到chrome-launcher的功能,也可以在命令行中无缝使用Chromium的参数(具体参数可参考此列表),这里我们选择使用如下选项:
1、–window-size=1200,800
自动设置窗口大小。
2、–auto-open-devtools-for-tabs
自动打开devtools(因为我们基本上每次都会用到这些功能)
3、–user-data-dir=/tmp/chrome-testing
设置用户目录。理想情况下我们并不需要这个参数,但出于某些原因,如果不使用该标志我们就无法在Mac OS X上使用非headless模式来拦截请求。由于我已经找到了解决方案,因此就不去深究背后的具体原因。如果大家找到更好的解决办法,欢迎推特联系我。
const chromeLauncher = require('chrome-launcher');async function main() { const chrome = await chromeLauncher.launch({ chromeFlags: [ '--window-size=1200,800', '--user-data-dir=/tmp/chrome-testing', '--auto-open-devtools-for-tabs' ] }); } main()
大家可以尝试运行如上脚本,确保能正确打开Chrome,效果如下图所示:
三、使用Chrome Devtools协议
该协议也被称为“Chrome调试器协议”,并且Google的某些文档中也用过这个称呼。首先,我们需要通过npm安装chrome-remote-interface包,以便与devtools协议交互。如果我们想深入了解具体细节,可以参考协议相关文档。
npm install chrome-remote-interface
为了使用CDP,我们需要连接到调试器端口,因为我们正在使用chrome-launcher库,只需要使用如下语句就能完成该任务:
const protocol = await CDP({ port: chrome.port });
首先我们需要启用协议中的许多域,这里我们先从Runtime域开始,这样我们才能hook控制台API,将浏览器对控制台的调用传递到命令行中:
const { Runtime } = protocol;await Promise.all([Runtime.enable()]); Runtime.consoleAPICalled( ({ args, type }) => console[type].apply(console, args.map(a => a.value)) );
现在当我们运行脚本时,将获得一个功能完整的Chrome窗口,也会将所有控制台消息输出到我们的终端,这对测试来说本身就是非常棒的一件事。
四、拦截请求
首先我们需要注册待拦截的目标,具体方法是向setRequestInterception提交待拦截的RequestPatterns。我们可以在Request阶段或者HeadersReceived阶段进行拦截,为了修改响应数据,我们需要等待HeadersReceived。resourceType与我们常在devtools网络面板中看到的类型一致。
此外,别忘了启用Network域(与启用Runtime域的方法一样,将Network.enable()加入数组中)。
await Network.setRequestInterception( { patterns: [ { urlPattern: '*.js*', resourceType: 'Script', interceptionStage: 'HeadersReceived' } ] } );
事件处理程序(handler)的注册过程相对比较简单,每个待拦截的请求都带有一个interceptionId,该ID可以用来查询关于该请求的信息或者继续放行。这里我们只是将已拦截的所有请求信息输出到控制台界面。
Network.requestIntercepted(async ({ interceptionId, request}) => { console.log( `Intercepted ${request.url} {interception id: ${interceptionId}}` ); Network.continueInterceptedRequest({ interceptionId, }); });
五、修改请求
为了修改请求,我们需要安装一些辅助库,用来编码及解码base64字符串。现在有许多库可以完成这项任务,我们可以选择自己惯用的工具。这里我们使用的是atob以及btoa,在使用这些库之前我们记得先得执行require操作,确保在脚本中能使用这些资源。
npm install btoa atob
这里用来处理响应的API略微有点麻烦。为了处理响应数据,我们需要在请求拦截上执行所有操作(而不是简单地拦截响应数据),然后通过interceptionId查询body数据。这是因为当handler被调用时body数据可能处于不可用状态,通过这种方式我们就能等待所需的数据。body可能经过base64编码处理,因此我们可能还需要检查编码状态,在继续传递之前执行解码操作。
const response = await Network.getResponseBodyForInterception({ interceptionId });const bodyData = response.base64Encoded ? atob(response.body) : response.body;
此时我们可以自由使用JavaScript:作为响应链的中间一环,我们可以访问所请求的完整JavaScript代码,也能返回任意内容。我们还可以稍微修改JS代码,在末尾添加一行console.log语句,这样当浏览器执行经过修改的代码时,我们的控制台就能收到相应消息。
const newBody = bodyData + `nconsole.log('Executed modified resource for ${request.url}');`;
我们不能直接简单地传递经过修改的body数据,因为修改后的内容可能会与来自原始资源的头部数据冲突。由于我们能自由测试并调整内容,我们可以从头开始构建最基本的头部数据,避免其它头部信息带来的潜在影响。如有必要,我们可以将responseHeaders传递给事件处理程序来访问响应头,但这里我们只需要使用数组创建所需的最小集合,以便将来进一步操作及编辑:
const newHeaders = [ 'Date: ' + (new Date()).toUTCString(), 'Connection: closed', 'Content-Length: ' + newBody.length, 'Content-Type: text/javascript'];
为了发送新的响应,我们需要构造一个完整的、经过base64编码的HTTP响应包(包括HTTP状态码),然后通过对象的rawResponse属性配合continueInterceptedRequest执行发送操作。
Network.continueInterceptedRequest({ interceptionId, rawResponse: btoa( 'HTTP/1.1 200 OKrn' + newHeaders.join('rn') + 'rnrn' + newBody ) });
此时,如果我们执行脚本,在互联网上随意冲浪时,就可以在终端界面看到如下类似内容,这是因为我们的脚本拦截了JavaScript,并且浏览器执行了经过我们修改的JavaScript,通过console.log()打印出这些信息。
完整的示例代码如下所示:
const chromeLauncher = require('chrome-launcher');const CDP = require('chrome-remote-interface');const atob = require('atob');const btoa = require('btoa');async function main() { const chrome = await chromeLauncher.launch({ chromeFlags: [ '--window-size=1200,800', '--user-data-dir=/tmp/chrome-testing', '--auto-open-devtools-for-tabs' ] }); const protocol = await CDP({ port: chrome.port }); const { Runtime, Network } = protocol; await Promise.all([Runtime.enable(), Network.enable()]); Runtime.consoleAPICalled(({ args, type }) => console[type].apply(console, args.map(a => a.value))); await Network.setRequestInterception({ patterns: [{ urlPattern: '*.js*', resourceType: 'Script', interceptionStage: 'HeadersReceived' }] }); Network.requestIntercepted(async ({ interceptionId, request}) => { console.log(`Intercepted ${request.url} {interception id: ${interceptionId}}`); const response = await Network.getResponseBodyForInterception({ interceptionId }); const bodyData = response.base64Encoded ? atob(response.body) : response.body; const newBody = bodyData + `nconsole.log('Executed modified resource for ${request.url}');`; const newHeaders = [ 'Date: ' + (new Date()).toUTCString(), 'Connection: closed', 'Content-Length: ' + newBody.length, 'Content-Type: text/javascript' ]; Network.continueInterceptedRequest({ interceptionId, rawResponse: btoa('HTTP/1.1 200 OK' + 'rn' + newHeaders.join('rn') + 'rnrn' + newBody) }); }); } main();
六、后续工作
后面我们还可以较为规整地打印出源代码,这对逆向工程来说通常是非常有用的第一步。当然现在许多浏览器都支持这个操作,但我们还是想自己控制数据修改的每个步骤,以便在各个浏览器版本之间保持兼容性,也能在分析源码时将各个环节连接起来。在分析外来的、经过混淆处理的代码时,如果我理解了变量以及函数的具体作用后,我就喜欢重命名这些对象。想要安全地修改JavaScript代码并非易事,本文只是一篇入门文章,后续大家还可以使用类似unminify的工具还原经过缩略或者混淆的代码。
const unminify = require('unminify'); [...]const newBody = unminify(bodyData + `nconsole.log('Intercepted and modified ${request.url}');`);