利用 Redsocks 解决透明代理的远程抓包问题
背景
最近在做一些远程设备的抓包能力建设。具体来说是设备(基于 Docker 的 Redroid 云手机)在服务器上,抓包工具在本地( Mitmproxy , Charles, Fiddler 等类似工具),希望通过某种方法将远程设备上的流量打到本地的抓包工具上,并且流量通过本地的 IP 出到公网。
这样做的目的主要有两个:
- 可以做到抓包工具和待抓包设备的分离。既能利用上强大的第三方抓包工具,又无需本地部署待抓包的设备。毕竟如果在远程服务器上部署抓包工具,操作起来就不那么方便,甚至可能还需要自行开发管控界面;如果在本地部署待抓包设备,很可能会遇到例如芯片架构、操作系统、环境依赖、系统资源消耗等问题。
- 可以方便进行 IP 出口的调整。在调试一些不可言说的功能时,服务器上部署的设备很可能是走一些代理 IP 池,有时候这些 IP 池本身可能有点问题,导致远程设备被封。这时候如果我们能够将设备的流量导到本地的正常 IP 出公网,可能会更方便我们验证是 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 的流量则都出现了问题:
-
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);