Chromium + Mitmproxy 组合使用踩坑

背景

众所周知,Chromium 目前是事实上的地表最强浏览器内核,Mitmproxy 是事实上地表最强的中间人代理工具。二者组合使用可以非常方便的进行控制与数据分离的自动化数据提取。不过在实际生产中大规模使用时,还是会或多或少的遇到了一些难以察觉的坑。。。

Mitmproxy 低版本长期运行易 OOM

现象

在容器中部署 chromium + mitmproxy 后,发现在多次访问某些类型网站时,mitmproxy 经常周期性地出现内存缓慢增长,直到超过 docker 限制而被 OOMKilled。虽然有 docker 的自动重启进程功能,但是总会不可避免的导致业务上网络连接的周期性断开。

分析

初步怀疑是流量本身过多(chromium 对 mitmproxy 是“多对一”)以及给 mitmproxy 分配的内存过低导致内存不足。于是尝试将 mitmproxy 的内存配额从 200MB 增长到 1G。

但是实际结果却是这只是延长了 OOM 的时间,并没有解决问题。于是考虑是出现了内存泄漏问题,但是业务脚本无论如何也排查不出问题,因此只能暂时用 docker 自动重启进程的功能保持服务的大致可用。

同时发现似乎在 chromium 中增加 --disable-http2 的启动参数后,内存泄漏的情况会有所缓解。

解决

经过一段时间,偶然回头一看才检索到 mitmproxy 有一个 #4786 的相关 issue。原来在较低版本中(8.0.0及以下),拦截的 HttpFlow 长连接对象的确存在连接泄漏导致内存不断膨胀直至 OOM 的问题。(这样一想强制关闭 http2 长连接的确有概率会降级到短连接,从而缓解长连接的 OOM 问题。)

这个问题终于在 8.1.0 版本得到了修复(CHANGELOG):

我们要做的就是直接使用最新稳定版的 mitmproxy 即可。不过这件事情也没有想象中的容易。

如果你的系统是 ubuntu:focal (20.04 LTS) 的版本,默认安装的 python3 版本应当是 3.8.x ,这时你会发现无论如何也装不上 mitmproxy@8.1.0 版本:

代码语言:javascript
复制
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.5 LTS
Release: 20.04
Codename: focal

$ python3 --version
Python 3.8.10

$ pip3 install mitmproxy==8.1.0
ERROR: Could not find a version that satisfies the requirement mitmproxy==8.1.0 (from versions: 0.8, 0.8.1, 0.9, 0.9.1, 0.9.2, 0.10, 0.10.1, 0.11, 0.11.1, 0.11.2, 0.11.3, 0.12.0, 0.12.1, 0.13, 0.14.0, 0.15, 0.18.1, 0.18.2, 0.18.3, 1.0.0, 1.0.1, 1.0.2, 2.0.0, 2.0.1, 2.0.2, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 4.0.0, 4.0.1, 4.0.3, 4.0.4, 5.0.0, 5.0.1, 5.1.0, 5.1.1, 5.2, 5.3.0, 6.0.0, 6.0.1, 6.0.2, 7.0.0, 7.0.1, 7.0.2, 7.0.3, 7.0.4, 8.0.0)
ERROR: No matching distribution found for mitmproxy==8.1.0

这里的关键是要记得去 pypi 上去看下 mitmproxy 对不同 python 版本的支持:8.0.0 的最低支持 python 版本是 3.8;而刚巧修复了 bug 的 8.1.0 的最低支持 python 版本就跳到了 3.9。于是这里又要继续升级 python3 到 3.9 以上。

这里又有两条路:要么需要在 20.04 的 ubuntu 里增加新的 python3.9 的源,把老的 python3.8 的相关数据清理干净,再安装新的 python3.9 ;要么直接升级到 jammy (22.04 LTS)。

经过一番尝试,发现在老的镜像里升级 python3.9 还是非常麻烦的,处理不好经常会残留一些老版本的库。于是我这里选择了直接将基础镜像换成了 ubuntu:22.04 。

全部升级完成后,正常运行的 mitmproxy 的内存占用基本都会维持在 100MB 左右了,还是非常稳定的。

Chromium 忽略证书校验会导致缓存失效

现象

原先的系统架构是先启动一个 mitmdump 服务监听 8888 端口,再使用一个基于 chromium 内核的浏览器,通过 --proxy-server=localhost:8888  将流量指向代理服务,再通过 --ignore-certificate-erros 参数忽略对 mitmdump 的自签名证书的校验,保证流量器正常访问。

同时为了减少图片、视频等带来的带宽损失,结合具体任务,在 mitmdump 的脚本里将视频、图片等相关的请求 drop 掉,保持对流量的高效利用。

本来这就是一个非常的朴素、透明、易理解的普通架构,线上也稳定运行了多年,没啥大的变动也没人想着改。不过近期业务流量逐渐大了起来,发现出口带宽有点撑不住了。于是增加了个对响应体的 Content-Type 监控,发现流量的大头竟然是 application/javascript 这一类的东西。

这显然不太合理,因为这些 javascript 资源理论上都是走的 cdn,数据都会带 Cache-Control 相关 header 方便浏览器进行本地缓存。在重复执行类似网页的时候,大概率应当会复用之前已经缓存好的 javascript 文件。

分析

仔细审查了一下正常浏览器请求和线上环境下请求的资源请求情况,果然发现了不同点。

本地环境:

线上环境:

可见本地环境的各种 javascript 资源在多次请求时都是要么命中了 memory cache ,要么命中了 disk cache,从而正常节省了流量。而 线上环境的各种 javascript 资源却只会命中 memory cache 而从未命中过 disk cache。

仔细对比了二者的环境下 chromium 的启动参数差别,多次实验后(需要注意每次实验之间一定要清空用户目录)终于发现区别只在于 本地环境没有使用 mitmproxy 抓包,而线上环境配置了mitmproxy抓包 。在本地环境下配置了 mitmproxy 抓包后终于复现了线上场景。

经过一番搜索,竟然在 MicrosoftEdge 的项目 issue #2634 里找到了对 chromium 问题的解释,具体原因可以参见 chromium 这里的解释:

Status: WontFix : The rule is actually quite simple: any error with the certificate means the page will not be cached.

没错,chromium 做了这样一个规定:证书错误的页面不会被持久化缓存,即使你配置了忽略证书校验。

解决

问题原因发现了,解决起来也就容易了。至少有两种方案可以处理:

  1. 在 mitmproxy 层基于 Http 的 Cache-Control 相关协议,自己实现一层静态资源的持久化缓存。
  2. chromium 不配置 --ignore-certificate-errors ,而是直接想办法将 mitmproxy 的证书种到 chromium 信任的 CA 里,保证对 TLS 流量的正常解析。

实测下来,二者都能很好地优化大并发任务下的网络请求。javascript 相关请求量近似跌零,整体的流量会减少 70% 以上。不过总体看下来,方案二处理起来更加便捷和稳妥。

Chromium 默认不信任 Linux 下的系统证书

现象

话接上一个问题的解决方案二,想将证书种到 chromium 中其实并不简单。一个 Ubuntu 下的通用种 mitmproxy 证书的方法是:

  1. HOME/.mitmproxy/mitmproxy-ca-cert.pem</code> 中拿到 mitmproxy 的默认证书;或者自己用 openssl 生成一对证书+私钥,并放在 mitmproxy 的相应位置下。</li><li>将上述的 <code>mitmproxy-ca-cert.pem</code>  复制到 <code>/usr/local/share/ca-certificates</code> 下,并重命名为 <code>mitm.crt</code>  (一定要以 crt 为后缀)。</li><li>执行 <code>update-ca-certificates</code> ,会自动将 mitm.crt 按证书信息重命名并软链接到 <code>/etc/ssl/certs</code> 中。</li></ol><p>这样搞完,例如 curl wget 等绝大多数应用就都能认得我们自签名的证书了。</p><p>可惜 chromium 不是这绝大多数,实测下来依然不信任我们已经种在系统 CA 里的自签名证书。</p><h4 id="ddvn9" name="%E5%88%86%E6%9E%90">分析</h4><p>其实不信任系统默认 CA 证书的事情也很常见,比如很多 App <strong>为了安全考虑</strong>会自己做 SSL Pinning,不信任用户机器上的证书;或者像 Java 这种工具<strong>为了跨平台的考虑</strong>也不会使用系统的证书,而是使用自己存储的 keystore。这里 Chromium 可能是也是出于类似考虑,反正也是默认只信任了自己安装时带过来的证书。对于用户新增的证书,也是希望直接通过软件本身的配置进行管理。</p><p>官方配置中添加自签名证书的方法是通过 <code>chrome://settings/certificates</code>  自行导入。</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722776726661032000.png" /></div></div></div></figure><p>不过显然,这中配置方式对于打镜像并不合适,我们还是要寻找通过配置文件进行配置的方案。</p><h4 id="emjhb" name="%E8%A7%A3%E5%86%B3">解决</h4><p>一番搜索后,从 superuser 中的这篇文章大概了解了 chromium 对自定义证书的管理方式。官方的说明是在 chromium 的 cert_management 文档中。</p><p>简而言之,Linux 下的 Chromium 使用的是公共 nssdb 来管理证书。数据存放在 <code>HOME/.pki/nssdb 下。

    如果这个目录不存在,那么在第一次打开 Chromium 时会自动创建。不过对于预构建的环境来说,这里还是需要自己事先初始化下的。

    具体步骤如下:

    代码语言:javascript
    复制
    $ mkdir -p ~/.pki/nssdb                                       # 准备路径和文件夹

    $ certutil -d ~/.pki/nssdb -N --empty-password # 初始化DB环境

    $ ls ~/.pki/nssdb/ # 查看DB文件
    cert9.db key4.db pkcs11.txt

    $ certutil -d ~/.pki/nssdb -L # 查看证书信息(目前为空)

    Certificate Nickname Trust Attributes
    SSL,S/MIME,JAR/XPI

    $ certutil -d ~/.pki/nssdb -A -t "C,," -n mitm -i ~/mitm.crt # 将准备好的证书导入进 CA

    $ certutil -d ~/.pki/nssdb -L # 查看导入后的证书信息

    Certificate Nickname Trust Attributes
    SSL,S/MIME,JAR/XPI

    mitm C,,

    正常情况下,这样处理是没有问题的,不过具体使用时,还是踩了一些坑。

    注意到 chromium 文档中给出的所有 nssdb 相关指令的 -d 参数和我上述用的有所不同,多带了一个 sql: 的前缀:

    代码语言:javascript
    复制
     certutil -d sql:HOME/.pki/nssdb -L

    这是因为在本地测试时,由于 bash 用习惯了,直接用 ~ 代替了 HOME 。结果命令敲出来结果就是这样:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0"> certutil -d sql:~/.pki/nssdb -L
    certutil: function failed: SEC_ERROR_BAD_DATABASE: security library: bad database.

    报了一个奇怪的错,想了半天没想明白问题出在哪,随手试了试将 sql: 前缀干掉,发现一切又都能 work 了,也就是我上述记录的命令。

    回头仔细看了下文档:

    代码语言:javascript
    复制
           -d [prefix]directory
    Specify the database directory containing the certificate and key database files.

           certutil supports two types of databases: the legacy security databases (cert8.db, key3.db, and secmod.db) and new
           SQLite databases (cert9.db, key4.db, and pkcs11.txt).
    
           NSS recognizes the following prefixes:
    
           o   sql: requests the newer database
    
           o   dbm: requests the legacy database
    
           If no prefix is specified the default type is retrieved from NSS_DEFAULT_DB_TYPE. If NSS_DEFAULT_DB_TYPE is not set
           then dbm: is the default.</code></pre></div></div><p>原来 nssdb 是有两种模式的,可以通过为 -d 参数加不同前缀指定。但是坑爹的是如果指定了前缀,似乎就无法识别 bash 下的 ~ 。。。因此这里要么不用 ~ 、改用完整路径,要么就不指定 db ,使用默认配置即可。</p><p>最后,这个 pki 的文件权限也要注意,开启 chromium 的用户一定要对这个目录有<strong>读写权限</strong>。一个稳妥的方法就是 <code>chown -R</code> 一下,保证用户权限没问题。</p><p>这样一番配置后,终于可以在 <code>chrome://settings/certificates</code> 下看到新增的自签名证书了。</p>