最近在做一些远程设备的抓包能力建设。具体来说是设备(基于 Docker 的 Redroid 云手机)在服务器上,抓包工具在本地( Mitmproxy , Charles, Fiddler 等类似工具),希望通过某种方法将远程设备上的流量打到本地的抓包工具上,并且流量通过本地的 IP 出到公网。
这样做的目的主要有两个:
为了打通远程设备本地抓包的这套链路,我们需要考虑如下技术点:
由于我们的远程设备是有 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 的流量则都出现了问题:
invalid first line in request
的错。Could not resolve original destination
的错。当然,个别代理工具可能不支持解 HTTPS ,出现问题可以理解。但是各种代理工具都不能抓,那显然应当是流程上出了问题。经过实验我们发现,使用正向代理或非 HTTPS 的透明代理再加上端口转发都是能通的,唯一不能通的是 HTTPS 的透明代理模式。那么我们就需要先辨析一下这些模式的区别。
正向代理是由客户端主动发起,主动将流量打给一个代理服务器,由代理服务器代替请求的过程。下图主要展示正向代理过程中 IP 报文的变化:
透明代理是客户端本身无感知的,由路由转发工具强行进行流量转发(Linux 下可以用 iptables ,Windows 下可以用 netsh ,Mac 下可以用 pfctl)。下图主要展示透明代理过程中 IP 报文的变化:
在透明代理模式下,路由工具会非常暴力地将客户端发来的包的目的地址直接改为透明代理服务器,这会导致当数据包到了透明代理服务器中时,代理服务器是无法直接获取客户端真正想要到达的服务器地址。而正向代理服务器则不同,客户端会明确告知代理服务器他想访问谁。
那么透明代理服务器要如何在报文中获得真实的目的地址呢?这时候就需要分情况讨论了。
我们知道 HTTP 报文是纯明文,就像一个没有封口的信封。只要打开来看就会发现,HTTP 请求报文会在 Header 中带上一个 Host 头表明当前的信期望到达的地方。透明代理服务器可以非常方便地解析到这个信息,从而知道报文需要被发送到的目的地址(Bob)。
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 ,略坑。