C++实现HTTPS中间人代理:动态证书生成与流量拦截实践
1. 项目概述与核心价值
最近在调试一个需要分析HTTPS流量的内部工具时,我遇到了一个经典难题:如何在不修改客户端和服务端代码的前提下,透明地解密和查看HTTPS请求与响应的内容?直接抓包看到的是加密的TLS数据,而修改系统根证书信任库又过于侵入且影响全局。这让我想起了“中间人代理”这个老牌技术。它就像一个透明的“翻译官”,坐在客户端和真实服务器之间,既能与客户端建立安全的HTTPS连接,又能与服务器建立另一个HTTPS连接,从而有机会看到明文的通信内容。
这个项目,就是使用C++和轻量级的cpp-httplib库,从零开始构建一个支持HTTPS的中间人代理。我们不仅会实现代理转发,更关键的是要动态生成和签发被客户端信任的SSL证书,并完成请求与响应的拦截与修改。这不仅仅是写一个转发器,更是对HTTPS/TLS协议、公钥基础设施(PKI)以及网络编程的一次深度实践。对于从事安全研究、API调试、流量分析或需要深度定制网络行为的开发者来说,掌握这套技术栈非常实用。即使你只是对网络底层原理好奇,跟着走一遍这个流程,也能彻底弄明白浏览器那个“小锁头”背后到底发生了什么,以及“中间人攻击”的基本原理和防御方法。
2. 核心原理与架构设计
2.1 HTTPS中间人代理的工作机制
要理解我们构建什么,首先得拆解HTTPS中间人代理的核心工作流程。它本质上扮演了两个角色:对客户端来说,它是“目标服务器”;对真实服务器来说,它是“客户端”。
- 监听与连接:代理服务器启动,监听某个端口(如8888)。客户端(如浏览器)配置代理指向该地址。
- HTTPS连接建立(客户端侧):当客户端发起一个
CONNECT请求(这是HTTP代理用于建立隧道连接的标准方法,常用于HTTPS)时,代理会与客户端协商建立TLS连接。这里的关键是,代理需要向客户端出示一个针对目标域名(例如www.example.com)的SSL证书。为了让客户端信任这个证书,这个证书必须要么由客户端信任的根证书签发,要么客户端手动信任了我们的代理根证书。 - 证书的动态签发:代理不可能预先拥有互联网上所有域名的证书。因此,它需要具备一个自签名的根证书(CA Certificate),并能在运行时,针对客户端请求的任意域名,动态生成一张由该根证书签名的“叶子证书”。这个过程模拟了正规CA的签发行为。
- HTTPS连接建立(服务器侧):代理使用客户端原本想要访问的真实域名,与目标服务器建立标准的HTTPS连接。这里使用的是服务器真实的证书,代理作为标准的TLS客户端验证该证书(可选,但建议)。
- 数据双向转发与拦截:两个TLS连接建立后,代理就拥有了两条安全通道。它可以从客户端TLS连接中解密出明文HTTP请求,进行查看、记录或修改;然后将这个请求通过服务器侧的TLS连接加密发送出去。反之,将服务器的响应解密、处理后再加密返回给客户端。
整个架构的难点和核心就在于第2步和第3步:如何管理证书、如何动态签发、如何让客户端信任我们。cpp-httplib库内置了SSL支持,大大简化了TLS通信的编程复杂度,让我们能更专注于代理逻辑本身。
2.2 技术选型:为什么是cpp-httplib?
C++实现网络服务有多种选择,从底层的socket API到Boost.Asio,再到各种HTTP库。我选择cpp-httplib主要基于以下几点考量:
- 轻量级与单头文件:它是一个单头文件库,只需包含
httplib.h,无需复杂的编译和链接第三方库的过程,集成成本极低,非常适合快速原型开发和嵌入式部署。 - 内置SSL支持:它封装了OpenSSL或mbed TLS的接口,提供了简单的
SSLServer和SSLClient类,让我们可以直接在应用层处理HTTPS,而无需深入啃食OpenSSL复杂的BIO和上下文管理。 - 简洁的API:其API设计直观,建立服务器、定义路由、处理请求的代码看起来非常清晰,降低了实现HTTP代理逻辑的心智负担。
- 足够的性能:对于中间人代理这种I/O密集型应用,
cpp-httplib基于线程池的模型能够提供不错的并发性能。虽然可能不及专门优化的异步框架,但对于调试、分析及中小流量场景完全足够。
当然,它也有局限,例如异步支持较弱、定制化程度不如底层库。但对于我们这个以演示原理和核心功能为主的项目,它的优势非常突出。
注意:
cpp-httplib的SSL客户端在验证服务器证书时行为比较基础。在生产级中间人代理中,我们可能需要更精细的证书验证逻辑,甚至需要忽略证书错误(仅用于调试环境)。本项目会展示如何处理这种情况。
3. 核心组件实现详解
3.1 自签名根证书的创建与管理
一切始于信任的源头——我们自己的根证书。这个证书将用于为我们动态生成的所有站点证书签名。
# 使用OpenSSL生成根证书私钥(无密码) openssl genrsa -out ca.key 2048 # 使用私钥生成自签名的根证书(CA Certificate) openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \ -subj "/C=CN/ST=Beijing/L=Beijing/O=MyProxy CA/CN=MyProxy Root Certificate"关键参数解析:
genrsa -out ca.key 2048: 生成一个2048位的RSA私钥,这是证书安全的基础。req -new -x509 ...:-x509表示直接生成一个自签名证书,而不是证书签名请求(CSR)。-days 3650: 证书有效期10年,避免频繁更换。-subj: 设置证书的主题信息。其中CN=MyProxy Root Certificate是证书的通用名,这个名称会显示在客户端的证书颁发者中。
生成ca.crt和ca.key后,我们需要将ca.crt导入到客户端的信任根证书存储区。这是让整个代理工作的前提。
- Windows: 双击
ca.crt,选择“安装证书”,放入“受信任的根证书颁发机构”。 - macOS: 使用钥匙串访问,将证书拖入“系统”钥匙串,然后信任该证书。
- Linux: 拷贝到
/usr/local/share/ca-certificates/,然后执行sudo update-ca-certificates。
实操心得:在开发环境中,可以为浏览器或系统单独创建一个证书信任配置文件,避免污染全局环境。例如,启动Chrome时使用
--ignore-certificate-errors和--ignore-certificate-errors-spki-list参数仅忽略特定证书的错误,是更安全的选择。
3.2 动态证书签发引擎的实现
这是代理的核心“黑魔法”。我们不能预知用户会访问哪个域名,因此必须能实时生成证书。
// 伪代码,展示核心逻辑 #include <openssl/x509.h> #include <openssl/x509v3.h> #include <openssl/pem.h> class CertificateGenerator { private: X509* ca_cert; EVP_PKEY* ca_key; public: bool loadCA(const std::string& cert_path, const std::string& key_path) { // 加载CA证书和私钥到内存 FILE* fp = fopen(cert_path.c_str(), "r"); ca_cert = PEM_read_X509(fp, nullptr, nullptr, nullptr); fclose(fp); // ... 类似地加载 ca_key ... return (ca_cert && ca_key); } std::pair<X509*, EVP_PKEY*> generateCertForDomain(const std::string& domain) { // 1. 生成新的RSA密钥对 EVP_PKEY* pkey = EVP_PKEY_new(); RSA* rsa = RSA_generate_key(2048, RSA_F4, nullptr, nullptr); EVP_PKEY_assign_RSA(pkey, rsa); // 2. 创建X509证书结构并设置版本、序列号等 X509* cert = X509_new(); X509_set_version(cert, 2); // X509v3 ASN1_INTEGER_set(X509_get_serialNumber(cert), generate_serial()); X509_gmtime_adj(X509_get_notBefore(cert), 0); X509_gmtime_adj(X509_get_notAfter(cert), 365 * 86400); // 1年有效期 // 3. 设置证书主题(使用者),这里CN设置为请求的域名 X509_NAME* name = X509_get_subject_name(cert); X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char*)domain.c_str(), -1, -1, 0); // 设置颁发者为我们的CA X509_set_issuer_name(cert, X509_get_subject_name(ca_cert)); // 4. 设置公钥 X509_set_pubkey(cert, pkey); // 5. 添加扩展项:主题备用名称(SAN),这是现代浏览器必须的 X509_EXTENSION* ext = nullptr; X509V3_CTX ctx; X509V3_set_ctx_nodb(&ctx); X509V3_set_ctx(&ctx, ca_cert, cert, nullptr, nullptr, 0); const char* san_str = ("DNS:" + domain).c_str(); ext = X509V3_EXT_conf_nid(nullptr, &ctx, NID_subject_alt_name, san_str); X509_add_ext(cert, ext, -1); X509_EXTENSION_free(ext); // 6. 使用CA私钥对证书进行签名 X509_sign(cert, ca_key, EVP_sha256()); return {cert, pkey}; } };关键点解析:
- 密钥与证书对象:每个动态证书都需要自己独立的密钥对(
EVP_PKEY)和证书结构(X509)。 - 主题与颁发者:证书的
Subject.CN通常设置为请求的域名,而Issuer必须设置为我们的CA证书的主题,这样证书链才能建立。 - 主题备用名称:这是至关重要的一步。现代浏览器(如Chrome 58+)主要依赖
Subject Alternative Name扩展来验证域名,而不是Subject.CN。如果不设置SAN,即使证书被信任,浏览器也会报错“证书与域名不匹配”。 - 签名:使用CA的私钥(
ca_key)和摘要算法(如SHA256)对证书进行签名,完成权威性背书。
3.3 基于cpp-httplib的代理服务器框架
有了证书签发能力,我们就可以搭建代理服务器了。cpp-httplib使得搭建一个支持SSL的服务器变得异常简单。
#include "httplib.h" #include "certificate_generator.h" // 我们上面实现的类 int main() { CertificateGenerator cert_gen; if (!cert_gen.loadCA("ca.crt", "ca.key")) { std::cerr << "Failed to load CA certificate or key!" << std::endl; return -1; } // 创建SSL服务器,需要服务器证书和私钥。 // 注意:这里初始化的证书是用于代理服务器自身身份(比如它的管理页面), // 不是用于动态签发的。我们可以用一个固定的域名证书,或者临时生成一个。 httplib::SSLServer svr("server.crt", "server.key"); // 1. 处理HTTP代理请求(非CONNECT的普通HTTP请求) svr.Get(".*", [&](const httplib::Request& req, httplib::Response& res) { // 这里实现普通HTTP请求的转发和拦截 std::string target_host = req.get_header_value("Host"); // ... 使用httplib::Client转发请求,处理响应 ... }); svr.Post(".*", [&](const httplib::Request& req, httplib::Response& res) { // 类似地处理POST等其它方法 }); // 2. 处理HTTPS隧道请求(CONNECT方法) svr.set_pre_routing_handler([&](const httplib::Request& req, httplib::Response& res) -> bool { if (req.method == "CONNECT") { // 这是建立HTTPS隧道的关键请求 std::string host_port = req.path; // 例如 “www.example.com:443” // 解析出主机名和端口 // ... 解析逻辑 ... // 动态生成该主机名的证书 auto [cert, pkey] = cert_gen.generateCertForDomain(hostname); // 将证书和私钥转换为内存BIO,以便cpp-httplib使用 // ... 转换逻辑 ... // 这里需要“劫持”连接,创建一个新的SSL连接给客户端,使用动态生成的证书。 // cpp-httplib的默认流程不直接支持在CONNECT后动态切换证书。 // 因此,我们需要更底层的处理:接受CONNECT请求后,发送200 Connection Established, // 然后从原始socket中提升为SSL连接,并指定新的证书上下文。 // 这涉及到对cpp-httplib内部socket和SSL上下文的管理,是最大的难点。 // 伪代码:示意关键步骤 // res.status = 200; // 发送成功响应 // 从req中获取底层socket // 创建新的SSL_CTX,并设置动态证书和私钥 // 将socket包装成SSL*连接 // 开始双向数据转发循环 // 由于篇幅,此处不展开完整代码,下文会详述。 return true; // 告诉框架我们已经处理了这个请求,不再走默认路由。 } return false; // 其他请求继续由默认路由处理 }); svr.listen("0.0.0.0", 8888); return 0; }这个框架勾勒出了核心结构:一个处理普通HTTP请求的路由,和一个拦截CONNECT方法以建立HTTPS隧道的预处理句柄。难点在于CONNECT处理中,如何将原始的TCP连接升级为使用我们动态证书的SSL连接。
4. HTTPS隧道拦截的核心实现
4.1 CONNECT请求的处理与隧道建立
当浏览器发起CONNECT www.example.com:443 HTTP/1.1请求时,代理需要做以下几件事:
- 解析目标:从请求路径中提取主机名(
www.example.com)和端口(443)。 - 响应建立:向客户端发送
HTTP/1.1 200 Connection Established\r\n\r\n。发送完这个响应后,后续的数据将不再是HTTP协议,而是原始的TCP数据流。 - 接管Socket:此时,与客户端的TCP连接(Socket)仍然开放。我们需要获取到这个原始的socket文件描述符。
- 创建服务端SSL上下文:使用OpenSSL API,创建一个新的
SSL_CTX,并将动态为该域名生成的证书和私钥设置到这个上下文中。 - SSL握手:基于原始的socket和新的
SSL_CTX,创建一个SSL*对象,并调用SSL_accept()与客户端进行TLS握手。此时,客户端验证的正是我们动态签发的证书。 - 连接目标服务器:同时,使用另一个
httplib::SSLClient(或普通的Client如果目标是HTTP)与真实的目标服务器www.example.com:443建立连接。这个连接使用服务器真实的证书。 - 双向数据转发:现在有两个活跃的连接:
client_ssl_conn(客户端-代理) 和server_conn(代理-服务器)。我们需要在两个连接之间进行非阻塞的、双向的数据转发。从client_ssl_ssl读取的数据,解密后是明文HTTP请求,我们可以进行记录或修改,然后通过server_conn加密发送出去;反之亦然。
// 简化的核心转发循环伪代码 void tunnel_forward(SSL* client_ssl, httplib::Client& server_client) { int client_fd = SSL_get_fd(client_ssl); // 假设server_client能提供与服务端的连接socket fd int server_fd = get_server_socket_from_client(server_client); fd_set readfds; char buffer[16 * 1024]; while (true) { FD_ZERO(&readfds); FD_SET(client_fd, &readfds); FD_SET(server_fd, &readfds); int max_fd = std::max(client_fd, server_fd) + 1; int activity = select(max_fd, &readfds, nullptr, nullptr, nullptr); if (activity < 0) { break; } // 客户端 -> 服务器 if (FD_ISSET(client_fd, &readfds)) { int bytes_read = SSL_read(client_ssl, buffer, sizeof(buffer)); if (bytes_read <= 0) { break; } // 连接关闭或错误 // 此处buffer中是解密后的明文HTTP请求数据! log_request(buffer, bytes_read); // 可选:修改buffer中的数据 server_client.send_raw_data(buffer, bytes_read); // 需要自定义发送方法 } // 服务器 -> 客户端 if (FD_ISSET(server_fd, &readfds)) { int bytes_read = recv(server_fd, buffer, sizeof(buffer), 0); if (bytes_read <= 0) { break; } // 此处buffer中是来自服务器的原始TLS加密数据(如果server_conn是SSL)或明文HTTP响应(如果是HTTP) // 如果是明文响应,可以记录或修改 log_response(buffer, bytes_read); SSL_write(client_ssl, buffer, bytes_read); } } }4.2 请求与响应的拦截与修改点
在双向转发循环中,我们获得了拦截和修改数据的绝佳机会:
- 请求拦截:在
SSL_read(client_ssl)之后,我们得到的是完整的明文HTTP请求(包括方法、URL、Headers、Body)。我们可以:- 记录:将请求完整地记录到日志文件或数据库,用于调试或审计。
- 修改:修改请求头(如添加、删除、修改
User-Agent,Cookie等),甚至修改请求体(对POST数据做处理)。例如,可以全局添加一个认证头。 - 阻断:根据规则(如黑名单URL)直接返回一个自定义的HTTP响应,而不转发到真实服务器。
- 响应拦截:在从服务器连接
recv到数据后(如果是HTTPS连服务器,则需要先解密,这要求代理也作为客户端与服务器完成TLS握手并解密),我们得到的是明文HTTP响应。我们可以:- 记录:记录状态码、响应头和响应体。
- 修改:修改响应内容,例如注入JavaScript脚本、修改HTML内容、替换图片资源等。
- 缓存:实现响应缓存逻辑,对相同的请求直接返回缓存内容。
重要提示:修改HTTPS响应的内容需要特别小心,可能会破坏响应的完整性(如Content-Length, Transfer-Encoding)或导致数字签名失效。对于非文本内容(如图片、视频),通常只记录不修改。
5. 项目集成、编译与测试
5.1 工程组织与依赖管理
一个完整的项目目录结构可能如下所示:
mitm_proxy/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── certificate_generator.cpp │ └── certificate_generator.h ├── certs/ │ ├── ca.crt │ ├── ca.key │ └── server.crt # 代理服务器自身静态证书 ├── third_party/ │ └── httplib.h └── build/CMakeLists.txt需要配置OpenSSL库:
cmake_minimum_required(VERSION 3.10) project(MITMProxy) set(CMAKE_CXX_STANDARD 11) # 查找OpenSSL find_package(OpenSSL REQUIRED) # 包含头文件 include_directories(${OPENSSL_INCLUDE_DIR} ./src ./third_party) # 添加可执行文件 add_executable(mitm_proxy src/main.cpp src/certificate_generator.cpp) # 链接OpenSSL库 target_link_libraries(mitm_proxy ${OPENSSL_LIBRARIES} pthread)5.2 编译与运行
# 在项目根目录 mkdir build && cd build cmake .. make -j4 # 生成可执行文件 mitm_proxy ./mitm_proxy代理服务器将在0.0.0.0:8888启动。
5.3 客户端配置与测试验证
- 配置系统代理:将系统的HTTP/HTTPS代理设置为
127.0.0.1:8888。或者只为特定浏览器配置。 - 访问HTTPS网站:用浏览器访问任何一个HTTPS网站,例如
https://httpbin.org/anything。 - 观察证书:首次访问时,浏览器可能会提示证书不安全(因为是我们签发的)。由于我们已经将
ca.crt导入为受信任的根证书,这个警告应该消失,或者点击“高级”->“继续访问”即可。查看证书详情,你会发现颁发者是“MyProxy Root Certificate”。 - 验证拦截:查看代理服务器的控制台输出,你应该能看到解密的HTTP请求和响应日志。例如:
[REQ] GET https://httpbin.org/anything Headers: Host: httpbin.org, User-Agent: Mozilla/5.0... [RES] 200 OK Headers: content-type: application/json... Body: {"args":{}, "headers":{"Host":"httpbin.org", ...}, "url":"https://httpbin.org/anything"} - 测试修改功能:你可以在代码的转发循环中添加逻辑,例如修改所有请求,添加一个
X-Proxied-By: MyMitmProxy的请求头,然后在httpbin.org的响应中查看headers字段,确认该头已成功添加。
6. 常见问题、安全考量与进阶方向
6.1 开发与调试中的典型问题
浏览器提示“证书无效”或“NET::ERR_CERT_AUTHORITY_INVALID”
- 原因:根证书
ca.crt未正确导入或未受信任。 - 排查:检查证书是否导入到了“受信任的根证书颁发机构”。在macOS钥匙串中,需要双击导入的证书,展开“信任”选项,将“使用此证书时”设置为“始终信任”。
- 技巧:使用浏览器开发者工具的“安全”标签页查看证书链,确保证书路径指向你的CA证书。
- 原因:根证书
浏览器提示“证书与域名不匹配”
- 原因:动态生成的证书缺少
Subject Alternative Name扩展,或SAN中未包含请求的域名。 - 解决:确保在
generateCertForDomain函数中正确添加了NID_subject_alt_name扩展,并且值为DNS:example.com。
- 原因:动态生成的证书缺少
代理服务器崩溃或内存泄漏
- 原因:OpenSSL对象(
X509,EVP_PKEY,SSL_CTX等)未正确释放;多线程环境下证书生成器竞争条件。 - 解决:使用RAII思想封装OpenSSL对象;对
CertificateGenerator的签发方法加锁(如果多线程访问);使用Valgrind等工具检测内存泄漏。
- 原因:OpenSSL对象(
连接速度慢或转发卡顿
- 原因:
select循环效率问题;证书生成耗时(RSA 2048生成较慢);日志写入阻塞。 - 优化:对于高并发,考虑使用
epoll或kqueue替代select;可以预生成一批证书或使用ECC密钥(生成更快);将日志改为异步写入。
- 原因:
6.2 安全警告与负责任的使用
这是一个强大的工具,但必须负责任地使用:
- 仅用于合法用途:仅在你拥有完全控制权的网络、设备或明确获得授权的环境下使用。例如,调试你自己的应用程序、分析公司内部API流量、安全教学研究。
- 切勿用于非法拦截:拦截他人的网络通信可能违反法律和隐私条例。
- 理解风险:导入自签名根证书会降低你设备的安全性。恶意软件可能利用这一点进行攻击。建议在虚拟机或专用测试设备上进行,使用后及时移除根证书。
- 不要处理敏感信息:避免在日志中记录密码、银行卡号等敏感信息。如果必须记录,确保日志文件被妥善加密和保护。
6.3 性能优化与功能扩展
- 证书缓存:为每个域名首次访问生成证书后,将其缓存到内存或磁盘。下次相同域名访问时直接复用,避免重复的密钥生成和签名计算,极大提升性能。
- 连接池:对于频繁访问的服务器,可以维护一个到后端服务器的连接池,避免为每个客户端请求都建立新的TCP/TLS连接。
- 支持WebSocket:扩展代理逻辑,使其能够正确转发和拦截WebSocket流量,这需要解析
Upgrade: websocket头并处理二进制帧。 - 规则引擎:实现一个配置文件或规则引擎,允许用户通过规则(基于URL、内容类型、关键字)来定义哪些请求需要被记录、修改或阻断。
- UI管理界面:使用
cpp-httplib再暴露一个管理用的HTTP API,甚至集成一个简单的Web界面,来实时查看流量日志、管理规则、清空缓存等。
这个项目从原理到实现,涵盖了网络、安全、密码学和应用开发的多个层面。亲手实现一遍,你会对HTTPS的“安全”二字有更立体和深刻的认识——它既是保护用户的盾,其原理也可以成为开发者手中的工具。关键在于使用工具的人。
