0x00 技术选型

前阵子有个需求是模拟浏览器在一个平台上填写信息、上传文件并提交。首先想到的肯定是基于Python的网络爬虫,然而稍作尝试后发现Python想要拿到登录后的Cookie实在是太难了,用户名、密码和验证码什么的先不说,光是这个平台整个是个单页Web APP就足以把Python拦住。其实是因为我Python菜啊嘻嘻

最后经过技术选型,决定使用Chrome + WebDriver实现了。

本来呢,我为了 装B 炫酷的效果,想要使用 Headless Chrome,这样实际使用的 user 看到的就是一个黑框弹出来,逐渐出现数十行文字(其实是运行日志),接着自己关闭了。然后——All Done!

然而在经历过这个沙雕 Web APP 那智障一般的体验,并对其稳定性产生了深刻的怀疑之后,我想了想还是不作这个死了,老老实实开实体浏览器吧,调试起来也方便一些。

WebDriver 现在的封装框架有很多,例如:

  • Selenium
  • WebDriverIO
  • php-webdriver
  • PhantomJS
  • Electron.js(你没看错!虽然这玩意主业并不是搞WebDriver)

PhantomJS 看着眼熟一些,之前在很多地方都见过。所以搜索到官网,打开,噩耗迎面而来:

Important: PhantomJS development is suspended until further notice (more details).

好吧,那这么说来很多新特性 PhantomJS 就不一定支持了(毕竟开发者自己拿 C++ 写的,不像 Electron.js 可以内嵌 Google 的 V8 引擎直接享受新特性),那就换 Selenium 吧,文档也比较全,毕竟PHP我写不太来。

Q:那为什么没用 WebDriverIO 呢?比 Selenium 新,功能还全

A:其实是因为当时没搜到这个框架23333

既然是要 Selenium + Chrome,那自然想到用 Node.js 驱动 Selenium 了,这样整个项目只用写一种语言就很舒适。

于是乎找到了 Selenium WebDriver 的 npm 包:selenium-webdriver

那么基本上准备工作就做完了。

0x01 从起飞到坠毁

首先是按照官方docs来一把梭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const webdriver = require("selenium-webdriver");

/**
* @type {webdriver.ThenableWebDriver}
*/
let browser = null;
if(argv.hidden) { // 命令行调用时如果指定了 hidden 参数,则调用 Headless Chrome
// 这里涉及到对 new 关键词的优先级的理解23333
browser = new webdriver.Builder().forBrowser("chrome").withCapabilities({
capability.set("chromeOptions", { args: ["--headless"] });
}).build();
} else {
browser = new webdriver.Builder().forBrowser("chrome").build();
}

browser.get("https://www.baidu.com");

等到调试完了它能跑起来了,就可以试着打开我们的自动化处理对象——某设计的特别坑的单页 Web APP 平台进行测试了。

直接打开,噩耗再次迎面而来:

警告:请不要使用自动化测试浏览器打开本页面!

呱,完蛋,被检测到了。

0x02 过检测

就此放弃当然是不可能的!Nobody can stop me such easily!

首先看看它是怎么检测到WebDriver的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 略有改动
async function detectWebDriver() {
return navigator.webdriver === true ||
/HeadlessChrome/i.test(window.navigator.userAgent) ||
navigator.plugins.length === 0 ||
navigator.languages.length === 0 ||
!window.chrome ||
(
(await navigator.permissions.query({
name: 'notifications'
})).state === 'prompt' && Notification.permission === 'denied'
);
}

webdriver-test-result.jpg

可以看到,由于没有用 Headless 模式,所以直接通过了五个针对 Headless Chrome 的测试。那么现在我们唯一要做的就是让 navigator.webdriverundefined 或者 false(正常情况下应该是 undefined)。

navigator.webdriverW3C 给出的(非规范)标准

1
2
3
4
Navigator includes NavigatorAutomationInformation;
interface mixin NavigatorAutomationInformation {
readonly attribute boolean webdriver;
};

navigator.webdriver Defines a standard way for co-operating user agents to inform the document that it is controlled by WebDriver, for example so that alternate code paths can be triggered during automation.

由于按照文档,这个属性是 readonly,所以直接在页面内用 JS 来修改这个值是不行的:

cannot-reassign-webdriver.jpg

怎么办呢?花了一点时间头脑风暴,想出来两个办法:

  1. delete 关键字删掉它
  2. 利用 Object.defineProperty 直接覆盖 navigator.webdriver

0x02.1 方法一

先尝试第一个办法。最开始的打算是直接删掉这个键,然后发现不太行:

1
2
3
4
> delete navigator.webdriver
true
> navigator.webdriver
true

显然是没有删掉。于是怒从心头起,恶向胆边生:

1
2
3
4
5
> delete navigator
true
> navigator
✘ Uncaught ReferenceError: navigator is not defined
at <anonymous>:1:1

woc这也太玄学了,就这么着把 navigator 给删掉了?

行吧,那就手写个代理好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_nav = navigator;
delete navigator;
var navigator = {};
for(let key in _nav) {
if(key !== "webdriver") {
if(typeof _nav[key] === "function") {
// make sure `this` refers to the correct object.
navigator[key] = _nav[key].bind(_nav);
} else {
navigator[key] = _nav[key];
}
} else {
// do nothing
}
}
navigator.constructor = Navigator;
navigator.toString = Navigator.prototype.toString.bind(_nav);
navigator.toLocaleString = Navigator.prototype.toLocaleString.bind(_nav);
navigator.isPrototypeOf = Navigator.prototype.isPrototypeOf.bind(_nav);

// Ta da!

成功干掉了 navigator.webdriver 这个暴露我们身份的叛徒。

然而这么做的话还会有一些问题:

1
2
3
navigator instanceof Navigator;  // expect: true, actual: false
navigator.toString.name; // expect: "toString", actual: "bound toString"
navigator.isPrototypeOf === Navigator.prototype.isPrototypeOf; // expect: true, actual: false

解决方案无非是继续魔改 Navigatornavigator.toStringNavigator.prototype.isPrototypeOf,甚至是顺着原型链一路魔改到 Object.prototype。无非是根据检测方法见招拆招,没有一劳永逸的办法。(例如,对方可以接着检测 navigator.toString.toString.name

更新:又想到这种手写代理会导致原先 navigator 的所有属性的 propertyDescriptor 全部丢失,新的 navigator 的属性值会全部变成在注入代码执行时刻访问原 navigator 的属性时得到的值,也即调用了 get() 之后得到的结果。换言之,新的 navigator 的所有属性都是可更改的,这与正常的 navigator 对象的表现不符;而且,新对象的属性值也不会变化(例如,用户断开了 Wi-Fi,但是 navigator.onLine 仍然为 true)。

综上,方法一对于我这种轻度(?)完美主义者来讲,还不够完美。(傲娇脸(bushi

0x02.2 方法二

写到这里,学校教学楼下有猫发情叫春,思路断了(逃

不得不说猫比人强的一点就是在那啥的时候不怕被一堆人盯着

直接上代码:

1
2
3
4
Object.defineProperty(navigator, "webdriver", {
get() { return undefined; },
set(v) { return v; }
});

于是乎把 navigator.webdriver 干掉了。

然而同样的,方法二也存在一些问题:

1
2
3
4
5
6
7
8
9
10
// define it again!
Object.defineProperty(navigator, "webdriver", {
// ...
}); // Uncaught TypeError: Cannot redefine property: webdriver

delete navigator.webdriver && navigator.webdriver === undefined; // expect: true, actual: false

"webdriver" in navigator; // expect: false, actual: true

Object.getOwnPropertyDescriptor(navigator, "webdriver"); // expect: undefined, actual: { ... }

对于问题 1,我们可以在最开始配置这个属性为 configurable: true,这样它就可以被再次定义。

1
2
3
4
5
6
Object.defineProperty(navigator, "webdriver", {
get() { return undefined; },
set(v) { return v; },
configurable: true,
enumerable: false
});

对于问题 4,如果是通过 Selenium WebDriver 的 API 注入代码的话,页面内的脚本测试时会直接返回 undefined(这与在浏览器 Console 中执行同样代码后的表现不同)。

对于问题 2 和 3,我目前没有找到有效的 Bypass 方法:

  • 问题 2. in 操作符无法重载
  • 问题 3. 使用 defineProperty 覆盖了 webdriver 键之后,如果为了让 delete navigator.webdriver 能够返回 true(而不是 false)就把 webdriver 配置为 configurable,那么这时 delete 掉它之后 navigator.webdriver 又会变回 true (ノಠ A ಠ)ノ┻━┻

好在目标平台没有这么聪明。hhhhhh

实在没办法了我就换成 Firefox 驱动的 WebDriver 呗

0x03 检测页面状态

过完检测了,还得在行为上装的像个人。目标平台没有做鼠标滑动检测,这个非常好,因为我搞不定;但是目标平台有一个非常智障的情况,就是这个单页 Web APP 经常处于加载中的状态,并且加载时间毫无规律。往好了说,就是它把 lazy load 玩到了极致,而且有效区分了人和机器;往坏了说,其实是前后端都写的太差,根本不知道什么是预加载,并在无意之间使用毫无规律的加载时间把机器挡在门外。

那么我们还得想办法知道页面什么时候加载完了。

(有点写累了,写简短点)

其实本质上就是使用 browser.executeScript 这个 Thenable API,注入一些检测指定元素状态的代码来判断页面是否加载完成。

例如:

1
2
3
4
5
6
7
8
async function attachmentIsUploaded() {
return await browser.executeScript(`
var node = document.getElementById("id-of-specific-element");
if(!node) return false;
if(node.innerText.trim().length === 0) return false;
return true;
`);
}

browser.executeScript 会自动把代码包装成一个 function,所以可以直接在代码里写 return 来返回一些值。

剩下的就简单多了,都是面条代码,如果想要复用性强一点就可以使用一些相对高级的编程思想(例如,我最后把逻辑抽象成了面向对象编程的样子)

0x04 注入代码

我们得想办法把所有上述过检测的代码都注入到页面的最开始(也即先于所有 JS 脚本执行),因为检测脚本是写在了一个 <script> 标签里,加载的同时就会执行。然而对于依托 Selenium 框架编写的代码来讲,最早的能够和页面发生交互的时刻也已经是在 DOMContentLoaded 事件之后了,所以貌似此路不通。这也解释了为什么 Selenium 近些年来在不断的流失用户23333

既然此路不通,我们就只好绕路了。于是想到通过代理,让浏览器拿到的 HTML 就已经是注入了代码了的。所以这时候就需要使用 mitmproxy 了:

mitmproxy is a free and open source interactive HTTPS proxy.

It can be used to intercept, inspect, modify and replay web traffic such as HTTP/1, HTTP/2, WebSockets, or any other SSL/TLS-protected protocols.

安装 mitmproxy:

1
brew install mitmproxy 

配置 mitmproxy 使其支持 HTTPS:

  1. 在 Chrome 里配置好代理,指向 localhost:8333
  2. 运行 mitmproxy -p 8333,可以看到经过该端口的所有流量
  3. 访问 mitm.it,按照指示进行配置即可
  4. 备注:别的 mitmproxy 用户不会因为你信任了 mitmproxy 的证书就能够窃听你的通信,因为 the certificate you are about to install has been uniquely generated on mitmproxy's first run and is not shared between mitmproxy installations.

稍微看了一下 mitmproxy文档,发现它推荐使用 Python PyPI package 的方式来引入并使用。

于是编写一个 Python 脚本 injector.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from bs4 import BeautifulSoup

with open("./inject.js") as f:
jscode = f.read()

def response(flow):
if flow.response.headers["Content-Type"] != "text/html":
return
if flow.response.status_code != 200:
return
html = BeautifulSoup(flow.response.text, "lxml")
if html.head:
script = html.new_tag("script")
script.string = jscode
html.head.insert(0, script)
flow.response.text = str(html)

安装依赖项,并且在 8333 端口运行这个代理:

1
2
python -m pip install lxml
mitmdump -p 8333 -s "./injector.py"

接下来需要做的就是配置所有 Selenium WebDriver 的请求都向 http://localhost:8333 发送。

对我们的原有的脚本稍作修改,加入一些传递给 chromedriver 的命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
const PROXY_HOST = "localhost", PROXY_PORT = 8333;
const capability = webdriver.Capabilities.chrome();
const chromeOptions = {
args: [`--proxy-server=${PROXY_HOST}:${PROXY_PORT}`]
};
capability.set("chromeOptions", chromeOptions);
const browser = new webdriver.Builder()
.forBrowser("chrome")
.withCapabilities(capability)
.build();
browser.get("http://hostname:port/pathname").then(async () => {
// ...
});

接下来就可以享受生产力的飞跃了(耶!

懒人改变世界!


❄面向对象编程:Object Oriented Programming(至少在刚开始写这个项目的时候确实是这个意思(逃

来源:https://blog.jiejiss.com/