突破抓包限制:Hook实战解密HTTPS流量与SSL双向认证

传统的抓包工具(如 Fiddler、Wireshark)在面对愈发严格的安全措施时,如 HTTPS 双向认证(mTLS)、内存加载证书、证书绑定等,它们往往显得力不从心。本文旨在带你超越传统抓包的边界,不仅会回顾抓包工具的核心原理,更将深入 Hook 注入技术,实战演示如何通过拦截关键函数,动态获取 SSL 证书、解密 HTTPS 明文流量。

这可能是全网最具深度的关于抓包文章。

抓包工具

Fildder 原理

Fiddler 通过扮演中间人的角色(对客户端扮演服务器,对服务器扮演客户端)来实现 HTTP 抓包和 HTTPS 流量解密的功能。

Fiddler 在启动后,会自动在本地建立一个代理服务器(端口默认为 8888),并通过调用 Windows 的 WinHttpSetDefaultProxyConfiguration 等函数,将自己设置为系统默认的 HTTP/HTTPS 代理。许多应用程序(如浏览器)默认会遵循系统的这个代理设置,因此它们发出的网络请求都会首先被发送到 Fiddler。

可以在系统的“设置” -> “网络和 Internet” -> “代理”中看到“使用代理服务器”的选项被自动开启,并且代理 IP 为:
http=127.0.0.1:8888;https=127.0.0.1:8888;

当 Fiddler 收到 HTTP 请求时,因为不涉及数据包解密,Fiddler 直接进行转发即可。

当需要解密 HTTPS 请求时,Fiddler 会在本地生成一个自签名的根证书(Fiddler Root Certificate),并将这个根证书安装到操作系统的“受信任的根证书颁发机构”存储中。

以请求 https://sample.com 为例,Fiddler 会使用自签名的根证书私钥来为 sample.com 域名动态的伪造一张证书,并返回给浏览器(因为对于浏览器而言,它的服务器是 Fildder),浏览器使用这个假证书来进行 SSL 加密,然后将加密数据包发送给 Fiddler,Fiddler 使用假证书的私钥来解密数据包进行显示、修改等,然后 Fiddler 从 sample.com 服务器请求真正的证书,并使用真证书对数据包再次加密,然后发送给 sample.com 服务器;

sample.com 服务器将响应发送给 Fiddler(因为对 sample.com 服务器而言,发起请求的客户端是 Fildder),Fiddler 使用真证书来解密数据包进行展示,然后使用假证书来再次加密数据包,最后转发给浏览器。

Proxifier 原理

Proxifier 在 Windows 系统中主要使用 LSP(Layered Service Provider,分层服务提供程序)​ 技术实现对网络流量的拦截,属于应用层的流量拦截,无需安装驱动,大多数游戏加速器也会使用该项目技术。

Proxifier 是透明流量转发,不负责解密 HTTPS 流量,解密操作由后续的软件进行。通常使用 Proxifier 将指定进程的流量转发到 Fildder 或 Charles,然后再使用这些工具进行解包分析。

通常还需要将 Proxifier 的“名称解析”->“DNS 设置”修改为“通过代理解析主机名称”。

下面来解释为什么要这样设置。

1
应用程序 -> Proxifier -> Fiddler

若没有将 DNS 设置为“通过代理解析主机名称”,当本地 DNS 缓存中存在 baidu.com 域名解析条目时,会直接将 baidu.com 域名解析为对应的 IP,从而将 TSL 握手请求包中的域名修改为 IP 后再转发给 Fiddler。Fiddler 在动态伪造证书时是需要知道域名的,如果不知道,Fiddler 就不知道要伪造哪个证书,无法伪造证书。

Wireshark 原理

Wireshark 的工作流程始于捕获,当选择一个网络接口(如 Wi-Fi 或以太网网卡)开始抓包时,Wireshark 会通过底层驱动(如 Npcap 或 WinPcap)将网卡设置为混杂模式。在此模式下,网卡会捕获所有流经它的网络数据包。

捕获到的原始数据是二进制比特流,Wireshark 的内置的协议解析器会按照网络协议栈(如 Ethernet → IP → TCP → HTTP)逐层解码这些二进制数据,将晦涩的代码转换为人类可读的协议字段和含义。因此要熟练掌握 Wireshark 的使用,需要先了解网络协议栈,可以参考: 网络编程/网络协议-1-基础概念 。

Wireshark 还提供了一个分为三部分的界面供我们深入检查数据包:数据包列表、数据包详情和数据包字节流。通常会需要结合显示过滤器(如 http.request.method == “GET”)来精准筛选流量,或通过“Follow TCP Stream”功能重构完整的会话内容。

虽然 Wireshark 也可以捕获和分析 HTTP(s)流量,但在这方面还是不如 Fiddler 等工具专业,所以我们通常只使用其来捕获除 HTTP(s)以外的流量。

浏览器 HTTP 抓包

先来一个开胃前菜。网页抓包是调试前端 API 接口最基础的技能,浏览器内置的开发者工具是完成这项任务的首选利器。

以主流的 Chrome 浏览器为例,按 F12 或 Ctrl+Shift+I 就可以打开开发者工具,其“Network(网络)面板”中记录了所有由浏览器发起的网络请求,包括 HTML、CSS、JS、图片、XHR/Fetch(API 接口)等。

但有些网页会禁用浏览器的调试工具,防止用户进行抓包和调试,它们通常采用包括但不限于下面的方式:

  • 使用 addEventListener 来拦截 F12、 Ctrl+Shift+I 等快捷键以及右键菜单事件
  • 因为只有在打开调试工具的情况下,debugger语句才会生效。根据这个机制,可以通过 debugger 上下语句执行的时间差来判断调试器是否打开。
  • 也可以通过无限执行debugger语句来干扰调试。

目前已经有开源组件 disable-devtool 可以快速搞定禁用调试工具,这个组件使用的检测/禁用手段也更加多样化。

遇到这种禁用调试工具的网站时,如果只需要抓包,使用 Fildder 或 Charles 无意是最简单的方法。

基于 libcurl 的 HTTP 抓包

许多命令行工具和应用程序使用 libcurl 库进行网络通信。默认情况下,libcurl 并不会自动遵循系统代理设置,除非在代码中显式指定。

1
curl_easy_setopt(pCURL, CURLOPT_PROXY, "127.0.0.1:8888");

对于一些支持设置 HTTP 代理的软件,我们可以尝试将代理设置为本地的 Fildder 代理端口(如 127.0.0.1:8888),然后使用 Fiddler 进行抓包分析。

而对于大多数不支持代理设置的软件,通常需要使用 Proxifier 将该软件进程的流量转发到 Fiddler 代理端口,然后使用 Fildder 进行抓包分析。

HTTPS 双向认证

根据之前的文章 网络编程/网络协议-7-HTTP与HTTPS协议 《网络协议(7)–HTTP与HTTPS协议》 所介绍的 SSL/TSL 握手的过程可以知道,在握手过程中,不仅客户端可以校验服务器的证书,服务器也可以校验客户端的证书,这个叫 HTTPS 双向认证(mutual TLS),简称 mTLS。libcurl 作为一个成熟的网络库,对 mTLS 提供了很好的支持。

客户端校验服务器证书

通过如下代码可以开启 libcurl 客户端对服务器证书的校验:

1
2
curl_easy_setopt(pCURL, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(pCURL, CURLOPT_SSL_VERIFYHOST, 1L);

在开启服务器证书校验后,还需要指定 CA 包(Certificate Authority bundle,包含多个信任的根证书,用于验证服务器证书链)才能正常请求。有两种方式指定 CA 包,一种是通过 CURLOPT_CAINFO 选项来从本地文件路径加载:

1
curl_easy_setopt(pCURL, CURLOPT_CAINFO, "D:\\certs\\ca.crt");

另一种是通过 CURLOPT_CAINFO_BLOB 选项来从内存加载:

1
2
3
4
5
6
7
8
9
10
11
  const char* strCA = R"(
-----BEGIN CERTIFICATE-----
.......
-----END CERTIFICATE-----
)";

struct curl_blob blob;
blob.data = (void*)strCA;
blob.len = strlen(strCA);
blob.flags = CURL_BLOB_COPY;
curl_easy_setopt(pCURL, CURLOPT_CAINFO_BLOB, &blob);

服务器校验客户端证书

像网银、企业内部服务等这些对安全级别要求较高的应用,服务器通常需要校验客户端的证书,即 TLS 握手的 ServerHello 响应中包含“客户端证书请求”,此时客户端就需要发送相应的证书给服务器。

libcurl 通过 CURLOPT_SSLCERT 和 CURLOPT_SSLKEY 选项指定客户端证书和私钥的本地路径(如果通过内存加载则分别对应 CURLOPT_SSLCERT_BLOB 和 CURLOPT_SSLKEY_BLOB 选项),还是可以使用 CURLOPT_SSLCERTTYPE 选项指定证书类型(默认为 PEM 类型)以及 CURLOPT_KEYPASSWD 选项指定私钥的密码(可选)。

libcurl 在收到服务器的“客户端证书请求”后,会自动将相应的信息发送给服务器。

创建证书及私钥

如何来创建双向证书所需的证书呢?在创建证书及私钥之前,需要先弄清除,公钥、私钥、CA 和证书的关系(详见 网络编程/网络协议-7-HTTP与HTTPS协议 《网络协议(7)–HTTP与HTTPS协议》 中的“证书的申请”章节)。

  • 公钥和私钥是非对称加密中的概念,私钥保密,公钥公开。
  • 证书里面会包含公钥,证书也是公开的,但证书对应的私钥需要保密。所以在我的文章中一般将证书等同于公钥。
  • CA = Certificate Authority,直译为证书机构。使用 CA 证书的私钥来签发证书,然后用 CA 证书来验证所签发的证书。

回到本节的双向校验过程中来,客户端验证服务器的证书也就是使用 CA 证书来验证服务器的证书,服务器验证客户端证书也就是使用 CA 证书来验证客户端的证书,因为客户端和服务器的证书都是通过 CA 证书私钥来签发的。

1
2
3
4
5
CA私钥 ---签发--> (服务器证书 + 私钥)
CA私钥 ---签发--> (客户端证书 + 私钥)

CA公钥 ---验证--> 服务器证书
CA公钥 ---验证--> 客户端证书

我们可以使用 openssl 来生成上述证书,大致步骤如下。

  1. 创建 CA 的私钥和证书。
1
2
3
4
5
6
7
8
9
10
11
12
# CA私钥
openssl genrsa -out ca.key 4096

# 使用私钥 ca.key 生成一个自签名的根证书 ca.crt,有效期为10年
# -nodes 不加密私钥(no DES)
# /C=CN 国家=中国 /ST=Beijing 省份=北京 /L=Beijing 城市=北京 /O=Test CA 组织=Test CA /OU=IT 部门=IT /CN=Test Root CA 通用名称=Test Root CA
# CA:true 表示这是一个CA证书
# 允许该证书用于签发其他证书(keyCertSign)和签发证书吊销列表(cRLSign)
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt ^
-subj "/C=CN/ST=Beijing/L=Beijing/O=Test CA/OU=IT/CN=Test Root CA" ^
-addext "basicConstraints=critical,CA:true" ^
-addext "keyUsage=critical,keyCertSign,cRLSign"
  1. 创建服务器的私钥和证书。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 私钥
openssl genrsa -out server.key 2048

# 生成服务端 CSR(请求证书),设置主题与常用用途
# CN=localhost 表示服务器域名为localhost,但现在已被废弃,优先使用下面的subjectAltName
openssl req -new -key server.key -out server.csr ^
-subj "/C=CN/ST=Beijing/L=Beijing/O=Test Server/OU=IT/CN=localhost"

# 创建扩展文件server.ext
# extendedKeyUsage为serverAuth表示这是一个用于服务器验证的证书
# 由于是本地测试,所以ip和域名为localhost、127.0.0.1、IP:::1
# 这里的DNS不是域名解析服务器,而是指域名
(
echo authorityKeyIdentifier=keyid,issuer
echo basicConstraints=CA:FALSE
echo keyUsage=digitalSignature, keyEncipherment
echo extendedKeyUsage=serverAuth
echo subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1
) > server.ext

# 使用CA签发一个自签名的证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial ^
-out server.crt -days 365 -sha256 -extfile server.ext
  1. 创建客户端的私钥和证书。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
openssl genrsa -out client.key 2048

# 客户端 CSR
# extendedKeyUsage为clientAuth
openssl req -new -key client.key -out client.csr ^
-subj "/C=CN/ST=Beijing/L=Beijing/O=Test Client/OU=IT/CN=test-user"

# 创建扩展文件client.ext
# extendedKeyUsage=clientAuth表示这是一个用于客户端验证的证书
(
echo authorityKeyIdentifier=keyid,issuer
echo basicConstraints=CA:FALSE
echo keyUsage=digitalSignature, keyEncipherment
echo extendedKeyUsage=clientAuth
) > client.ext

# 使用CA签发一个自签名的证书
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial ^
-out client.crt -days 365 -sha256 -extfile client.ext

绕过对服务器证书的校验

在抓取 libcurl 的 HTTPS 包时,如果提示如下的错误,说明 libcurl 开启了对服务器证书的校验,并且证书校验失败。

要解决 Fiddler 解密失败的问题,我们需要理解两种场景下证书校验的差异:

  1. 没有 Fiddler 的情况。

    客户端(libcurl)向服务器发起 TLS 握手请求,其中包含 ClientHello 消息。服务器返回 ServerHello、证书(以及其它消息)。客户端收到证书后,会使用自定义的 CA 证书包来验证服务器证书。如果验证通过(即服务器证书是由自定义 CA 证书包中的某个根证书签名的),则握手继续;否则,握手失败。

  2. 有 Fiddler 的情况(且 Fiddler 作为中间人代理)。

    客户端实际上是与 Fiddler 建立 TLS 连接,而不是直接与目标服务器建立连接。因此,客户端会向 Fiddler 请求证书(因为 Fiddler 此时扮演服务器的角色)。Fiddler 会动态生成一个目标服务器域名的证书,并用 Fiddler 自己的根证书签名。客户端收到这个证书后,同样会用自定义的 CA 包来验证。如果自定义 CA 包中包含了 Fiddler 的根证书,则验证通过;否则,验证失败。

如果 libcurl 是通过文件路径方式来指定的 CA 包,我们可以找到该文件,通过将 Fiddler 根证书添加到该文件中的方式,来使程序再次信任 Fiddler 的根证书。大致步骤如下:

  1. 导出 Fiddler 的根证书(打开 Fiddler → Tools​ → Options​ → HTTPS​ → Actions​ → Export Root Certificate to Desktop)

  2. 把证书转成与 libcurl 程序所指定证书一样的格式,如转成 pem 格式。

    1
    openssl x509 -in FiddlerRoot.cer -out FiddlerRoot.pem -outform PEM
  3. 将 pem 内容添加到原有的 CA 包尾部(或者完全替换原有 CA 包的内容)。

如果在 Fiddler 之前使用了 Proxifier 转发流量,不要忘记在 Proxifier 中将 DNS 设置“通过代理解析主机名称”。

如果 libcurl 是通过内存加载的 CA 包,可以采用注入 + Hook 的方式将 CA 包重定向到我们指定的文件,见下面的章节。

libcurl 与 Schannel

libcurl 中的 Schannel 和 OpenSSL 都是用于处理 HTTPS (SSL/TLS) 连接的底层安全库,它们的主要区别在于:OpenSSL 跨平台的第三方加密库;而 Schannel (Security Support Provider Interface) 是 Windows 平台自带的微软实现,原生集成。可以在编译 libcurl 时选择以何种方式支持 HTTPS。

当使用 OpenSSL 作为底层安全库时,可以使用上面介绍的方法来抓取 HTTPS 流量。

但是,当使用 Schannel 作为底层安全库时,默认会验证证书的吊销状态,而 Fiddler 生成的证书没有有效的吊销信息,会导致返回CERT_TRUST_REVOCATION_STATUS_UNKNOWN错误。

使用如下命令查看 Fiddler 导出的证书的吊销状态:

1
certutil -verify FiddlerRoot.cer

通常会输出如下结果:

1
2
证书是一个 CA 证书
无法检查分支证书吊销状态

如果能修改 libcurl 程序的代码,可以禁用吊销状态检查:

1
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NO_REVOKE);

当然大多数情况下,都无法修改程序的代码,那么如何跳过吊销状态检查呢?嘿嘿

绕过对客户端证书的校验

找到客户端使用的证书和私钥文件(如果私钥有密码,还要先想办法获取到密码),通过如下命令来生成 pfx 文件:

1
openssl pkcs12 -export -out client.pfx -inkey client.key -in client.crt

双击 pfx 文件,导入到系统中(理论上是不需要该步骤了,但可能是因为 Schannel 存在 bug 的缘故,如果不将 pfx 导入到系统,fiddler 会解析证书失败)。

编辑 Fiddler 的 Rules,在 OnBeforeRequest 函数中添加类似如下内容:

1
2
3
if (oSession.uriContains("localhost")) {
oSession["https-Client-Certificate"] = "D:\\certs\\client.pfx";
}

如果 libcurl 是通过内存加载的客户端证书、私钥,可以采用注入 + Hook 的方式将获取到这些信息,见下面的章节。

Hook libcurl 获取证书信息

当目标程序通过内存(BLOB)方式加载证书和私钥时,我们无法直接修改文件。此时,Hook(钩子)​ 技术便成为关键手段。通过拦截程序对 libcurl API 的调用,我们可以动态读取、修改甚至替换这些敏感信息。

涉及到的 libcurl 选项如下:

1
2
3
4
CURLOPT_CAINFO_BLOB
CURLOPT_SSLCERT_BLOB
CURLOPT_SSLKEY_BLOB
CURLOPT_KEYPASSWD

通过分析 libcurl(8.13.0 版本)的代码,发现 curl_easy_setopt 调用路径如下:

1
2
3
4
5
6
7
CURLcode curl_easy_setopt(CURL *d, CURLoption tag, ...)

CURLcode Curl_vsetopt(struct Curl_easy *data, CURLoption option, va_list param)

CURLcode setopt_blob(struct Curl_easy *data, CURLoption option, struct curl_blob *blob)

CURLcode setopt_cptr(struct Curl_easy *data, CURLoption option, char *ptr)

Hook Curl_vsetopt 一个函数就可以截获和修改我们所需要的信息。

libcurl 没有导出 Curl_vsetopt 函数,所以需要通过特征码来搜索函数地址。使用 IDA Pro 分析 32 位 libcurl.dll 发现,可以通过函数的前几个指令作为特征码来搜索函数地址,因此 函数特征码为 0x8B, 0x4C, 0x24, 0x08, 0x81, 0xF9, 0x10, 0x27, 0x00, 0x00。

1
2
8B4C24 08                | mov ecx,dword ptr ss:[esp+8]            | _Curl_vsetopt
81F9 10270000 | cmp ecx,2710 |

编写 DLL,在 DLL 中通过特征码查找函数地址,然后使用 MinHook 库来 Hook 该函数。

在查找函数特征码时,需要区分动态链接和静态链接 libcurl 库的情况。动态链接时,直接从 libcurl.dll 模块查找;静态链接时,从主模块中查找。

查找 Curl_vsetopt 函数地址的代码如下:

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
PVOID dllBase = NULL;
ULONG sizeOfImage = 0L;
PFN_Curl_vsetopt pfnCurlVSetOpt = NULL;

PVOID processBase = NULL;
ULONG sizeOfProcessImage = 0L;

// 从LDR中查找libcurl.dll模块的基地址
// 若查找不到,可能是静态链接,从主模块查找
PPEB peb = NtCurrentTeb()->ProcessEnvironmentBlock;
PPEB_LDR_DATA ldr = peb->Ldr;
PLIST_ENTRY pListHead = &ldr->InLoadOrderModuleList;
for (PLIST_ENTRY pListEntry = pListHead->Flink; pListEntry != pListHead; pListEntry = pListEntry->Flink) {
PLDR_DATA_TABLE_ENTRY pEntry = CONTAINING_RECORD(pListEntry, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
if (!processBase) {
processBase = pEntry->DllBase;
sizeOfProcessImage = pEntry->SizeOfImage;
}
std::wstring moduleName(pEntry->BaseDllName.Buffer, pEntry->BaseDllName.Length / 2);
if (StrIsEqual(moduleName, L"libcurl.dll", true)) {
dllBase = pEntry->DllBase;
sizeOfImage = pEntry->SizeOfImage;
break;
}
}

if (dllBase == NULL || sizeOfImage == 0) {
dllBase = processBase;
sizeOfImage = sizeOfProcessImage;

TraceW(L"[CurlHook] Cannot find libcurl.dll module, try to search the whole process image(ImageBase: 0x%p, Size: %u).\n", dllBase, sizeOfImage);
}

const size_t patternSize = sizeof(pattern);
BYTE* foundAddr = NULL;

SearchPattern((BYTE*)dllBase, sizeOfImage, pattern, patternMask, (BYTE**)&pfnCurlVSetOpt);

if (!pfnCurlVSetOpt) {
TraceW(L"[CurlHook] Cannot find Curl_vsetopt function.\n");
return;
}

TraceW(L"[CurlHook] Found pattern at address: 0x%p\n", pfnCurlVSetOpt);

使用 MinHook 库 Hook 查找到的函数,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
MH_STATUS mhStatus = MH_Initialize();
if (mhStatus != MH_OK) {
TraceW(L"[CurlHook] MH_Initialize failed: %d\n", mhStatus);
return;
}

mhStatus = MH_CreateHook(pfnCurlVSetOpt, My_Curl_vsetopt, (LPVOID*)&pfnCurlVsetoptTrampoline);
if (mhStatus != MH_OK) {
TraceW(L"[CurlHook] MH_CreateHook failed: %d\n", mhStatus);
return;
}

mhStatus = MH_EnableHook(pfnCurlVSetOpt);
if (mhStatus != MH_OK) {
TraceW(L"[CurlHook] MH_EnableHook failed: %d\n", mhStatus);
return;
}

TraceW(L"[CurlHook] Hook succeeded.\n");

最后,在我们的 detour 函数中根据 option 来做相应的处理:

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
CURLcode My_Curl_vsetopt(struct Curl_easy* data, int option, va_list param) {
if (option == 40309) { // CURLOPT_CAINFO_BLOB
// 附加Fiddler的CA到末尾,然后返回
File file(PathJoin(GetCurrentExeDirectoryW(), L"FiddlerCA.pem"));
if (file.open(L"rb")) {
std::string strFiddlerCA = file.readAll();
file.close();

TraceA("[CurlHook] FiddlerCA.pem %s\n", strFiddlerCA.c_str());

curl_blob* blob = va_arg(param, struct curl_blob*);
std::string newCA = std::string((const char*)blob->data, blob->len) + "\n" + strFiddlerCA;

gBuffer = malloc(newCA.length());
if (gBuffer) {
memset(gBuffer, 0, newCA.length());
memcpy(gBuffer, newCA.c_str(), newCA.length());

struct curl_blob myBlob;
myBlob.data = gBuffer;
myBlob.len = newCA.length();
myBlob.flags = blob->flags;

TraceW(L"[CurlHook] Append CA success.\n");
return Wrapped_Curl_vsetopt(data, option, &myBlob);
}
}
return pfnCurlVsetoptTrampoline(data, option, param);
}

if (option == 40291 || option == 40292) {
// 转存到文件
curl_blob* blob = *((struct curl_blob**)param);
std::wstring filePath;
if (option == 40291) // CURLOPT_SSLCERT_BLOB
filePath = PathJoin(GetCurrentExeDirectoryW(), L"DumpClient.cert");
else if (option == 40292) // CURLOPT_SSLKEY_BLOB
filePath = PathJoin(GetCurrentExeDirectoryW(), L"DumpClient.key");

File file(filePath);
if (file.open(L"w")) {
file.writeFrom(blob->data, blob->len);
file.close();
}
return pfnCurlVsetoptTrampoline(data, option, param);
}

if (option == 10026) { // CURLOPT_KEYPASSWD
char* pwd = *((char**)param);
TraceA("[CurlHook] CURLOPT_KEYPASSWD: %s\n", pwd);
return pfnCurlVsetoptTrampoline(data, option, param);
}

return pfnCurlVsetoptTrampoline(data, option, param);
}

static CURLcode Wrapped_Curl_vsetopt(struct Curl_easy* data, int option, ...) {
va_list args;
va_start(args, option);
CURLcode result = pfnCurlVsetoptTrampoline(data, option, args);
va_end(args);
return result;
}

Hook OpenSSL 获取明文

libcurl 通过 OpenSSL 提供对 SSL 和 TLS 协议的支持,因此我们可以 Hook OpenSSL 库的函数来截获加密前的请求明文以及解密后的响应明文。

写一个简单的 OpenSSL 示例程序,单步调试后可以发现,OpenSSL 内部会分别调用 SSL_write 函数写入待加密的报文数据,SSL_read 函数读取解密后的报文数据。

因此我们只需要 Hook 这两个函数就可以截获请求和响应的明文,而且这两个函数都是 OpenSSL 的导出函数,在程序使用动态链接 OpenSSL 库的情况下,我们只需要查找 IAT 就可以获取函数地址。

本节以静态链接 OpenSSL 库为例,通过特征码来查找这两个函数的地址。

从 OpenSSL 3.5.0 版本中查看 SSL_read 和 SSL_write 函数的定义如下:

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
int SSL_read(SSL *s, void *buf, int num)
{
int ret;
size_t readbytes;

if (num < 0) {
ERR_raise(ERR_LIB_SSL, SSL_R_BAD_LENGTH);
return -1;
}

ret = ssl_read_internal(s, buf, (size_t)num, &readbytes);

/*
* The cast is safe here because ret should be <= INT_MAX because num is
* <= INT_MAX
*/
if (ret > 0)
ret = (int)readbytes;

return ret;
}

int SSL_write(SSL *s, const void *buf, int num)
{
int ret;
size_t written;

if (num < 0) {
ERR_raise(ERR_LIB_SSL, SSL_R_BAD_LENGTH);
return -1;
}

ret = ssl_write_internal(s, buf, (size_t)num, 0, &written);

/*
* The cast is safe here because ret should be <= INT_MAX because num is
* <= INT_MAX
*/
if (ret > 0)
ret = (int)written;

return ret;
}

SSL_read 与 SSL_write 函数的定义非常类似,而且在 OpenSSL 源码中还有很多类似的函数,因此不能简单的使用前几个指令作为特征码进行搜索。

进一步分析 ERR_raise 宏,其定义如下:

1
2
3
4
5
6
7
# define ERR_raise(lib, reason) ERR_raise_data((lib),(reason),NULL)
# define ERR_raise_data \
(ERR_new(), \
ERR_set_debug(OPENSSL_FILE,OPENSSL_LINE,OPENSSL_FUNC), \
ERR_set_error)

void ERR_set_debug(const char *file, int line, const char *func);

其中,ERR_set_debug 函数的参数分别是源文件路径、代码所在行、函数名称,代码行是一个整数常量,可以很方便地作为特征码来使用,也就是下面汇编代码中的 push 94Bh。

因此 SSL_read 与 SSL_write 函数的特征码分别如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

static BYTE sslReadPattern[] = {
0x8B, 0x44, 0x24, 0x0C, // mov eax, [esp+arg_8]
0x85, 0xC0, // test eax, eax
0x79, 0x00, // jns short loc_6343D6
0xE8, 0x00, 0x00, 0x00, 0x00, // call sub_454800
0x68, 0x00, 0x00, 0x00, 0x00, // push offset aSslRead
0x68, 0x4B, 0x09, 0x00, 0x00, // push 94Bh
};
static char sslReadPatternMask[] = u8R"(xxxxxxx?x????x????xxxxx)";

static BYTE sslWritePattern[] = {
0x8B, 0x44, 0x24, 0x0C, // mov eax, [esp+arg_8]
0x85, 0xC0, // test eax, eax
0x79, 0x00, // jns short loc_6343D6
0xE8, 0x00, 0x00, 0x00, 0x00, // call xxx
0x68, 0x00, 0x00, 0x00, 0x00, // push offset aSslWrite
0x68, 0x71, 0x0A, 0x00, 0x00, // push 0A71h
};
static char sslWritePatternMask[] = u8R"(xxxxxxx?x????x????xxxxx)";

在找到函数地址之后,同样使用 MinHook 库进行 Hook,将截获到的请求和响应内容写入到日志文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int MySSLRead(void* s, void* buf, int num) {
int readbytes = pfnSSLReadTrampoline(s, buf, num);
if (readbytes > 0) {
logFile.writeFrom(buf, readbytes);
}
return readbytes;
}

int MySSLWrite(void* s, void* buf, int num) {
int written = pfnSSLWriteTrampoline(s, buf, num);
if (written > 0) {
logFile.writeFrom(buf, written);
}
return written;
}

WinHTTP

Fiddler 可以抓取使用 WinINET API 接口发送的流量,但是无法抓 WinHTTP API 接口发送流量。尝试了各种办法依然无法抓取,最后只好使用 Proxifier 来将流量转发到 Fiddler。

同样别忘记在 Proxifier 中将 DNS 设置“通过代理解析主机名称”。

Electron HTTP 抓包

Electron 应用默认不走系统代理,所以使用 Fiddler 无法直接抓包。使用 Electron 程序内置的调试工具进行抓包非常直观,而且不用考虑 HTTPS 双向认证的问题,可以优先尝试打开调试工具。

方法 1

如果能直接打开调试工具(如快捷键 Ctrl+Shift+I 或 F12 等),这是最简单的方式。

方法 2

如需抓取渲染进程的包,则使用 --remote-debugging-port=9222 --remote-allow-origins=* 命令行启动 Electron 程序,然后在浏览器中通过 http://localhost:9222 访问调试工具。

如需抓取主进程的包,则使用--inspect=9222命令行启动 Electron 程序,并进行相应配置,详见 Web与Electron/Electron启动和禁用调试工具的方法

方法 3

使用 asar 命令解压 app.asar 资源包,并在 js 代码中查找 new BrowserWindow,针对需要抓包的 BrowserWindow 对象启动 devTools 并调用 openDevTools 函数打开调试工具(也粗暴一点就把所有的都加上),然后再使用 asar 命令重新打包资源包。

1
2
3
4
5
6
7
8
9
10
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
devTools: true, // 启动devTools
},
});

// 打开调试工具
mainWindow.webContents.openDevTools({ mode: "detach" });

有的 Electron 应用会校验 asar 资源包的哈希值(如 WeMod),防止被修改之后重新打包,此时需要使用 IDA Pro 屏蔽掉该逻辑。

方法 4

虽然大多数情况下都可以打开调试工具的,除非方法不到位,但如果实在无法打开,还可以使用 Proxifier 将流量转发到 Filddler,然后使用 Fiddler 抓包分析。按照这种方法,如果遇到了 HTTPS 双向认证,则需要从资源文件和源码中获取到证书、私钥等信息。

CEF HTTP 抓包

CEF 应用可以通过设置远程调试端口来进行调试,也就是使用下面的命令行来启动 CEF 应用(指定一个 1024~65535 之间的端口号),然后在浏览器中通过 http://localhost:34444 访问调试工具。

1
cef_app.exe -remote-debugging-port=34444

调试端口屏蔽与反屏蔽

但开发者也可以在代码中屏蔽远程调试命令行参数,下面介绍几种屏蔽调试参数的方法,同时也介绍了对于逆向分析人员如何绕过该屏蔽方法的手段。

方法 1:不提供 devtools_resources.pak 文件。

这种情况只需要从 cef 官网下载对应版本的 devtools_resources.pak 文件放到 libcef.dll 同级目录即可。

方法 2:禁用命令行参数解析。

1
2
3
CefSettings settings;
settings.command_line_args_disabled = 1;
CefInitialize(args, settings, application, windows_sandbox_info);

CefInitialize 函数的调用路径如下:

1
2
3
libcef_dll_wrapper.dll!bool CefInitialize(const CefMainArgs& args, const CefSettings& settings, CefRefPtr<CefApp> application, void* windows_sandbox_info)

libcef.dll!int cef_initialize(const struct _cef_main_args_t* args, const struct _cef_settings_t* settings, cef_app_t* application, void* windows_sandbox_info)

cef_initialize 函数是 libcef.dll 的导出函数,所以我们只需要 Hook 该函数,并将 CefSettings.command_line_args_disabled 修改为 0 即可绕过该禁用。

方法 3:在 OnBeforeCommandLineProcessing 回调函数移除命令行参数中的 remote-debugging-port。

1
2
3
4
5
6
7
8
9
// 移除remote-debugging-port参数
void ClientAppBrowser::OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) {
if (command_line->HasSwitch("remote-debugging-port")) {
// remove remote-debugging-port
// ......
}
}

OnBeforeCommandLineProcessing 函数由 libcef_dll 项目中的 app_on_before_command_line_processing 函数所调用,但由于 app_on_before_command_line_processing 函数不是导出函数,因此查找该函数的地址可能会费点功夫。

Hook BoringSSL

上述方法虽然可以在大部分情况下打开调试工具,但有些情况下还是可能无法打开调试功能,比如开发者对 CEF 进行魔改。在无法打开调试工具时,使用 Proxifier + Fiddler 的组合就可以轻易的抓取 HTTP 数据包。

如果需要抓取 HTTPS 数据包,可以使用 Hook BoringSSL 的方式。

因为 CEF 使用了 OpenSSL 的 BoringSSL 分支,因此在抓取 HTTPS 数据包时,可以通过 Hook BoringSSL 的 SSL_read 和 SSL_write 函数来截获发送和接收的数据包,具体方法与上面小节介绍的 Hook OpenSSL 的 SSL_read 和 SSL_write 函数的方法类似。