0x00 承接上篇

上篇只是研究了 chrome headless 的启动以及部署,顺便跑起来个 demo,距离放到实际的漏扫检测中还是有点难度的,和 wilson 师傅做了一些奇怪的尝试,记录一下。

0x01 成为一个 server 吧

由于是借用的 CDP 与 chrome 进行的通信,所以想到利用 Express 框架搞个 server,不停的给他post 数据,让他进行检测,于是第一版代码很快就写出来了,这个就不贴了,没什么用。测试的时候遇到一个问题,如果我们的漏扫(Python的)快速的向他 post 了一把 URL,比如5个,chrome 就崩了。

一开始以为是 CDP 的问题或者是 Express 的问题,仔细研究发现,其实是我们的姿势不对。我们的第一版代码中,只开启了一个 CDP的实例,也就是只开启了一个 chrome 的 tab 页,(没想到 chrome 的 headless 模式竟然如此完备)不停的 POST 之后,chrome 只在一个 tab 页进行操作,所以就出现了类似竞争的问题,导致出错。

由于 node 水平实在有限,又不想用自己不熟悉的语言去搞这件事情,于是和 wilson 师傅商量我们用 Python 写个和 chrome 交互的 API,这样扫描器调用起来也会很方便。

0x02 踏上蛋疼的旅程

翻了一大把资料+读 CDP 源码之后发现,与 Chrome 交互的过程实际上是与 Chrome DevTools 进行交互,走的 websocket。也就是说,google 并没有搞出一套新的通信协议来,用的还是 Chrome DevTools 的协议。不仅仅可以操控 headless,非 headless 也可以借用这一套协议进行交互。

后来不记得在哪里翻出来一个 python 版的这套协议的实现,我们自己的实现其实也参考了这个项目:https://github.com/minektur/chrome_remote_shell

简单说一下这套协议吧,这套协议通过 websocket 进行通信,发送和返回的内容都是 json 格式。发送的格式大概是这样:

{
    "id": id,
    "method": command,
    "params": params
}

换成一个实际的例子可能是这样:

{
    "id": 1,
    "method: "Page.enable",
    "params": {}
}

{
    "id": 2,
    "method": "Page.navigate",
    "params": {"url": "https://www.github.com"}
}

在 CDP 的项目首页中有链接给到了所有的协议类型:https://chromedevtools.github.io/debugger-protocol-viewer/tot/ 很多很全很厉害。这样一来就简单很多,我们自己用 Python 连接 websocket 发 json 就可以了。

0x03 距离终点还很远

然而事情并没有我和 wilson 师傅想的这么简单。我们遇到了两个问题:

  1. 上次 node 代码里的事件监听怎么搞,例如:Page.javascriptDialogOpening 等。
  2. 怎么知道去连哪个 websocket 地址。

我们一个一个来解决,先来解决第二个问题,就是 websocket 地址的问题。

我们通过 CDP 的源码,发现了几个关键的 URI,分别是:

  • http://localhost:9222/json
  • http://localhost:9222/json/new
  • http://localhost:9222/json/close/tab_id

其中第一个 URL 是获取当前所有打开的 TAB 页,第二个是新建一个 TAB 页,第三个是根据 TAB 页的 id 关闭这个 TAB 页。
当我们请求第一个 URL 时,返回的内容大概如下:

[
    {
        "description": "",
        "id": "c33a4799-13e0-4b6a-b636-fd717c32c941",
        "title": "a.html",
        "type": "page",
        "url": "http://x.x.x.x/a.html"
    },
    {
        "description": "",
        "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/1adf9b16-5cca-483e-874a-2a53f4b131ca",
        "id": "1adf9b16-5cca-483e-874a-2a53f4b131ca",
        "title": "about:blank",
        "type": "page",
        "url": "about:blank",
        "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/1adf9b16-5cca-483e-874a-2a53f4b131ca"
    }
]

这里面可以拿到每个 TAB 页的详细信息。第二个新建 TAB 页访问之后,也会返回新 TAB 页的信息。其中就有一个很重要的字段:webSocketDebuggerUrl,这个就是我们要拿的 websocket 的地址。

另外一个问题,怎么处理监听事件的问题。一开始我还抓websocket 包,发现并没发送监听事件相关的内容,就陷入了无尽的思索。然后无意间发现,当我们发送完 Page.navigate 这个命令后,除了返回一个 id 相等的 json 之外,还会发送回来一堆东西。一开始代码中只 recv 了一次,所以一直没有看到这些,其中就有所有触发的事件,大概是这个样子:

{"id":2,"result":{"frameId":"33320.1"}}
{"method":"Page.frameNavigated","params":{"frame":{"id":"33320.1","loaderId":"33320.2","url":"http://x.x.x.x/a.html","securityOrigin":"http://x.x.x.x","mimeType":"text/html"}}}
{"method":"Page.javascriptDialogOpening","params":{"message":"9527","type":"alert"}}
{"method":"Page.javascriptDialogClosed","params":{"result":true}}
{"method":"Page.loadEventFired","params":{"timestamp":131319.852874}}
{"method":"Page.frameStoppedLoading","params":{"frameId":"33320.1"}}
{"method":"Page.domContentEventFired","params":{"timestamp":131319.853225}}

这样只要不停的接受返回,就可以拿到触发的所有事件了。但是在我们自己封 Python 包的时候,出现了问题。我们使用的是 websocket-client 库,这个 recv() 如果没有消息时,会 block 住,只好采取曲线救国策略。

0x04 封个包吧

我的代码中封了一个 ChromeHeadlessInterface 类,每一个类的实例代表一个 tab 页。主要实现了open_tab, close_tab, add_event_listener, send_command, recv方法。思路很简单,就是发一下,收一下,检查收到的消息中有没有触发用户监听的事件,有的话调用用户提供的 callback。因为 recv 会 block,我这里就提供了两种停止接收数据的方式,分别是通过字符串和 id 进行停止接收。

根据字符串停止比较好理解,就是出现了某个消息,我就认为这次通信完成了,可以继续发送下一个命令了。id 的方式就是比较这次返回的内容中是否有刚刚发送过去的 id,如果有的话,就认为可以停止了。大概就这样调用:

from ChromeHeadlessInterface import ChromeHeadlessInterface
def hook(params):
    print("detected alert!")
def hook2(params):
    print("detected alert! again!!")
chi = ChromeHeadlessInterface()
chi.add_event_listener("Page.javascriptDialogOpening", hook)
chi.add_event_listener("Page.javascriptDialogOpening", hook2)
chi.send_command("Page.enable")
result = chi.recv()
print(result)
chi.send_command("Page.navigate", {"url": "http://x.x.x.x/a.html"})
result = chi.recv("Page.domContentEventFired")
print(result)
chi.close()

wilson 师傅看完我的代码表示太辣鸡了,于是自己也封了一个,思路大概就是,一边疯狂的发命令,另一边不停的收结果,每收到一条结果就匹配一下用户监听的事件和 id,进行不同的操作。两份代码都放到了 github 上,大家可以参考下。地址:https://github.com/wilson9x1/ChromeHeadlessInterface

PS:如果不想自己折腾,可以看看selenium+chrome headless。链接:https://duo.com/blog/driving-headless-chrome-with-python,至于我们为啥要自己造个轮子,只能说对于我们的场景以及需求以及漏扫,自己封个轮子比直接调用这个东西更好用吧。