gRPC 中间人攻击抓包方案

通常的 Android 抓包非常简单,网上也有很多现存的参考资料。但对于 gRPC,尤其是 secure channel 的 gRPC 抓包资料很少,导致去年我研究某个游戏的流量时被坑了好长一段时间无果后放置了。最近抽空总算把这个坑给填上了,记录下来以免忘记。

Conceptions

首先需要了解 gRPC 的一些基本概念。

  • gRPC 基于 HTTP2 协议,故对于不支持 HTTP2 的工具(例如 Fiddler)是无法使用的。
  • 一个 Channel 对应一个 endpoint,并且 Channel 是一个十分昂贵的类,不推荐被频繁创建和销毁。
  • gRPC 有两种最常用的预置 Channelsecure channelinsecure channel。前者基于 SSL 必须为服务端和客户端设置 credentials,后者通常仅用于测试,不需要设置。
  • gRPC 自带证书绑定。所有被信赖的根证书被放在一个独立的 pem 文件中
  • 除了 gRPC 自带的证书验证以外,用户还可以构造 VerifyPeerCallback 自定义验证,随 credentials 一起传入 channel options 中,每次 call 中都会被调用。
  • 除了作用域为整个 Channel 的 ChannelCredentials 外,gRPC 还有 CallCredentials,作用域仅仅为一次 Call,用完即销毁。

Situation

目标:

  • 抓取某 Android App 的 gRPC 流量

已知信息:

  • 目标 App 是某 Android Unity 游戏,使用了 il2cpp 技术
  • 经过前期分析,该游戏没有自定义 VerifyPeerCallback,也没有 CallCredentials,验证机制只有 gRPC 本身的证书验证

Walkthroughs

知道了以上信息后,就可以开工了。

1. Unpinning

没错,unpin。而且还是双重 unpin。

系统的 unpin 很简单网上也已经有大量资料和现成的方法可用,我采用的是将自定义证书重命名后丢到系统证书库:

1
2
$ cert_hash=`openssl x509 -inform PEM -subject_hash_old -in $CERT_FILE_NAME | head -1`
$ mv $CERT_FILE_NAME /system/etc/security/cacerts/$cert_hash.0

关于 Android 系统证书命名规则可参考 CAcert system trusted certificates (HTTP only)

然后是 gRPC unpin。

gRPC 的证书验证机制我在 另一篇文章 里进行过了详细分析,想要 bypass 验证可以一劳永逸地 hook 核心模块或者针对性地 hook C# 方法。我采用了 hook C# 方法这条路,大概思路如下:

  1. 从游戏 APK 中取出原始的 pem 文件。
  2. 向 pem 文件中添加需要用到的自签根证书。
  3. Hook 掉读取 pem 文件的相关方法,将原始内容替换为修改后的 pem 文件内容。

前两步很简单,关键在于第三步。
因这部分内容较长,并且与本文的主题没有太大关系,故折叠起来有需要则看。

[Click Me] 替换 pem 文件方法

经过前期分析,可以知道 pem 文件被读取后是被当作纯粹的 string 来使用的,所以想要替换原始的 string,那么就需要重新构造一个 string。

需要注意的是,这里的 string 指的不是 C++ 的 string,也不是 C# 的 string,而是 il2cpp 的 string。

il2cpp 的 string 长什么样子?dump 内存可以得到:

1
2
3
4
5
6
struct il2cppString {
size_t address; // string 的 Il2CppClass 指针地址
size_t nothing; // 0x00
int length; // buf 的长度,不包含最后的 \0
char buf[0]; // 字符串内容
};

这是一个变长结构体,size_t 在 arm64 中占 8 字节。

得到结构之后就很简单了。构造一个新的 il2cppString 结构体,调用原始函数获取原始 string,将原始 string 的 address 原封不动抄过来,然后读取修改后的 pem 文件,将其长度和内容写入 lengthbuf 即可。下面是一种实现:

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
il2cppString* getSSLRootCertificates(void* self, void* method) {
if(getSSLRootCertificatesBackup == nullptr){
LOGE("backup DOES NOT EXIST");
}
LOGI("calling getSSLRootCertificates");
il2cppString* origin = getSSLRootCertificatesBackup(self, method);

// 读取修改后的 pem 文件
u16string rootspem = wreadTextFile("roots.pem");

if (rootspem.empty()) {
LOGI("Cannot read text from roots.pem, using original strings instead.");
return origin;
}

const char16_t* pem_char = rootspem.c_str();
size_t pem_len = rootspem.length();

// 构造一个新的 il2cppString 结构体
auto* alter = (il2cppString*)malloc(sizeof(il2cppString) + pem_len * 2);
alter->address = origin->address;
alter->nothing = 0;
alter->length = pem_len;
LOGI("Writing buf...");
memcpy(alter->buf, pem_char, pem_len * 2);

LOGI("SSLRootCertificates has been replaced.");
return alter;
}

看起来十分简单的一个函数,但实际上在我写好之前踩了无数个坑……

坑 1: 字符串格式

首先看读取文件这一段

1
u16string rootspem = wreadTextFile("roots.pem");

这里用到的不是 string,而是 u16string

为什么?因为 C# 在内存中储存 string 采用的是 UTF-16 Little Endian 编码方式。区别在哪里?看下面。

文件中的 pem 长这个样子:

image

C# 读取到内存中的正确的 pem 长这个样子:

image

如果采用 string,那么 C++ 会默认以 ANSI 的编码格式在内存中存储字符串,每个字符的长度都为单字节,显然不符合上图 C# 字符串格式的标准。

那么为什么不采用宽字符类型 wstring?好问题。其实我一开始也使用了 wstring 而不是 u16string,并且在调试环境(Windows x64)中得到了完全正确的字符串。然而放到 Android arm64 中去执行时却得到:

image

?????

为什么变成了宽宽字符??

为了搞清这个事实我还专门写了一个 Android Native 程序测试,发现在 arm64 中 sizeof(wstring) 确实就是 4 字节长度。

所以在这里只能强行指定字符串类型为双字节的 u16string,对应的 char 类型也必须采用 char16_t

坑 2: 进程权限

读取文件这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string readTextFile(const char* filename) {
// string path = "/sdcard/Download/";
string path = "/data/data/${package_name}/files/";
path.append(filename);
LOGI("%s", path.c_str());
fstream file(path, ios::in);
if (!file) {
LOGE("Cannot open file %s.", path.c_str());
LOGE("%s", strerror(errno));
return "";
}
LOGI("Read %s succeed.", path.c_str());
stringstream buffer;
buffer << file.rdbuf();
file.close();
return buffer.str();
}

u16string wreadTextFile(const char* filename) {
string sstr = readTextFile(filename);
u16string wbuffer = u16string(sstr.begin(), sstr.end());
return wbuffer;
}

由于 hook 函数的调用进程会继承原始进程(在这里是目标游戏 App),所以 hook 函数拥有的权限不能超出原始进程所拥有的权限。也就是说,修改后的 pem 文件必须在原进程有权限访问的路径中。

理论上来说 /sdcard/Download/ 是默认所有 App 都有读写权限的,但在我测试时不知为何总是会出现 Permission denied 的错误,所以只好把 pem 文件放在 App 的私有文件夹中。

完成系统 unpin 和 gRPC unpin 之后,就可以开始配置抓包环境了。

2. Routing Scenarios

先安装 mitmproxy

mitmproxy 的官方文档对于几种路由方式有很详细的解释,这里借官方文档的几张图来说明。

image

上图是 Regular Proxy 模式,适合于能够设置客户端代理的场景。
由于新版 Android 只会对浏览器 App 适用系统 HTTP proxy 设置,其他应用的流量会被 bypass,所以在 WiFi 里设置 proxy 不会起到任何作用。这个场景虽然最实用,但无法适用于我们需要的场景。

image

上图是 Transparent 模式。这种模式的强大之处在于代理服务器的存在对于客户端是完全透明的,客户端不会有任何感知。这就是我们需要采用的模式。

所需的路由模型定好了,为了使用这种模式,需要对 PC 进行一些设置。

  1. 打开注册表,转到 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
  2. 设置 IPEnableRouter 值为 1
  3. 设置 EnableICMPRedirect 值为 0(如果没有则创建 DWORD)
  4. 打开服务,找到 Routing and Remote Access,启动

配置好后,执行

1
$ mitmweb --mode transparent --certs ~/.mitmproxy/${CERT_FILE}.pem -k

注意:执行此语句前需要先关闭或正确配置可能导致网络回环的路由工具(如:proxifier)

可以手动指定 pem 文件,也可以使用 mitmproxy 自动生成的 mitmproxy-ca-cert.pem

mitmproxy 会启动两个监听端口。8080 用于监听流量,8081 用于 web server。

然后打开手机 WiFi 设置,将 gateway 设置为 PC 的 IP 地址。

以上就 ok 了。打开目标应用,Go!

……Wait a minute

如果你所在的网络环境不需要任何代理可以直接访问目标应用服务器,那么看到这里就可以结束了。但如果很不幸并非如此,还得配置前向代理。

3. Forward Proxy

可是,mitmproxy 会截取所有本机流量,并且没有可以修改这个 feature 的设置选项,除非前向代理服务器不在本机上,否则代理流量也会被截取导致回环:

flowchart LR

  subgraph PC [Local Machine]
    direction LR 
    s2
    s3 
  end

  s1("Client") --> s2("mitmproxy") --> s3("upstream proxy") --> s2
  PC -..- ? -..-> server("Server")

因此,必须要修改 mitmproxy 源码,自己编译一套 bypass 本机流量的版本。

幸好,修改非常简单,找到 windows.py,修改 local 的默认值为 False 即可(明明这么简单为什么不给一个配置接口可以修改)。

1
2
3
4
5
6
7
def __init__(
self,
local: bool = True, # Change to False
forward: bool = True,
proxy_port: int = 8080,
filter: Optional[str] = "tcp.DstPort == 80 or tcp.DstPort == 443",
) -> None:

然后照着 官方文档 的说明编译即可。

使用自行编译的 mitmproxy 时需要进入 venv/Scripts 激活环境后使用。

1
2
3
$ cd venv/Scripts
$ ./activate
$ mitmweb --mode transparent

现在,所有的本机流量都不会通过 mitmproxy 了,可以放心地将 mitmproxy 的下一跳设置为需要的代理了。

而且这种方式还有一个好处,所有本机流量都不会出现在 flow 中,会使得视图看上去很干净。

References

 Comments
Comment plugin failed to load
Loading comment plugin