阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

爬虫浏览器的Cloudflare五秒盾处理

背景

近期业务有不少涉及到国外的网站,本以为经受了和国内大量卷王公司对抗的考验之后,处理国外业务应该是降维打击才对。结果本地测试的时候的确很OK,但是一上线就发现全面飘红,多个不同业务同时出了 Cloudflare 著名的五秒盾

根据 Cloudflare 的说明,只要是使用 Cloudflare 的域名解析服务,默认都会自动“享受” 他家的一系列防护服务(尤其是 Javascript 防护)。具体特征就是会在/cdn-cgi/challenge-platform/ 下嵌入风控逻辑。他们会收集浏览器的一系列指纹,再根据后台大数据对比,计算出当前环境的可疑概率以进行风控。

原则上出现这个也无妨,毕竟五秒盾也只防第一次登录。就算 hCaptcha 不好自动化处理,先人肉处理下,后续复用统一份 Cookie ,只要控制好规模和频率,基本能满足业务需要。但是吊诡的在于:无论验证多少次,永远不会跳转回正常网页。(鬼知道我点了多少次小海豚和大熊猫)

一般来说,对于网页爬虫而言,遇到强力风控时,只要不是极其重要的业务,我们一般都是考虑从业务上更换策略,尽量不正面硬刚。但是用本地浏览器在匿名模式下访问时、在和线上使用相同代理IP的前提下,虽然也出了五秒盾,但是人肉点也是能正常滑过的。这说明他的风控策略本身应当并不强,或者说这看起来不像是一种反爬策略,而更像是一种 Bug 或是某种安全策略,并不像是无法解决的问题。于是就尝试处理了一下,果然发现了一些意料之外的坑。

环境

我们的工作环境是基于 cef 定制的有头浏览器。通过 cef 框架 hook 浏览器页面的 LoadStart, LoadEnd, LoadError 等事件,注入用于页面操作的纯 JS 脚本,再通过代理抓包获取数据。同时定制一些集群管理、任务管控、链路追踪等逻辑。内核基于的是 87.1.12+g03f9336+chromium-87.0.4280.88 的 Linux 版本,默认的 Chromium 启动参数大致有如下这些:

--disable-dev-shm-usage
--no-first-run
--disable-web-security
--disable-site-isolation-trials
--lang=zh-CN
--no-sandbox
--single-process
--disable-http2
--disable-gpu
--disable-sync
--allow-running-insecure-content
--ignore-certificate-errors
--proxy-server=localhost:8080
--user-agent="some user agent"

解决

解决的思路比较清晰,既然本地环境可以正常过风控、线上的环境不行,我们就先想办法在本地搭建类似线上的环境,先想办法复现出问题,再控制变量将二者环境参数不断接近,直到找到临界的问题点。

测试的时候尤其需要注意两点:

  1. 如果配置了用户目录或者缓存目录,一定要记得每次测试前都要删一下,防止影响结论。
  2. 每一次测试都要重复多次,否则很容易由于各种偶然原因影响结论。

这里省略枯燥的探索过程,直接记录多次尝试后的结论:(实测,以下配置缺一不可)

关闭 --single-process 配置

默认 Chromium 是多进程模式的。这里的多进程模式可以有两层理解:

  1. 窗口进程、渲染进程、GPU进程、工具进程分开;
  2. 不同站点页面进程分开;

进程分开的好处就是可以更方便的进程安全控制,但是代价就是整体内存占用会略高。

因此我们线上默认选择了单进程模式,本意是为了节省进程数从而节省内存,让单个机器能同时跑更多的任务。当然,这里说的单进程并不是只有一个进程,而是只有 zygote 一种进程。(参考 #single-process-mode

例如,打开百度,如果采用单进程模式,只会有四个进程:

myths@ubuntu:~$ ps -ef|grep myproject|grep -v myproject
myths      27250    3785 10 15:27 pts/1    00:00:05 /home/myths/Projects/myproject --url=https://www.baidu.com --single-process
myths      27253   27250  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --no-zygote-sandbox --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27254   27250  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27256   27254  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data

而如果使用默认的多进程模式,则会有八个进程:

myths@ubuntu:~$ ps -ef|grep myproject -v myproject
myths      27351    3785  9 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --url=https://www.baidu.com
myths      27354   27351  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --no-zygote-sandbox --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27355   27351  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27357   27355  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27378   27351  4 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27394   27357  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=renderer --user-data-dir=/home/myths/.config/cef_user_data --first-renderer-process --lang=en-US --num-raster-threads=1 --renderer-client-id=5 --time-ticks-at-unix-epoch=-1669691342091293 --launch-time-ticks=102038976468 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27395   27357 35 15:29 pts/1    00:00:02 /home/myths/Projects/myproject --type=renderer --user-data-dir=/home/myths/.config/cef_user_data --lang=en-US --num-raster-threads=1 --renderer-client-id=4 --time-ticks-at-unix-epoch=-1669691342091293 --launch-time-ticks=102038982131 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27420   27354  2 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=gpu-process --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data --gpu-preferences=WAAAAAAAAAAgAAAIAAAAAAAAAAAAAAAAAABgAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAABAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAIAAAAAAAAAA== --use-gl=angle --use-angle=swiftshader-webgl --shared-files --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache

使用单进程模式看似很美好,但是问题就是失去了多进程间的隔离,很多安全策略就失效了,浏览器的安全性就会下降很多。同时单进程模式下似乎也更容易出 bug,我在测试的时候发现单进程模式下的浏览器的确很容易出现一些 DeadLock 和 Coredump。

Cloudflare 或许通过某种方式测试出了单进程模式下安全性的问题,也或许是触发了单进程模式下的一些 Bug,导致风控页面无法跳转,具体方式还不清楚。

关闭--disable-site-isolation-trials 配置

添加这个配置的本意是方便进行跨域请求,但是据说这个需要跟 --disable-web-security--user-data-dir 一起使用才能生效(参考 StackOverflow)。但是实际上我测试下来,不加这个配置也能进行跨域访问,不太明白。

Cloudflare 似乎也能检测到这个参数引入后带来的问题,导致风控校验不过。原因也不明。

Chromium 内核版本尽量用较新的版本

众所周知,UserAgent 信息一定会被拿来进行大数据计算和风控识别,因此这个值的设置要尤其注意:

  1. UserAgent 里带的浏览器内核版本要尽量的新。长期不升级的浏览器显然是有问题的,况且内核较老的浏览器容易有未被修复的安全漏洞,可能会通不过检测。
  2. UserAgent 的值尽量和真实的内核版本一致。毕竟 UserAgent 声明的配置有时是会和其他配置互相应证的,如果不改全,很可能会被认为是恶意修改。不过,如果是 Linux 机器,还是建议不要用 Linux 的 UA,毕竟在 Linux 下刷网页的,更像是爬虫,更容易被风控。实测用 Mac 的 UA 会更好。

不要对所有页面都注入脚本

风控页面本身会加载很多的 Iframe 用于跑验证脚本、出验证码之类的,这些东西都会触发页面加载事件。通过 cdp 我们可以直接看到这些页面:

Just a moment...
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=challenge&id=04nubo7q8lgo&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=
false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=checkbox&id=04nubo7q8lgo&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=challenge&id=12ks6yz7fbwp&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=checkbox&id=12ks6yz7fbwp&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethos
t=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com

在对目标页面加载脚本时,尽量不要对那些校验用的中间页面进行 hook。原先我们有一个对所有加载的页面都打印一下日志的兜底脚本(没有做任何其他事情),本以为没啥影响,结果测试了很久发现,这个兜底脚本的存在极大影响了页面加载的成功率。具体原因未知,但是理论上的确是有办法能够检测到。

尽量提高页面加载速度

Did you know some signs of bot malware on your computer are computer crashes, slow Internet, and a slow computer?

大数据表明,越慢的网络环境越有可能是危险的。各种 Hook 处理、多层代理、抓包解包等都会影响网络响应速度,因此一定要想办法提高。

目前我们采用的抓包代理技术比较老旧,解包太慢了,导致一加抓包代理,页面打开的成功率就骤降。尤其是清除缓存后的浏览器首次打开时,由于缓存被干掉了,资源一起加载就会非常慢。因此我们在已有的抓包代理的基础上又套了一层静态资源缓存,实现对静态资源 Cache-Control 的处理,减少因清除缓存带来的速度变慢的问题。

实测后发现,加了静态资源缓存后,页面打开的成功率也有了明显提升。

改用指纹浏览器

上面的方法虽然基本上能让滑块或者验证码出来,但是仍然解决不了一直验证的问题。测试过很多浏览器之后发现,即使是本地正常的浏览器、正常的IP,很大概率也会出验证的。

但是偶然间发现了一款 GoLogin 指纹浏览器,在不做任何额外配置时、竟然能几乎100%通过 cloudflare 的盾,非常神奇(多次重试均可通过✅);但是在我加了一堆自以为是的配置之后,却又出验证了。看来爬虫浏览器改用指纹浏览器应当是一大趋势。不过目前市面上这类的浏览器基本都是闭源收费的,且价格一般都比较昂贵,只能说多多关注了。。。

有趣的东西

基于V8引擎特性的内核版本判断

通过 JS 校验 V8 引擎在不同 Chromium 内核版本下的特性,可以用来大致判断真实内核状态。如果和 UserAgent 声明的版本不一样,则会是一个非常负面的评价。

根据 V8 的版本说明文档,V8 的版本和 Chromium 版本是一一对应的:

说的很清楚,将 Chromium 的大版本号除以10,即得到了 V8 的大版本号。从 chrome://version 中也可以应证这一点(106.0.5249.103 -> 10.6.194.17):

Google Chrome 106.0.5249.103 (Official Build) (x86_64)
Revision 182570408a1f25ab2731ef5f283b918df9b9f956-refs/branch-heads/5249_91@{#6}
OS macOS Version 12.6 (Build 21G115)
JavaScript V8 10.6.194.17
User Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36
Command Line /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --flag-switches-begin --flag-switches-end
Executable Path /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

有了这个对应关系,我们就可以通过测试 V8 语言在不同版本下的语言特性,来推断当前 Chromium 内核的版本。我们可以在 V8 的 Features 页查看到 V8 的 Javascript api 各种新特性,选择其中一个希望测试的特性,再去 V8 的 Blog 页查看其对应的版本,从而对 Chromium 内核版本进行甄别。

例如,我们发现 V8 在 9.3 版本新增了一个 Object.hasOwn 方法,那么在 Chromium 内核高于 93 的机器上,下面的 Javascript 就会正常执行:

Object.hasOwn.toString()
'function hasOwn() { [native code] }'

而在内核低于 93 的机器上,下面的 Javascript 就会报错:

Object.hasOwn.toString()
Uncaught TypeError: Cannot read property 'toString' of undefined
at <anonymous>:1:15

通过测试足够多的特征,我们就可以很轻松的精确判断当前浏览器实际的内核版本。

当然,这里直接通过 toString() 来判断函数是否存在的方法比较粗暴,很容易绕过。实际上可以多设计一些测试用例,甚至可以在服务端随机生成测试用例,再将客户端返回的结果与服务端的进行对比,这样即使通过数据抓包、JS 逆向,也挺难处理分析的。

hCaptcha基于指纹和行为打分

和 reCatpcha 类似,hCaptcha 在提供验证服务的同时,也会给后端服务返回当前校验结果的“打分”,用于评判当前的用户时真人或是机器的概率。因此并不是说点对了验过码图片就能100%保证通过风控。

一般来说,这类验证码服务几乎都会通过 JS 收集浏览器的基本信息(尤其是 window.screen 和 window.navigor 下的所有参数),以及用户的行为信息(鼠标的移动序列),传回 hCaptcha 服务端;服务端经过大数据计算后返回类似如下接口的数据给业务后端,用于进行风控判断,这样业务后端就可以根据这个 score 来控制风控强度:

{
"success": true|false,     // is the passcode valid, and does it meet security criteria you specified, e.g. sitekey?
"challenge_ts": timestamp, // timestamp of the challenge (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
"hostname": string,        // the hostname of the site where the challenge was solved
"credit": true|false,      // optional: whether the response will be credited
"error-codes": [...]       // optional: any error codes
"score": float,            // ENTERPRISE feature: a score denoting malicious activity.
"score_reason": [...]      // ENTERPRISE feature: reason(s) for score.
}

本来我也想测试下自己的定制浏览器的实际得分,奈何这个 score 只对企业级用户开放,普通开发者是拿不到的。

寻找了一圈倒是发现 reCaptcha 有类似的 demo ,不过仔细一看,他返回的 score 也只是个 demo ,不具参考意义。

🔲 ☆

利用 Redsocks 解决透明代理的远程抓包问题

背景

最近在做一些远程设备的抓包能力建设。具体来说是设备(基于 Docker 的 Redroid 云手机)在服务器上,抓包工具在本地( Mitmproxy , Charles, Fiddler 等类似工具),希望通过某种方法将远程设备上的流量打到本地的抓包工具上,并且流量通过本地的 IP 出到公网。

这样做的目的主要有两个:

  1. 可以做到抓包工具和待抓包设备的分离。既能利用上强大的第三方抓包工具,又无需本地部署待抓包的设备。毕竟如果在远程服务器上部署抓包工具,操作起来就不那么方便,甚至可能还需要自行开发管控界面;如果在本地部署待抓包设备,很可能会遇到例如芯片架构、操作系统、环境依赖、系统资源消耗等问题。
  2. 可以方便进行 IP 出口的调整。在调试一些不可言说的功能时,服务器上部署的设备很可能是走一些代理 IP 池,有时候这些 IP 池本身可能有点问题,导致远程设备被封。这时候如果我们能够将设备的流量导到本地的正常 IP 出公网,可能会更方便我们验证是 IP 问题还是其他的设备问题。

计划

为了打通远程设备本地抓包的这套链路,我们需要考虑如下技术点:

  1. 远程设备需要能够安装本地抓包工具的证书。
  2. 远程设备的流量需要通过某种内网穿透能力打到本地的代理工具上。
  3. 远程设备的流量需要保证不遗漏地进行转发。

由于我们的远程设备是有 root 权限的云手机,因此证书安装并不难。只要将本地的证书通过 openssl 命令转换成指定格式的证书文件,传到服务器上,在云手机启动时 bind 到 /system/etc/security/cacerts/  目录下即可。

同理,由于远程设备是云手机,通过暴露 adb 的 tcpip 端口,我们可以用本地的 adb 客户端进行连接,再通过 adb reverse 就可以构建一个云手机访问本机代理端口的信道。

而要保证云手机的流量(这里特指 HTTP/HTTPS 流量)不遗漏的进行转发,我们就不能采用配置全局正向代理的方法( adb shell settings put global http_proxy xxxx  ),因为个别 app 可以配置强制 NO_PROXY 不走系统代理。一个简单的方法是通过云手机自带的 iptables 工具进行转发,将云手机中所有目的端口为 80/443 的流量转发到 adb reverse 命令转发过来的、映射到本地抓包工具的代理端口即可。

理想的架构图如下:

问题

架构图谁都会画,但是真正实操起来才发现有一堆坑。这套流程对 HTTP 请求的确是有用的,透明代理的工具无论是使用 Charles 还是 Mitmproxy 等中间人代理工具都能正常抓到包。但是对与 HTTPS 的流量则都出现了问题:

  • Charles 会报 invalid first line in request 的错。
  • Mitmproxy 会报 Could not resolve original destination 的错。

当然,个别代理工具可能不支持解 HTTPS ,出现问题可以理解。但是各种代理工具都不能抓,那显然应当是流程上出了问题。经过实验我们发现,使用正向代理或非 HTTPS 的透明代理再加上端口转发都是能通的,唯一不能通的是 HTTPS 的透明代理模式。那么我们就需要先辨析一下这些模式的区别。

分析

正向代理

正向代理是由客户端主动发起,主动将流量打给一个代理服务器,由代理服务器代替请求的过程。下图主要展示正向代理过程中 IP 报文的变化:

  • Alice 代表发起请求的客户端
  • Flank 代表代理服务器(Forward Proxy)
  • Bob 代表客户端需要请求到的服务器

透明代理

透明代理是客户端本身无感知的,由路由转发工具强行进行流量转发(Linux 下可以用 iptables ,Windows 下可以用 netsh ,Mac 下可以用 pfctl)。下图主要展示透明代理过程中 IP 报文的变化:

  • Alice 代表发起请求的客户端
  • Ivan 代表转发流量的路由工具(Iptables之类的工具)
  • Tom 代表透明代理服务器(Transparent Proxy)
  • Bob 代表客户端需要请求到的服务器

在透明代理模式下,路由工具会非常暴力地将客户端发来的包的目的地址直接改为透明代理服务器,这会导致当数据包到了透明代理服务器中时,代理服务器是无法直接获取客户端真正想要到达的服务器地址。而正向代理服务器则不同,客户端会明确告知代理服务器他想访问谁。

那么透明代理服务器要如何在报文中获得真实的目的地址呢?这时候就需要分情况讨论了。

HTTP

我们知道 HTTP 报文是纯明文,就像一个没有封口的信封。只要打开来看就会发现,HTTP 请求报文会在 Header 中带上一个 Host 头表明当前的信期望到达的地方。透明代理服务器可以非常方便地解析到这个信息,从而知道报文需要被发送到的目的地址(Bob)。

HTTPS

HTTPS 这里就比较尴尬了。我们知道 HTTPS 在第四层有一个 TLS 加密层,如果想和 HTTP 一样从 Header 中获取 Host 头的话,则需要先进行 TLS 解密;但是,如果想进行 TLS 解密,则必须和实际的服务器进行 TLS 握手;可是你都不知道实际的服务器在哪,如何握手呢?这竟然变成了一个鸡生蛋还是蛋生鸡的问题。

那么问题最终会怎么解决呢?目光还得回到路由工具。

既然报文是你路由工具传给透明代理的,那显然路由工具这边是记录了报文原先实际需要访问的目的地址的,我直接请求你不就好了么?在 Linux 下,我们有一个用户工具 conntrack 可以展示当前网络连接的链路追踪信息:

$ sudo conntrack -L
tcp      6 60 TIME_WAIT src=192.168.32.1 dst=192.168.32.3 sport=40298 dport=8080 src=192.168.32.3 dst=192.168.32.1 sport=8080 dport=40298 [ASSURED] mark=0 use=1
tcp      6 56 TIME_WAIT src=192.168.32.1 dst=192.168.32.7 sport=54398 dport=2368 src=192.168.32.7 dst=192.168.32.1 sport=2368 dport=54398 [ASSURED] mark=0 use=1
tcp      6 60 TIME_WAIT src=192.168.32.5 dst=172.17.0.1 sport=52992 dport=5001 src=172.17.0.1 dst=192.168.32.5 sport=5001 dport=52992 [ASSURED] mark=0 use=1
tcp      6 79 TIME_WAIT src=10.0.0.4 dst=168.63.129.16 sport=39414 dport=80 src=168.63.129.16 dst=10.0.0.4 sport=80 dport=39414 [ASSURED] mark=0 use=1

而透明代理程序则可以通过 getsockopt 等方法直接向内核查询 socket 的链路信息:

static int getdestaddr(int fd, struct sockaddr_storage *destaddr)
{
    socklen_t socklen = sizeof(*destaddr);
    int error         = 0;

    error = getsockopt(fd, SOL_IPV6, IP6T_SO_ORIGINAL_DST, destaddr, &socklen);
    if (error) { // Didn't find a proper way to detect IP version.
        error = getsockopt(fd, SOL_IP, SO_ORIGINAL_DST, destaddr, &socklen);
        if (error) {
            return -1;
        }
    }
    return 0;
}

这样一来,透明代理服务就能在 TLS 握手前就能拿到真实的目的 IP 了。

然而有了 IP 就够了么?并不是。路由工具中我们只能拿到目的 IP ,但是并没有域名!现代服务端的网关层基本都需要通过域名来进行转发。如何解决这个问题呢?这就引入了 SNI (Server Name Indication)头。在 TLS 握手阶段,客户端会在握手报文里额外增加 SNI 信息(这个已经是TLS标准了,但是有些客户端可能没有加),这样服务端或者透明代理服务器就能获取到实际的域名了。

最后我们再来看下 Mitmproxy 文档中提供的透明代理的流程图:

解决

了解了代理服务器的工作细节,我们再来尝试回答下开头的问题。为什么把远端的 HTTPS 流量通过路由工具+端口映射转发到本地的透明代理服务器中会报错呢?

答案已经很明显了,那就是本地的透明代理工具无法查询到远端服务器中的路由链路信息,导致无法获取真实的目的地址。(也就是为什么 mitmproxy 会报 Could not resolve original destination的原因。

解决这个问题的思路也很清晰,既然希望走透明代理的流量无法跨主机,那我们就将透明代理服务部署在本机,然后转换成正向代理出去即可

具体操作中,由于 mitmproxy 默认不支持在透明代理模式下再配置一个上游正向代理,因此我们可以选择简单魔改一下代码,在透明代理模式下的出口处请求另一个正向代理。

如果懒得改代码,我找到了一个 Redsocks 工具。这个工具可以直接作为透明代理服务器,并将流量转发给一个上游的正向代理。Ubuntu 下可以直接用 apt 安装,配置一下 /etc/redsocks.conf 就能直接使用。

如此,架构图修改成下图即可:

稳定性

方案上线后,原先预计 redsocks 可能会对系统稳定性造成影响,后来发现这一块还好,反倒是由于 mitmproxy 从透明代理模式切到正向代理/上行代理模式,导致 mitmproxy 经常 crash,参见 issue ,略坑。

参考资料

Mitmproxy Docs - Transparent HTTPS

如何使用透明代理抓 HTTPS

Istio的流量劫持和Linux下透明代理实现

🔲 ⭐

IEEE论文爬虫及数据统计

1. IEEE论文爬虫

爬虫代码网上有很多了,这部分是直接用的网上可以跑通的[1]。使用的时候直接调用get_article_info(),其中参数 conferenceID需要手动在 IEEE 上查询会议的 ID 号,参数 saceFileName为希望保存的 csv 文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# 获取issueNumber
def get_issueNumber(conferenceID):
"""
Get the issueNumber from the website.
"""
conferenceID = str(conferenceID)
gheaders = {
'Referer': 'https://ieeexplore.ieee.org/xpl/conhome/'+conferenceID+'/proceeding',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36'
}
md_url = 'https://ieeexplore.ieee.org/rest/publication/home/metadata?pubid='+conferenceID
md_res = requests.get(md_url, headers = gheaders)
md_dic = json.loads(md_res.text)
issueNumber = str(md_dic['currentIssue']['issueNumber'])
return issueNumber

# 爬取论文及其下载链接
def get_article_info(conferenceID, saveFileName):
"""
Collect the published paper data, and save into the csv file "saveFileName".
"""
# 获取issueNumber
issueNumber = str(get_issueNumber(conferenceID))
conferenceID = str(conferenceID)

# 记录论文数据
dataframe = pd.DataFrame({})
paper_title = []
paper_author = []
paper_year = []
paper_citation = []
paper_abstract = []
paper_ieee_kwd = []

# 从第一页开始下载
pageNumber = 1
count = 0
while(True):
# 获取会议文章目录
toc_url = 'https://ieeexplore.ieee.org/rest/search/pub/'+conferenceID+'/issue/'+issueNumber+'/toc'
payload = '{"pageNumber":'+str(pageNumber)+',"punumber":"'+conferenceID+'","isnumber":'+issueNumber+'}'
headers = {
'Host': 'ieeexplore.ieee.org',
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36',
'Referer': 'https://ieeexplore.ieee.org/xpl/conhome/'+conferenceID+'/proceeding?pageNumber='+str(pageNumber),
}
toc_res = requests.post(toc_url, headers = headers, data=payload)
toc_dic = json.loads(toc_res.text)
try:
articles = toc_dic['records']
except KeyError:
break
else:
for article in articles:
title = article['highlightedTitle']
paper_link = IEEE_root_url + article['htmlLink']
paper_info = requests.get(url=paper_link, headers=headers, timeout=10)
soup = BeautifulSoup(paper_info.text, 'lxml') # 解析
# 正则表达式 创建模式对象
pattern = re.compile(r'xplGlobal.document.metadata=(.*?)"};', re.MULTILINE | re.DOTALL)
script = soup.find("script", text=pattern) # 根据模式对象进行搜索
try:
res_dic = pattern.search(script.string).group(1)+'"}' # 配合search找到字典,匹配结尾字符串,降低文章摘要中也出现这种字符串的概率
# 解析异常,一般是因为文章 abstract 中出现了字符串 '"};'
json_data = json.loads(res_dic) # 将json格式数据转换为字典
except Exception as e:
print(pattern.search(script.string).group(0))
print(res_dic)
# 保存文章信息
paper_title.append(title)
paper_year.append(json_data['publicationYear'])
print(json_data.keys())
#a = input('input anything...')
if 'author' in json_data.keys():
paper_author.append(json_data['author'])
else:
paper_author.append(None)
if 'abstract' in json_data.keys():
paper_abstract.append(json_data['abstract'])
else:
paper_abstract.append(None)
if 'keywords' in json_data.keys():
paper_ieee_kwd.append(json_data['keywords'][0]['kwd']) # ieee有三种 key words
else:
paper_ieee_kwd.append(None)
count=count+1
#link = 'https://ieeexplore.ieee.org/stampPDF/getPDF.jsp?tp=&arnumber='+article['articleNumber']+'&ref='
#alf.write(title.replace('\n','')+'>_<'+link+'\n')

# 写入csv文件
dataframe = pd.DataFrame({'title':paper_title, 'year':paper_year, 'abstract':paper_abstract, 'key words':paper_ieee_kwd})
dataframe.to_csv(saveFileName, index=True, sep=',')
print('Page ', pageNumber, ', total ', count, 'papers.')
pageNumber = pageNumber+1
# 停一下防禁ip
import time
time.sleep(3)

# 写入csv文件
dataframe = pd.DataFrame({'title':paper_title, 'year':paper_year, 'abstract':paper_abstract, 'key words':paper_ieee_kwd})
dataframe.to_csv(saveFileName, index=True, sep=',')
return

2. IEEE论文数据统计

3. 写一个图形界面

3.1 弹出提示窗口

在写代码过程中有时候需要测试功能是否成功实现,于是想要加一个弹出窗口的函数可以显示调试信息,用以验证想要的功能是否正常实现。主要难点在于根据内容自动调整窗口大小,以获得较好的显示效果。

采用的方法是利用 QLabel.adjust()函数获取文本显示的宽度,并据此调整窗口的大小[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from PyQt6.QtWidgets import (QWidget, QDialog, QLabel, QPushButton)
from PyQt6.QtCore import (QSize, QRect)

class PaperCollector(QWidget):
def __init__(self):
super().__init__()
self.initUI()

def initUI(self):
self.dialog_btn = QPushButton('Click')
self.dialog_btn.clicked.connect(self.click_callback)
self.setGeometry(300, 300, 300, 200)
self.setWindowTitle('IEEE paper collector (by Glooow)')
self.show()

def click_callback(self):
self.show_dialog('You clicked me!')

def show_dialog(self, info):
"""
Pop up dialogs for debug.
"""
hint_dialog = QDialog()
hint_dialog.setWindowTitle('Hint info')
#hint_dialog.setWindowModality(PyQt6.QtCore.Qt.NonModal)

hint_info = QLabel(info, hint_dialog)
hint_info.adjustSize()
padding = 20
max_width = 360
# set the maximum width
if hint_info.size().width() > max_width:
hint_info.setGeometry(QRect(0, 0, max_width, 80))
hint_info.setWordWrap(True)
hint_info.move(padding, padding)

hint_dialog.resize(hint_info.size() + QSize(padding*2, padding*2))
hint_dialog.exec()

3.2 文本框显示爬取日志

我希望在窗口中增加一个文本框,将爬取过程中的日志信息打印出来,便于用户实时监测。

采用的思路是定义一个logging.Logger,将其日志信息同时输出到窗口的文本框和控制台中打印,通过自定义logging.Handler可以实现这一功能[3][5][6]。实现方式为:

  1. 继承 logging.Handler类,并初始化阶段将整个窗口(QWidget类)作为参数传入,便于后续修改窗口的信息;

  2. 自定义实现 emit 函数,在 emit 函数中将log 信息同时输出到窗口文本框、打印到控制台;

  3. 创建 logger 的时候设置Handler[4]

    1
    2
    3
    4
    ex = PaperCollector()
    logger = logging.getLogger("logger")
    handler = LogHandler(ex)
    logger.addHandler(handler)

下面是这部分功能相关的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import logging

class LogHandler(logging.Handler):
def __init__(self, parent):
super().__init__()
self.parent = parent

def emit(self, record):
try:
print(self.format(record))
self.parent.print_log(self.format(record))
QApplication.processEvents()
except Exception:
self.handleError(record)

class PaperCollector(QWidget):
def __init__(self):
super().__init__()
self.initUI()

def initUI(self):
"""
Define the UI playout.
"""
# button to start crawing
self.startCrawling_button = QPushButton('Start')
self.startCrawling_button.setToolTip('Click and wait for collecting published paper data.')
self.startCrawling_button.clicked.connect(self.start_collect_paper)
# print log
self.process = QTextEdit(readOnly=True)
self.process.setFont(QFont("Source Code Pro",9))

grid = QGridLayout()
grid.setSpacing(10)
grid.addWidget(self.startCrawling_button, 1, 0)
grid.addWidget(self.process, 2, 0, 3, 3)
self.setLayout(grid)

self.setGeometry(300, 300, 700, 300)
self.setWindowTitle('IEEE paper collector (by Glooow)')
self.show()

def start_collect_paper(self):
global logger
#self.show_dialog('start!')
get_article_info(self.conferenceID_edit.text(), self.saveFile_edit.text(), logger)

def print_log(self, s):
self.process.append(s)

logger = None

def main():
app = QApplication(sys.argv)
ex = PaperCollector()

global logger
logger = logging.getLogger("logger")
logger.setLevel(logging.INFO)
formater = logging.Formatter(fmt="%(asctime)s [%(levelname)s] : %(message)s"
,datefmt="%Y/%m/%d %H:%M:%S")
handler = LogHandler(ex)
handler.setFormatter(formater)
logger.addHandler(handler)

sys.exit(app.exec())


if __name__ == '__main__':
main()

爬取论文的主函数如下,其中一个参数为logger,在函数内部需要打印日志信息的地方添加logger.info(...) 即可。

1
2
def get_article_info(conferenceID, saveFileName, logger):
logger.info('collecting paper......')

3.3 多线程避免卡顿

上述打印日志的方法不能做到实时输出信息到窗口文本框,而是会等到所有论文爬取完毕之后再一股脑的更新,这是因为PyQt的界面线程是主线程,当爬虫开始工作时,也是运行在主线程中,这时主界面就无法更新,看起来就像是卡死了。解决方法就是开一个子线程运行爬虫工作<spanclass="hint--top hint--rounded" aria-label="PyQt- 使用多线程避免界面卡顿 - bailang zhizun的博客 - CSDN博客">[7]

具体实现细节为:

  1. 新建类 SpiderThread 继承 QObject,自定义run 函数,在其中运行爬虫程序;
  2. SpiderThread 类中定义一个_spider_finish = pyqtSignal(),该信号用于告知主线程爬虫子线程已完成工作
  3. PaperCollector 类中定义一个_start_spider = pyqtSignal(str, str, logging.Logger),该信号用于启动爬虫子线程[8][9]
  4. 通过 pyqtSignal.connect分别将各个信号连接到对应的槽(处理函数)上;

下面是这部分功能相关的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from PyQt6.QtCore import (QObject, pyqtSignal, QThread)

class SpiderThread(QObject):
_spider_finish = pyqtSignal()

def __init__(self):
super().__init__()
self.flag_running = False

def __del__(self):
print('>>> __del__')

def run(self, conference_ID, save_filename, logger):
get_article_info(conference_ID, save_filename, logger)
self._spider_finish.emit()

class PaperCollector(QWidget):
_start_spider = pyqtSignal(str, str, logging.Logger)

def __init__(self):
super().__init__()
self.initUI()
#sys.stdout = LogStream(newText=self.onUpdateText)

self.spiderT = SpiderThread()
self.thread = QThread(self)
self.spiderT.moveToThread(self.thread)
self._start_spider.connect(self.spiderT.run) # 只能通过信号槽启动线程处理函数
self.spiderT._spider_finish.connect(self.finish_collect_paper)

def start_collect_paper(self):
if self.thread.isRunning():
return

self.startCrawling_button.setEnabled(False)
self.startCrawling_button.setToolTip('I\'m trying very hard to collect papers >_<')
# 先启动QThread子线程
self.thread.start()
# 发送信号,启动线程处理函数
# 不能直接调用,否则会导致线程处理函数和主线程是在同一个线程,同样操作不了主界面
global logger
self._start_spider.emit(self.conferenceID_edit.text(), self.saveFile_edit.text(), logger)

def finish_collect_paper(self):
self.startCrawling_button.setEnabled(True)
self.startCrawling_button.setToolTip('Click and wait for collecting published paper data ^o^')
self.thread.quit()

def stop_collect_paper(self):
if not self.thread.isRunning():
return
self.thread.quit() # 退出
self.thread.wait() # 回收资源
self.show_dialog('stop!')

3.4 流畅中止子线程

有时候我们需要中途停止爬虫工作,比如发现会议ID设置错误、希望先对已经爬取的部分数据进行统计分析等。在上面的实现中,尽管线程正常运行很流畅,但是如果在爬虫运行中途点击停止按钮,程序就会卡死。

在原本的爬虫脚本中,get_article_info()函数内部的爬虫采用了 while(True) 死循环,主线程中直接用self.thread.quit()强制退出,从控制台来看这样确实可以停掉,但是Qt窗口却总是会卡死。原因我也不太清楚,采用的解决方法是:

  1. 定义一个爬虫类 IEEESpider,设置成员变量flag_running,将函数 get_article_info也设置为类成员函数;
  2. get_article_info 中的循环改为while(self.flag_running)
  3. 在主线程中想要停止爬虫子线程的时候,只需要首先设置flag_running=False,那么爬虫子线程在当前一次循环结束后就自动结束,这个时候主线程调用self.thread.quit() 就不会导致界面卡死。需要注意的是设置flag_running=False 一定要 sleep一段时间,以保证爬虫子线程能够结束当前循环,否则还是容易卡死。

下面是这部分功能的代码。

1
2
3
4
5
6
7
class IEEESpider:
def __init__(self):
self.flag_running = False

def get_article_info(self, conferenceID, saveFileName, logger):
while(self.flag_running):
pass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SpiderThread(QObject):
def __init__(self):
super().__init__()
#self.flag_running = False
self.ieee_spider = IEEESpider()

def run(self, conference_ID, save_filename, logger):
self.ieee_spider.flag_running = True
self.ieee_spider.get_article_info(conference_ID, save_filename, logger)
self._spider_finish.emit()

class PaperCollector(QWidget):
def stop_collect_paper(self):
if not self.thread.isRunning():
return
self.spiderT.ieee_spider.flag_running = False
time.sleep(15)
self.thread.quit() # 退出
#self.thread.wait() # 回收资源
self.show_dialog('stop!')

3.5 增加侧边导航栏

前面只有爬取论文的页面,现在我想加上数据分析的页面,那么就需要设置一个侧边导航栏,以切换两种不同的任务。

实现方式为左侧设置多个按钮,右侧添加一个QTabWidget(),将不同的页面设置为子标签页,通过按钮的点击回调函数切换不同的标签页[10]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class PaperCollector(QWidget):
def sidebarUI(self):
"""
Define the UI playout of sidebar.
"""
self.sidebar_btn_1 = QPushButton('Collector', self)
self.sidebar_btn_1.clicked.connect(self.sidebar_button_1)
self.sidebar_btn_2 = QPushButton('Analyzer', self)
self.sidebar_btn_2.clicked.connect(self.sidebar_button_2)
self.sidebar_btn_3 = QPushButton('Reserved', self)
self.sidebar_btn_3.clicked.connect(self.sidebar_button_3)

sidebar_layout = QVBoxLayout()
sidebar_layout.addWidget(self.sidebar_btn_1)
sidebar_layout.addWidget(self.sidebar_btn_2)
sidebar_layout.addWidget(self.sidebar_btn_3)
sidebar_layout.addStretch(5)
sidebar_layout.setSpacing(20)

self.sidebar_widget = QWidget()
self.sidebar_widget.setLayout(sidebar_layout)

def sidebar_button_1(self):
self.right_widget.setCurrentIndex(0)

def sidebar_button_2(self):
self.right_widget.setCurrentIndex(1)

def sidebar_button_3(self):
self.right_widget.setCurrentIndex(2)

def initUI(self):
"""
Define the overall UI playout.
"""
self.sidebarUI()
self.spiderUI()
self.analyzerUI()
self.reservedUI()

# 多个标签页
self.right_widget = QTabWidget()
self.right_widget.tabBar().setObjectName("mainTab")

self.right_widget.addTab(self.spider_widget, '')
self.right_widget.addTab(self.analyzer_widget, '')
self.right_widget.addTab(self.reserved_widget, '')

# 隐藏标签部件的标签并初始化显示页面
self.right_widget.setCurrentIndex(0)
self.right_widget.setStyleSheet('''QTabBar::tab{width: 0; height: 0; margin: 0; padding: 0; border: none;}''')

# overall layout
main_layout = QHBoxLayout()
main_layout.addWidget(self.sidebar_widget)
main_layout.addWidget(self.right_widget)
main_layout.setStretch(0, 40)
main_layout.setStretch(1, 200)
self.setLayout(main_layout)

self.setGeometry(300, 300, 850, 300)
self.setWindowTitle('IEEE paper collector (by Glooow)')
self.show()

3.6 next ...

接下来考虑:写数据分析页面 ......

Referencce

这里关于参考文献的部分,本来我想按照下面格式来写,希望实现的效果是都像[2][10]一样,每一条引用列出来的是超链接,而不是直接写出来链接地址,但是我发现除了第[2][10]条,其他条这么写话都会像现在的第<spanclass="hint--top hint--rounded" aria-label="PyQt- 使用多线程避免界面卡顿 - bailang zhizun的博客 - CSDN博客">[7]条一样,格式会乱,也不知道为什么。有人知道的话可以告诉我嘛>_<

1
2
3
4
5
6
7
8
9
10
[^1]:[Python爬虫——爬取IEEE论文 - 乐 ShareLe的博客 - CSDN博客](https://blog.csdn.net/wp7xtj98/article/details/112711465)
[^2]:[PyQt 中文教程 (gitbook.io)](https://maicss.gitbook.io/pyqt-chinese-tutoral/)
[^3]:[python日志:logging模块使用 - 知乎](https://zhuanlan.zhihu.com/p/360306588)
[^4]:[python3 自定义logging.Handler, Formatter, Filter模块 - 太阳花的小绿豆的博客 - CSDN博客](https://blog.csdn.net/qq_37541097/article/details/108317762)
[^5]:[python logging output on both GUI and console - stackoverflow](https://stackoverflow.com/questions/41176319/python-logging-output-on-both-gui-and-console)
[^6]:[How to dynamically update QTextEdit - stackoverflow](https://stackoverflow.com/questions/24371274/how-to-dynamically-update-qtextedit)
[^7]:[PyQt - 使用多线程避免界面卡顿 - bailang zhizun的博客 - CSDN博客](https://blog.csdn.net/bailang_zhizun/article/details/109240670)
[^8]:[pyqt 带单个参数/多个参数信号&槽总结 - gong xufei的博客 - CSDN博客](https://blog.csdn.net/gong_xufei/article/details/89786272)
[^9]:[PyQt5 pyqtSignal: 自定义信号传入的参数方法 - Mic28的博客 - CSDN博客](https://blog.csdn.net/qq_39560620/article/details/105711799)
[^10]:[PyQt5 侧边栏布局 • Chang Luo (luochang.ink)](https://www.luochang.ink/posts/pyqt5_layout_sidebar/)
  1. https://blog.csdn.net/wp7xtj98/article/details/112711465↩︎
  2. PyQt中文教程 (gitbook.io)↩︎
  3. https://zhuanlan.gitbook.io/p/360306588↩︎
  4. https://blog.csdn.net/qq_37541097/article/details/108317762↩︎
  5. https://stackoverflow.com/questions/41176319/python-logging-output-on-both-gui-and-console↩︎
  6. https://stackoverflow.com/questions/24371274/how-to-dynamically-update-qtextedit↩︎
  7. PyQt- 使用多线程避免界面卡顿 - bailang zhizun的博客 - CSDN博客↩︎
  8. https://blog.csdn.net/gong_xufei/article/details/89786272↩︎
  9. https://blog.csdn.net/qq_39560620/article/details/105711799↩︎
  10. PyQt5侧边栏布局 • Chang Luo (luochang.ink)↩︎
🔲 ⭐

快手抓包问题分析

背景

不知从什么版本后,对快手进行简单抓包似乎“不可行”了。表现就是使用常规的 HTTP 正向代理抓包工具(charlesmitmproxyfiddler 之类)并且把自签名证书种到系统证书里后,数据依然能刷出来,也能抓到一些零星的报文,但是关键出数据的那些接口报文都没有。

一般来说,常规方法无法抓安卓应用的 https 包,通常有以下几种可能:

  1. 证书信任问题。在 Android 7 以上,应用会默认不信任用户证书,只信任系统证书,如果配置不得当则是抓不到包的。
  2. 应用配置了 SSL pinning,强制只信任自己的证书。这样一来由于客户端不信任我们种的证书,因此也无法抓包。
  3. 应用使用了纯 TCP 传输私有协议(通常也会套上一层 TLS)。由于甚至都不是 HTTP 协议,当然就抓不到包了。
  4. 应用使用 WebSocket 长链接,将不同的接口封装在这个长链接里。在 WebSocket 里承载的协议一般是用某种自定义方式来模拟 http 请求,因此也难以抓包。
  5. 应用使用了基于 UDP 的 http3.0协议等。大部分代理工具目前还不支持 quic 等协议,所以这样一般是抓不到包的。
  6. 应用配置了 Proxy.NO_PROXY ,强制不走系统代理。这样http流量就绕过我们配置的代理,自然抓不到包。

当前的现象是数据能刷出来,那就说明并不是证书信任相关的问题。接下来就需要验证它究竟是使用了什么样的传输方式,对症下药。

最稳妥的验证方式当然是白盒测试看源码,不过快手反编译的代码看起来也费劲,于是考虑直接当成黑盒来测试看看。以下验证方式均以 快手 8.2.31.17191 版本为例。

分析

环境部署

准备自签名CA证书

需要在 Linux 主机上使用 openssl 工具生成一波证书。当然,这一步可以忽略,直接使用mitmproxy生成的证书。只是手动配置一下能够加深一下对 openssl 的理解。

# 生成RSA私钥 cert.key 
openssl genrsa -out cert.key 2048

# 生成有效期为15年的X509证书 cert.crt(DN信息随便填填似乎都行,但是不能全空)
openssl req -new -x509 -key cert.key -out -days 5480 cert.crt

# 私钥和证书放一起,构成PEM格式的 cert.pem
cat cert.key cert.crt > cert.pem

# 将之前生成的pem证书作为mitmproxy使用的根证书
cp cert.pem ~/.mitmproxy/mitmproxy-ca.pem

# 以普通正向代理模式启动在8000端口,并配合curl简单验证证书可用性
mitmproxy -p 8000 

# 能正常访问没有SSL相关报错,就说明之前生成的自签名证书是OK的
curl -x localhost:8000 --cacert ~/.mitmproxy/mitmproxy-ca.pem https://www.baidu.com

# 从证书文件中计算出用于放置在安卓中的文件hash名,假设结果为 a5176621 
openssl x509 -subject_hash_old -in cert.crt -noout

# 将cert.crt复制出一个上述hash结果的新文件
cp cert.crt a5176621.0

值得注意的是,不要尝试使用 mitmproxy --certs 来配置证书,这种方式只能配置 leaf 证书,而不能配置根 CA 证书。因此还是老老实实的把根证书放在默认路径下。

准备设备

为了方便测试,我在 arm 服务器上使用 redroid 准备了一台安卓虚拟机。

docker run -itd --rm --memory-swappiness=0 \
--privileged --name redroid \
--mount type=bind,source=/home/myths/exp/a5176621.0,target=/system/etc/security/cacerts/a5176621.0 \
-p 5555:5555 \
redroid/redroid:11.0.0-arm64 \
redroid.width=720 \
redroid.height=1280 \
redroid.fps=15 \
redroid.gpu.mode=guest

其中 /home/myths/exp/a5176621.0 替换成实际文件所在路径。

然后在arm主机上用 adb 连接安卓的 tcpip 端口,下载并安装快手 8.2.31.17191 版本。

# 在arm服务器上用adb连接安卓虚拟机
adb connect localhost:5555

# 安装快手
adb install kuaishou.apk

为了方便远程操作,需要在本地机器上连接 arm 服务器上的安卓虚拟机,并用scrcpy操作。

# 在本地主机上连接远程arm服务器上的安卓虚拟机
adb connect <ip for arm server>:5555

# 启动scrcpy
scrcpy

到这一步骤时,可以检测安卓中的网络应该都已经是通的了。

复现抓包问题

先尝试使用传统正向代理的方式进行抓包。

# 在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 对安卓设置正向代理,其中 172.17.0.1 为安卓下访问arm主机的ip
adb shell settings put global http_proxy 172.17.0.1:8000

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,返回也正常,但是mitmproxy只能抓到一些静态资源,无法抓到接口。

Ban掉不走代理的所有流量

有数据但是抓不到包,怀疑应当是有些流量漏掉了,于是尝试把这些流量Ban掉看看效果。

# 首先需要打开内核的 ip_forward 功能
echo 1 > /proc/sys/net/ipv4/ip_forward

# 依然在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 继续在手机上配置http代理,其中172.17.0.1为安卓下访问arm主机的ip
adb shell settings put global http_proxy 172.17.0.1:8000

# 在 arm 服务器上配置iptables,将来源于安卓虚拟机但目的地不是arm服务器的流量重定向到一个无用端口。
# 其中 172.17.0.12 位安卓虚拟机的ip,1234为一个无用端口。
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12 ! -d 172.17.0.1 -j REDIRECT --to-ports 1234
# 如果上述操作报了 Couldn't load match `owner':No such file or directory 的错,那么需要在 arm 服务器上启动下相关模块。
sudo modprobe ipt_owner

# 记得测试完成后,将上面这个记录干掉
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,显示“无网络连接“。

说明这里的确是有流量漏了,没有走正向代理,但是依然出了外网。

Ban掉不走代理的443/80流量

那么这些流量到底是私有的四层TCP流量、还是没走正向代理的80/443流量呢?因此尝试把非80/443的流量放开试试。

# 依然在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 继续在手机上配置http代理
adb shell settings put global http_proxy 172.17.0.1:8000

# 在 arm 服务器上配置iptables,将来源于安卓虚拟机但目的端口不是80/443的流量重定向到一个无用端口。
# 其中 172.17.0.12 位安卓虚拟机的ip,1234为一个无用端口。
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12  --dport 80 -j REDIRECT --to-ports 1234
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12  --dport 443 -j REDIRECT --to-ports 1234

# 记得测试完成后,将上面这两条记录干掉
iptables -t nat -D PREROUTING 1
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,依然显示“无网络连接“。

这就说明,控制快手推荐页的流量并不是所谓私有流量,而就是走的80/443端口,只是没有走正向代理。

改用透明代理模式

既然七层的代理配置会被忽略,那就尝试使用四层的透明代理,将流量强制转到透明代理服务器上即可。

# 在arm服务器上以透明代理模式启动mitmproxy
mitmproxy -p 8000 -m transparent

# 将手机上的http代理移除
adb shell settings put global http_proxy :0

# 在arm服务器上配置将来源于安卓虚拟机的的80/443流量直接路由到mitmproxy
# 其中 172.17.0.12 位安卓虚拟机的ip
sudo iptables -t nat -I PREROUTING -p tcp  -s 172.17.0.12 --dport 80 -j REDIRECT --to-ports 8000
sudo iptables -t nat -I PREROUTING -p tcp  -s 172.17.0.12 --dport 443 -j REDIRECT --to-ports 8000

# 记得测试完成后,将上面这两个记录干掉
iptables -t nat -D PREROUTING 1
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,能成功刷出,并且mitmproxy也抓到了包。

总结

目前看来,快手并非像很多网上传的那样,大多数接口都走了 kquic 协议导致无法抓包。其实很多接口只是做了一个禁止走系统代理的设置,简单使用透明代理模式进行抓包即可轻松绕过。当然,不排除有些关键接口也做了SSL pinning、走私有协议之类的。。。

参考

https://docs.mitmproxy.org/stable/concepts-certificates/

https://stackoverflow.com/questions/56830858

https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/proxy/

❌