如何通过Devtools协议拦截和修改Chrome响应数据

2021-04-15 10:51:21

参考地址  如何通过Devtools协议拦截和修改Chrome响应数据

一、前言

在日常研究中,我们经常碰到大量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}');`);


  • 2019-12-04 10:48:18

    vue 项目资源文件 static 和 assets 不说区别直接使用?

    assets中资源会webpack构建压缩到你代码中,而static文件直接引用。 static 中长存放类包、插件等第三方的文件,assets里放属资源文件比如自己资源图片、css文件、js文件。 引入资源的方式static文件夹可以使用~/static/方式引入, assets文件夹可以使用 ~@/assets 方式引入

  • 2019-12-05 17:01:36

    Vue 结合 Axios 接口超时统一处理

    当网路慢的时候。又或者公司服务器不在内地的时候,接口数据请求不回来超时报错的情况相信大家肯定遇到过的,这里我把我公司项目请求超时的处理方法分享下,希望看过后有帮助。

  • 2019-12-05 17:13:40

    JS模板工具lodash.template的简单用法

    lodash是从underscore分支的一个项目,之前我写了一篇JS模板工具underscore.template的简单用法,lodash跟underscore很相似,这也简单介绍一下lodash的template方法。 先把underscore的文章中用过的代码贴过来,把underscore的js文件换成lodash的js,其他一字不改,然后我们试试:

  • 2019-12-06 10:47:29

    date-fns日期工具的使用方法详解

    isToday() 判断传入日期是否为今天 isYesterday() 判断传入日期是否为昨天 isTomorrow() 判断传入日期是否为 format() 日期格式化 addDays() 获得当前日期之后的日期 addHours() 获得当前时间n小时之后的时间点 addMinutes() 获得当前时间n分钟之后的时间 addMonths() 获得当前月之后n个月的月份 subDays() 获得当前时间之前n天的时间 subHours() 获得当前时间之前n小时的时间 subMinutes() 获得当前时间之前n分钟的时间 subMonths() 获得当前时间之前n个月的时间 differenceInYears() 获得两个时间相差的年份 differenceInWeeks() 获得两个时间相差的周数 differenceInDays() 获得两个时间相差的天数 differenceInHours() 获得两个时间相差的小时数 differenceInMinutes() 获得两个时间相差的分钟数

  • 2019-12-06 10:49:39

    npm 查看源 换源

    npm,cnpm,查看源,切换源,npm config set registry https://registry.npmjs.org

  • 2019-12-06 11:01:31

    npm发布包流程详解 有demo

    npm发布包步骤,以及踩过的坑(见红颜色标准): 1.注册npm账号,并完成Email认证(否则最后一步提交会报Email错误) 2.npm添加用户或登陆:npm adduser 或 npm login

  • 2019-12-06 13:16:18

    vue mixins组件复用的几种方式

    最近在做项目的时候,研究了mixins,此功能有妙处。用的时候有这样一个场景,页面的风格不同,但是执行的方法,和需要的数据非常的相似。我们是否要写两种组件呢?还是保留一个并且然后另个一并兼容另一个呢? 不管以上那种方式都不是很合理,因为组件写成2个,不仅麻烦而且维护麻烦;第二种虽然做了兼容但是页面逻辑造成混乱,必然不清晰;有没有好的方法,有那就是用vue的混合插件mixins。混合在Vue是为了提出相似的数据和功能,使代码易懂,简单、清晰。

  • 2019-12-06 13:26:30

    vue的mixins混入合并规则

    混入minxins:分发vue组件中可复用功能的灵活方式。混入对象可以包含任意组件选项。组件使用混入对象时,所有混入对象的选项将混入该组件本身的选项。