关于 Chrome Headless 漏扫爬虫的一些思考

引言

去年5月初识Chrome Headless,带我走进了一个全新的领域,恰好那时学校要求去做一个爬虫。经过一段时间信息的收集,看见了fate0师傅写的关于动态爬虫漏扫文章,觉得非常不可思议,只能用amazing来形容。于是我决定实现它,但是前前后后几乎一年多的时间,断断续续的写。以至于最后也变成了我的毕业设计,感触很多,思考也很多。

如今关于用chrome headless来构造爬虫的资料也有许多,所以这里我不会过多从大方向去介绍它,我想讲一讲关于某些细节的实现。

The Start

第一个摆在我面前的问题是,我应该用哪种方式去操作CDP。

这很重要会决定后面应该去怎么写,我首先尝试的是官方出品的puppeteer https://github.com/GoogleChrome/puppeteer

这是我的第一个选择,nodejs写的,但是经过一番简单的尝试以后,我并没有决定使用它,转而又去使用了另一个也是用nodejs写的package-chrome-remote-interface

https://github.com/cyrus-and/chrome-remote-interface

这个包的调用方法更加的贴近原生的CDP的api,于是我选择了它,但这两种写法都是基于nodejs的,在这其中我遇到了一个没有sleep函数的尴尬,因为最开始我需要使用sleep来等待页面,这其中包括页面的加载和对页面的处理时间,而且也不好动态判断,关于等待页面加载和页面处理时间点的处理,后面我会详细讲一下。

最后实在不太喜欢await的语法,但是我还是比较喜欢callback的写法。于是又一次结束了这段时间的尝试,后来我想要不要用go来试试,最好我认定了go是最好的处理方式。 go是最直观的阐述了什么叫多线程的一门语言 :)

于是我直接去找了github 上star最多的chromedp,用了之后却发现其实不太好用,还不如自己重新造个轮子,这个package竟然都不能自定义给CDP的事件添加回调,于是我去官方提了一个issue,引了很多人的讨论

https://github.com/chromedp/chromedp/issues/252](https://github.com/chromedp/chromedp/issues/252

也有很多人使用了比较巧妙的hook方法,只是这一切都来的太迟了。我早已经了fork了原来的项目,添加了可以自定义给CDP添加回调的接口,删掉了几乎chromedp里面所有包装了CDP中DOM模块的接口,我更喜欢原生的js在游览器的console里面执行,修复了一些在headless下的一些小bug,比如在headless下面并不能正常的关闭游览器,初步可用。

在今年3月,官方也决定了重构整个chromedp,发布了dev版,但是还是先前说的那样,这一切都来的太迟了,我也没有重新用过重构后的chromedp,但是应该不会太差吧。其实原先的chromedp并没有兼容的headless下面的CDP,而是面向正常的可视化下的chrome浏览器,所以或多或少会存在一些问题,但是这些问题也并非是不可解决的。

页面加载等待时间的确定 && 页面中爬虫脚本执行完毕的时间点的确定

第一个时间点的确定,其实就是什么时候该往页面去注入JS,首先要考虑的是什么时候标志着页面趋近于稳定状态。

关于这个问题fate0师傅设计了一种等待模式,或多或少我觉得有些稍微复杂了一些,于是我决定去看看能不能找到更加稳定的时间点,我回过头又去重新看一遍页面加载过程的生命周期,多了几个fate0师傅没有提到的几个事件,于是经过研究这几个事件,发现也是不能绝对预测的。最后还是把目标锁定在了DOMContentLoadedLoaded这两个事件上,也是在页面js中经常检测页面变化所使用到的事件,fate0师傅所提到的顾虑也是关于这两个事件。

一般的框架语言或者业务逻辑的需求都会去绑定这两个事件,去执行一些操作,注入js的时间必须靠考虑到这两个时间点,如果只是单纯的去考虑这两个事件,比如完全等到页面停止转圈以后再注入js,但是如果存在一个比较大的资源文件,比如字体视频图片,那么去等待它的加载是完全没有必要的,那么再将范围缩小,考虑几个必要条件,注入的爬虫js脚本肯定是需要DOM树的支持,如果页面原有的js绑定了这个时间点,那么它势必会做一些操作,这些逻辑通常包括的是初始化一些过程,这对于页面的完整性有很重要的意义,我们不能去干扰到它,所以我们需要找到一个时间点是这些原有的回调过程刚好结束以后的某个时间点。

如何确定这个时间点呢?

其实这里关于DOMContentLoaded之前我的理解一直是错的。触发页面的DOMContentLoaded的时间点其实是在用户绑定的操作完成以后才发生的。这里我使用了F12里面preformance功能所观察到的,通过这个功能可以实时的观测运行时preformance.timing的变化,通过测试发现如果绑定多个回调函数在DOMContentLoaded上时,相应的回调函数调用的时间是位于domContentLoadedEventStartdomContentLoadedEventEnd之间,而DOMContentLoaded发生在domContentLoadedEventEnd之后一小段时间里。load也一样,也是发生在loadEventEnd之后。

所以这里有一个很重要的信息是,在考虑监听DOMContentLoadedload时候并不需要去关注用户是否在这里绑定了回调函数,因为已经执行完毕了。所以在注入js的时间点上,我使用是等待DOMContentLoaded,然后直接注入js,可能存在一些不容易被发现的问题,如果有师傅发现在这个点有好的思考,欢迎交流。

第二个时间点的确定,爬虫脚本执行完毕的时间点。如何去标志这个时间?

在最开始也困扰了我很长时间,爬虫脚本执行完毕以后的结果,应该是页面又开始趋近于稳定,第一次稳定是发生在页面的加载过程。如果你去仔细想想这爬虫脚本执行整个过程的话,其实比较复杂且不可能来预测的,所以我第一次是用sleep来等待一个合适的时间。这样做其实非常不妥,肯定不能是等待固定的时间,随着页面复杂度的增加,这个等待时间也应该是正向增长的,随之而来的问题是怎么确定页面的复杂度?

如果细心观察的话,在往页面注入js的时候,如果是比较简单的表达式,会直接返回相应值,而如果是不能立即返回的复杂表达式的时候,这个时候会返回一个inject-id,关于这个差异我又去仔细读了一遍CDP的文档,发现了在runtime模块又发现了一个有意思的接口 Runtime.awaitPromise,当把inject-id作为objectId参数传递给这个接口的时候,它会等到我们注入js脚本完全结束以后才会返回,我们可以用它来阻塞当前的控制进程,这里就实现了标志爬虫结束的时间点。

避免爬虫进入死循环

爬虫在遍历节点的过程,可能进入死循环,这个东西很奇妙,你会遇到,也可能遇不到。最终我解决这个问题的方法就是--”让我们的爬虫尽可能模拟一个正常人去浏览页面“,在写js爬虫尽量去掉一些反人类的逻辑。

关于如何遍历页面的节点和监控页面节点的新变化,fate0师傅已经的很详细了,我在这里不再赘述,其中fate0师傅提到了首先要对所有节点做一次深拷贝,这个操作其实不仅仅在开始就要做,在后面的每一次深度遍历之前,都需要再做一次,收集当前处理节点下的静态子节点。关于节点与节点之间处理的时间间隔,这个也十分重要,如果你增加有过类似的经历,在不加时间间隔的时候,form请求会被cancel掉,在网络层你会拿不到form的请求,关于这个时间间隔是多少,我也无法去准确描述,应该根据你所处的环境去判断,如果能设计出一种自适应的机制,那一定会很棒!

关于用MutationObserver来监控页面节点时候,有一点你需要知道的它是不会立即返回页面节点的变化,它会等到一次DOM操作完全结束才会返回。

在这里我讲一个我遇到的进入死循环的问题,应该是在用爬虫测试wp的时候发生的,有这样一个情况,有两个按钮,一个变化,另一个也变。这导致一直在通过MutationObserver接受相同的records,然后陷入死循环,内存暴涨,最后chrome crash。这个地方我调了好一会也才找到问题发生的地方,这个情况应该去怎么样处理呢? 经过我的仔细分析因为相关的页面变化没能及时返回,这个时候其实页面已经变化了,但是又回去处理旧节点,这地方就会出现问题。然后我仔细了思考了一下,人在面对这种问题的时候,应该怎么处理,人在点击某个按钮之后,等待页面发生变化之后,在这个变化的基础上,再去做一些操作。这很好理解,我们不能切状态,必须马上响应当前的变化,所以我调整了爬虫,在每次对节点处理之前,我都会通过ob_callback_func(ob.takeRecords());这一步先清空记录页面变化的records,确保之前的变化都能被响应。

这个地方的处理是玄之又玄,你可能一时间无法想到可能存在隐藏的问题,陷入死循环,让爬虫脚本无法正常结束,其实说白了,这个爬虫还需要经过大量的前端应用识别,来进一步优化。 :(

网络层的处理

这一节是关于如果拿到链接的一些细节,如果你想全部在网络层拿到所有的链接。

这个有一个工具不得不提ajax-hook,因为异步请求是会被其他操作cancel掉的,你需要去完全hook掉xhr,让ajax全部改为同步的,这样所有的请求都可以在网络层拿到,关于是否放行xhr,这个也没有固定的选择,又一个纠结点,xhr的response是否会产生新的链接,这是我们无法预测的。

在第一次遇到重定向链接的时候,让我又开始重新思考网络层拦截的流程,我之前的策略是放行page.navigate,然后之后的请求全部abort,在这里就很尴尬了,遇到了重定向的返回,页面直接变空白。

所以不得不重新想一下具体的流程。所以策略又变为遇到重定向之后,直接放行。我又遇到了另一个问题,在文档中拦截到的请求是Network.requestIntercepted这个事件,这个事件是包含redirectUrl这个字段,当然这个字段不为空时,代表拦截的请求是重定向链接,这里需要结束一下关于网络层的拦截器的设置,是可以指定拦截的时候,分为请求发起的时候,和接受到请求响应的时候,这两个时间点,由于文档不是太清晰,加上设置的拦截点一直是发起request的时候,一直没有redirectUrl这个字段,遂去看了chromium实现此处的代码,这是我当时去看chromium源码的经历# Network.requestIntercepted for Redirect,所以此处另外我还得设置对response的拦截器,那会不会造成资源浪费呢?

其实不会,这篇文章里有为什么,另外这里也提到其实这里也是可以通过改源码来实现的。

URL分类

这里我等了很长fate0师傅关于漏洞检测技巧的文章,但是等了很长时间也没有等到,想和师傅的文章印证一下自己的想法。所幸的是师傅关于这节的文章也在后来发出来了,可是我这个项目也已经很长时间没有动了。

这里为什么叫URL分类呢,主要是想谈一下关于去重的技巧,再到怎么把URL分解成一个一个 unit传给我们的漏洞检测或者fuzzing模块。这里的去重并不是简单的相同的去重而是相似去重。其实很多时候都是在猜程序员的心里想法。

一个url 可能如下:

1
http://90sec.org/something.php?a=view&page=1&pre=20
请求的结构分成固定的逻辑参数和非固定的参数。

很明显上述a是逻辑参数,而page和pre是用户指定的非固定的参数,那么这里就可以写一条临时规则 something\.php\?(a=view|page=\d*?|pre=\d*?){3},根据规则命中的数量,我们可以有理由去判断这是不是一个真正的规则。

我们策略是先遍历规则库,没有命中再去刷一遍已经跑过的列表,这只是简单的判断策略,URL很有可能是rewrite过的,这个时候你的参数分割符也要变,关于怎么判断是不是逻辑参数在这里只能靠去,然后靠后面的命中阀值再去判断,猜也有策略的比如1位数字,纯字母,这就很有可能是逻辑参数,固定长度单一数字串,可能是时间戳,固定长度的字符串,可能是hash,这里就完全靠你开发经验能不能猜中程序员的❤了。

再就是要把URL分割成unit,什么叫unit呢,用下面例子来解释:

1
2
http://90sec.org/something.php?a=view&page={{exp}}&pre=20
http://90sec.org/something.php?a=view&page=1&pre={{exp}}
你需要对你分割的URL参数再做一个排列组合,每次保证只有一个变量,我们的漏洞测试模块只需要去替换{{exp}}就行了,这里就可以把爬虫和漏扫分开为两个独立的模块。

在我的爬虫里面,我把fuzzing模块写成了.so,这里吐槽一下go编译出东西是真的大,import一个package,尽管只调用其中一个方法,都会把包的内容全部编译到二进制里面 !: ,还有一个吐槽点不能动态的卸载.so!!

再不重新编译chromium情况下的一些处理

fate0师傅提到了由于一些问题,我们不得不去重新编译chromium,来满足我们的需求,这个过程相对来说是比较困难的,首先第一个难题就是拉chromium的代码,然后改完之后再编译。

我逃了(有一个肯定会遇到问题,在触发了用window.open打开窗口的时候,这个时候拿到新的window的url之后需要去关闭它,如果你能直接重新编译chromium,你可以直接不允许在当前页面中打开一个新的页面。但是做不到的前面操作的时候,这个时候该怎么去处理呢?由于设计的时候,要做到并发,例如开5个tab,那么在chromium启动的时候,直接给它5个tab,后面打开的tab直接全部kill,缺点是后续你无法动态的添加tab,因为通过CDP我们无法去确定是谁打开了新的tab,对它来说无论是谁,都会被kill。

但是关于连续的location这种,我们还是无法处理。有种深深的无奈 (

尾声

其实还有很多点不能用语言去全部阐述,因为坑太多,再后来由于没有时间,做到最后,我想我实力再强一点,我为什么不能把chromium改造一个漏扫呢???

想到这里再去控制CDP变得有些索然无味,但是js的爬虫脚本还是值得思考的点。事隔这么长时间再来看这个东西,也别有一番滋味,本来在回家之前,打印了一份paper,一路上在火车上无聊也可以研究一番,是一个关于linux kernel比较经典洞,但是思前想去,如果发一篇分析其实效果也不太好,但是我本人还是对这个洞的精秒利用比较痴迷的。

于是在回到家以后有了此篇,如果有喜欢它的朋友,你有好想法或者疑问都可以来找我交流,这里还是非常感谢fate0师傅,虽然我们素未谋面,因为他文章才有了我的那些东西。