☰
Windows下基于OpenSSL的HTTPS代理与MITM证书管理实战指南
2026/10/22 6:17:13 网站建设 项目流程

手头接到一个需求:在Windows环境下用C++写一个HTTPS代理,要能抓HTTPS明文流量、能对指定域名做请求转发,还要在公司内部分发使用。我第一反应是直接用现成方案,比如Nginx反向代理或者Charles的团队版,但需求里有几个定制点是现成工具搞不定的:一是要在自有程序里嵌入代理逻辑,二是要做证书的白名单/自动签发,三是需要和内部的日志系统打通。于是决定自己动手,技术栈锁定在OpenSSL + cpp-httplib,这两样都是Windows下非常成熟的东西。

这个标题里的“HTTPS代理”我按实际场景拆成两部分:对外是传统代理的CONNECT隧道,对内是MITM解密后再转发。文章会把整条链路拆开讲清楚,重点放在证书管理上——很多人做这类项目的第一步不是代码,而是被证书整崩溃。我会把证书的痛点、生成方式、动态签发思路、Windows证书库的坑全部记录下来,同时给出一个可以跑起来的核心代码骨架。

1. 项目背景与整体设计思路

1.1 这个代理到底用来做什么

先说清楚我这里的“代理”是什么形态。最典型的需求来自移动端调试:手机连上WiFi后手动配一个HTTP代理,后台服务的流量全部走到电脑上,我需要看到明文HTTP请求内容。如果目标是普通HTTP站点,代理拿到请求直接转发就行;但如果目标是HTTPS站点,代理看到的是一个加密连接,必须做中间人解密才能查看明文。

另一个真实场景是内网流量审计。比如公司内部办公网络要求对某些关键业务的访问做记录,所有终端都指向一台代理网关,这台网关需要把HTTPS连接解密后记录请求路径、响应码、延时,再原样转发到目标服务器。这种场景下,终端必须信任代理自建的CA根证书,否则TLS握手会直接失败。

所以这个项目的核心功能很明确:接收客户端的CONNECT请求,与客户端完成TLS握手(出示动态签发的域名证书),解密HTTP请求,再用上游连接转发到目标服务器,最后把响应加密回传。听起来很简单,但每一步都有细节,尤其是证书管理,处理不好就是连环坑。

1.2 为什么是OpenSSL + cpp-httplib

选型时我对比过几条路线。用Nginx做第三方模块或者stream代理很强大,但要嵌入自定义逻辑就得写模块,开发成本不低,而且Windows下调试Nginx模块不直观。用Python写代理很方便,但性能一般,部署到客户环境还要额外装解释器。最后锁定C++。

OpenSSL不需要多解释,TLS的事实标准,HTTPS代理里的证书生成、TLS握手、动态签发全部依赖它。cpp-httplib则是这两年C++ HTTP编程里很顺手的轻量库,单头文件、跨平台、默认就带OpenSSL支持,在转发环节可以用它作为HTTP客户端,把解密后的请求重新发到目标站点。它还有一个好处:自带SSLServer和SSLClient封装,如果只是做一个反向代理形态的HTTPS服务,几乎不用自己写底层socket。

这个组合的合理性在于分工明确:OpenSSL负责TLS/证书体系这类底层协议能力,cpp-httplib负责HTTP语义和连接管理。因为整个代理不是一个简单的Socket转发程序,它必须理解HTTP请求方法、头部、响应状态,这些靠OpenSSL自己去解析会很痛苦,cpp-httplib恰好把这个层次补齐了。

1.3 一个HTTPS代理的完整工作链路

把整个链路画在脑子里,是这样的:

  • 客户端(浏览器或手机App)配置代理地址为localhost:8888。
  • 客户端发起HTTPS访问时,先向代理发送CONNECT example.com:443。
  • 代理与目标服务器(此时不建立连接)协商,而是直接与客户端在已有TCP连接上做TLS握手,此时代理出示动态签发的example.com证书。
  • 客户端校验证书(信任链最终落到代理的自建CA),成功后,加密通道内是明文HTTP请求。
  • 代理用OpenSSL的SSL_read读明文请求,再用cpp-httplib向真正的目标服务器发起HTTPS请求。
  • 拿到目标响应后,代理把HTTP响应写回TLS通道,客户端解密后看到的就是正常响应。

这条链路里有一个容易被忽视的点:代理在TLS握手阶段向客户端出示的证书,必须与客户端请求的域名完全匹配。所以证书体系必须支持按域名动态签发,而不是手工为每个站点提前做一张证书。这也是后面“证书管理避坑指南”的重头戏。

2. Windows环境准备与编译配置

2.1 安装OpenSSL

Windows下装OpenSSL,我推荐用vcpkg统一管理,省去环境变量和路径的麻烦。在PowerShell里执行:

git clone https://github.com/microsoft/vcpkg.git cd vcpkg .\bootstrap-vcpkg.bat .\vcpkg install openssl:x64-windows

安装完成后,vcpkg会把OpenSSL的include目录和lib目录以VCPKG_ROOT环境变量暴露出来。工程里直接引用$env:VCPKG_ROOT\installed\x64-windows\include就可以。

这里有个Windows特有的小坑:如果项目里同时用了WinSock2和OpenSSL头文件,必须确保winsock2.h在windows.h之前包含,否则会出现一堆宏重定义冲突。我在代码里都写在最前面:

#define _WINSOCK_DEPRECATED_NO_WARNINGS #include <winsock2.h> #include <ws2tcpip.h> #include <openssl/ssl.h> #include <openssl/err.h>

2.2 把cpp-httplib接进工程

cpp-httplib的接入方式极为简单,它本身就是一个httplib.h头文件。我直接把头文件放进项目的third_party目录,然后CMake里设置好include路径,不需要额外链接库。但要启用SSL支持,需要保证它能看到OpenSSL头文件,并且最终链接libssl.lib和libcrypto.lib。

在CMakeLists.txt里我是这样引用的:

cmake_minimum_required(VERSION 3.20) project(https_proxy) set(CMAKE_CXX_STANDARD 17) set(VCPKG_ROOT "$ENV{VCPKG_ROOT}") set(OPENSSL_ROOT_DIR "${VCPKG_ROOT}/installed/x64-windows") add_executable(https_proxy src/main.cpp) target_include_directories(https_proxy PRIVATE src third_party ${OPENSSL_ROOT_DIR}/include ) target_link_directories(https_proxy PRIVATE ${OPENSSL_ROOT_DIR}/lib ) target_link_libraries(https_proxy PRIVATE ws2_32 crypt32 libssl libcrypto )

ws2_32和crypt32是Windows下OpenSSL必须的两个系统库,少了它们编译能过但链接会报一堆未解析符号。

2.3 编译与链接的常见报错

第一次编译时最容易撞上的问题:ssl.h找不到、libssl.lib找不到、或者是链接时提示无法打开libcrypto.lib。基本都是路径问题,检查VCPKG_ROOT是否设置、架构是否选的x64、CMake有没有重新生成,就能解决。

另外一个坑是运行时找不到libssl-3-x64.dll。这是OpenSSL动态库的问题,启用OPENSSL_DLL后程序运行时需要动态库在PATH里,这时有三种处理方式:一是触发程序时把对应的DLL目录加入PATH;二是将DLL拷贝到可执行文件相同目录;三是改用OPENSSL_USE_STATIC_LIBS编译成静态库。我为了分发方便选的静态库,配置如下:

set(OPENSSL_USE_STATIC_LIBS ON) set(OPENSSL_MSVC_STATIC_RUNTIME ON)

这样生成的exe不再依赖OpenSSL的DLL,在客户机器上省掉一大串环境变量问题。

3. 证书体系:从CA到域名证书

3.1 第一步:生成自建CA

中间人代理的信任根基是自建CA根证书。只要把这张根证书装进客户端的“受信任的根证书颁发机构”列表,代理后续签发的所有域名证书都会被客户端自动信任。

生成CA根证书用两行命令:

openssl genrsa -out ca.key 2048 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=Local Debug CA/O=LocalDev"

解释一下参数:genrsa生成2048位RSA私钥;req -x509表示直接自签一张有效期为10年的根证书;-nodes表示私钥不加密,方便程序运行时加载。

一个容易忽略的点:CA的密钥长度。有人图省事用1024位,现在主流浏览器和操作系统会认为强度不够,直接拒绝信任。2048是底线,4096更稳妥。这台训练用的样例我用了2048,实际生产内网建议4096。

3.2 第二步:签发域名证书,补齐SAN

生成一张给具体域名用的证书,顺序是:先生成服务器私钥,再生成证书签名请求,最后用CA签发。命令如下:

openssl genrsa -out server.key 2048 openssl req -new -key server.key -out server.csr -subj "/CN=example.com" cat > server.ext <<EOF subjectAltName=DNS:example.com,DNS:*.example.com,IP:127.0.0.1 EOF openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 825 -sha256 -extfile server.ext

这里的重点在server.ext。Chrome从58版本开始,对不含SAN(Subject Alternative Name)的证书一律报错,即使CN匹配也没用。很多人在这一步被卡死:明明证书里CN已经写成example.com,浏览器仍然提示“证书无效”,原因就是没有SAN。

此外,证书有效期我特意写的825天,这是Apple和Google对公共可信证书的规定上限,自建CA不受此约束,但把有效期控制在这个范围可以避免一些比较新的规范检查报错。

3.3 把根证书装进Windows证书库

证书生成好了,需要让系统信任这张自建CA。Windows下有两种方式,一种是用图形界面的“导入”向导,另一种是命令行:

certutil -addstore -f Root ca.crt

注意,这里必须导入到“根证书颁发机构”而不是“中间证书颁发机构”。如果导入到中间证书颁发机构,浏览器会认为这张CA是一个下级CA,无法直接信任它签发的域名证书,则握手失败。

装好之后,在浏览器里访问https://127.0.0.1测试,应该能看到证书链是完整的且浏览器地址栏出现小锁头。如果这里没过,后面代理代码写得再好也是白搭。

3.4 动态签发证书的思路

前面是静态证书,适用于固定测试域名。但代理要对接任意域名,不可能提前知道用户会访问哪些站,所以必须按需动态签发:代理收到CONNECT example.com:443时,立即为example.com生成一张证书,用CA签名,然后用于当前的TLS握手。

动态签发在代码里通过OpenSSL的API完成,核心流程是:

X509* issue_cert(const std::string& host, EVP_PKEY* server_pubkey, X509* ca_cert, EVP_PKEY* ca_key) { X509* x509 = X509_new(); // 设置序列号 ASN1_INTEGER* serial = ASN1_INTEGER_new(); ASN1_INTEGER_set(serial, next_serial++); X509_set_serialNumber(x509, serial); ASN1_INTEGER_free(serial); // 设置有效期 X509_gmtime_adj(X509_get_notBefore(x509), 0); X509_gmtime_adj(X509_get_notAfter(x509), 60L * 60 * 24 * 365); // 设置公钥,这里统一使用server.key对应的公钥 X509_set_pubkey(x509, server_pubkey); // 设置主题CN X509_NAME* name = X509_get_subject_name(x509); X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char*)host.c_str(), -1, -1, 0); X509_set_subject_name(x509, name); // 设置SAN GENERAL_NAMES* gens = sk_GENERAL_NAME_new_null(); GENERAL_NAME* gen = GENERAL_NAME_new(); ASN1_IA5STRING* ia5 = ASN1_IA5STRING_new(); ASN1_STRING_set(ia5, host.c_str(), host.size()); GENERAL_NAME_set0_value(gen, GEN_DNS, ia5); sk_GENERAL_NAME_push(gens, gen); X509_add_ext(x509, X509V3_EXT_nconf_nid(NULL, NULL, NID_subject_alt_name, gens), -1); // 用CA私钥签名 X509_sign(x509, ca_key, EVP_sha256()); return x509; }

这段代码的核心思想:所有动态证书共用同一个server私钥和公钥,只有CN和SAN不同。对客户端来说,每张证书都是合法签发的,而对代理来说,只需要维护一份server.key即可。

这里有一个工程上的缓存策略:同一个域名不要反复签发证书,把证书按域名放入内存缓存,TTL设30分钟到1小时。一方面降低CPU开销,另一方面避免大量无意义证书导致浏览器缓存异常。我实际跑下来,缓存后的TLS握手性能影响几乎可以忽略。

4. 代理核心实现:CONNECT、TLS与转发

4.1 监听端口与CONNECT解析

代理入口是一个在8888端口监听的TCP服务器。收到客户端连接后,第一个动作是读取HTTP请求头,判断方法。如果第一行以CONNECT开头,说明客户端要建立HTTPS隧道。

CONNECT请求的格式是这样的:

CONNECT example.com:443 HTTP/1.1 Host: example.com:443

解析目标主机和端口时,要注意IPv6地址的括号形式,比如CONNECT [::1]:443,直接按冒号分割会拆错。我用了一个处理函数,先判断是否有括号,再提取端口。

解析完成后,向客户端返回:

HTTP/1.1 200 Connection Established

这时客户端就开始在同一连接上发起TLS握手。

4.2 TLS握手与动态选择证书

TLS握手阶段,代理要用与目标域名匹配的证书来做SSL_accept。由于动态证书是按域名生成的,所以必须先解析CONNECT拿到的域名,再根据域名从缓存取证书,最后创建SSL对象进行握手。

核心逻辑如下:

void handle_connect(SOCKET client_sock, const std::string& host) { // 取出或动态生成该主机的证书 X509* cert = cert_cache.get_or_issue(host); // 创建 SSL 对象并绑定证书 SSL* ssl = SSL_new(ssl_ctx); SSL_set_fd(ssl, (int)client_sock); SSL_use_certificate(ssl, cert); SSL_use_PrivateKey(ssl, server_pkey); if (SSL_accept(ssl) != 1) { SSL_free(ssl); closesocket(client_sock); return; } // 握手成功后,SSL_read 读取的就是解密后的明文HTTP请求 char buf[16384]; int n = SSL_read(ssl, buf, sizeof(buf)); std::string request_data(buf, n); // 交给转发模块 forward_http_request(ssl, host, request_data); SSL_free(ssl); closesocket(client_sock); }

这里需要注意:SSL_use_certificate和SSL_use_PrivateKey要在SSL_accept前调用,否则OpenSSL会默认使用SSL_CTX里的证书。如果用固定证书的简单模式,可以在初始化SSL_CTX时就通过SSL_CTX_use_certificate_file加载,这样每个连接不用重复设置。

4.3 用cpp-httplib完成上游请求转发

TLS握手完成,代理手里拿到的是解密后的HTTP请求。这段明文请求会走cpp-httplib向上游服务器发起真实请求。

由于cpp-httplib既支持HTTP也支持HTTPS,这里根据目标端口判断创建httplib::Client还是httplib::SSLClient。转发函数的核心结构如下:

void forward_http_request(SSL* client_ssl, const std::string& host, int port, const std::string& raw_request) { httplib::Headers headers; std::string method, path, version; parse_first_line(raw_request, method, path, version); parse_headers(raw_request, headers); if (port == 443) { httplib::SSLClient cli(host); cli.enable_server_certificate_verification(true); auto res = cli.Get(path.c_str(), headers); if (res) { std::string response = build_http_response(res); SSL_write(client_ssl, response.data(), (int)response.size()); } } else { httplib::Client cli(host, port); auto res = cli.Get(path.c_str(), headers); if (res) { std::string response = build_http_response(res); SSL_write(client_ssl, response.data(), (int)response.size()); } } }

示例里只写了GET,实际需要把POST、PUT、DELETE都按method换成对应的cli.Post、cli.Put等。网络库没有通用的“按方法名动态调用”的反射机制,通常用if/else把全部分支列全。

构造响应头时,httplib::Response里的headers已经包含Content-Length,不需要自己再算一遍,否则会把响应体长度覆盖错,导致客户端一直等不到结束:

std::string build_http_response(const httplib::Result& res) { std::string head = "HTTP/1.1 " + std::to_string(res->status) + " " + res->reason + "\r\n"; for (const auto& kv : res->headers) { head += kv.first + ": " + kv.second + "\r\n"; } head += "\r\n"; return head + res->body; }

4.4 双向TLS场景怎么处理

有的内部服务要求客户端证书,即双向TLS。代理在转发时,也要向目标服务器出示自己的客户端证书。

我通过向SSL_CTX加载客户端证书来支持这种场景:

SSL_CTX* ctx = SSL_CTX_new(SSLv23_client_method()); SSL_CTX_use_certificate_file(ctx, "client.crt", SSL_FILETYPE_PEM); SSL_CTX_use_PrivateKey_file(ctx, "client.key", SSL_FILETYPE_PEM);

然后用这份ctx创建SSL对象并连接目标服务器。

双向TLS在实际业务中有一个很典型的场景:代理后面是数据库管理接口或云服务管理接口,部门要求所有访问者必须携带有效客户端证书。如果没有这一步,代理能解密客户端流量,但会被上游服务器直接拒绝。

5. 常见问题与避坑实录

5.1 浏览器报错排查询问题速查表

我整理了一张排查表,都是做这个项目最常遇到的问题:

现象根因解决
Chrome提示NET::ERR_CERT_AUTHORITY_INVALID根证书没有装进系统信任库用certutil安装ca.crt到Root存储
Chrome提示NET::ERR_CERT_COMMON_NAME_INVALID证书的SAN缺少当前域名在extfile中添加subjectAltName
Firefox单独报错,但Chrome正常Firefox使用NSS证书库,不读Windows信任库在Firefox设置里单独导入ca.crt
SSL_accept返回失败,错误码503或506证书与私钥不匹配检查是否错配crt与key
客户端一直转圈最后超时转发后响应头Content-Length不对使用httplib返回的原始header,不重新计算
动态证书会闪一下错误,刷新后正常证书缓存过期策略有问题重新生成证书时保持相同的序列号规律,避免缓存冲突

这些错误里,第一个最隐蔽。很多人做代理时,自建CA只导入到“个人”证书区,Chrome依然不信任。必须导入到“受信任的根证书颁发机构”。

5.2 cpp-httplib编译与运行时的坑

cpp-httplib本身很省心,但配合OpenSSL用的时候会撞几个编译开关。

最典型的:编译时没有定义CPPHTTPLIB_OPENSSL_SUPPORT,导致SSLClient和SSLServer根本不存在,编译直接报类未定义。必须在CMake里加:

target_compile_definitions(https_proxy PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

另一个坑是连接池问题。httplib::SSLClient默认是短连接,每次cli.Get结束后连接就释放了。如果代理的QPS比较高,每次都新建TLS连接,开销会很可观。我在实现里给每个目标域名维护了一个长期存活的SSLClient实例,通过cli.set_keep_alive(true)开启长连接。

关于set_keep_alive(true),实际上cpp-httplib自带的连接管理会在同一线程内复用连接,跨线程使用同一个Client实例时要注意线程安全问题,我直接在每个域名一个IPC锁的粒度上做了保护。这里不展开线程模型,只提醒一句:没有多线程,代理速度起不来。

5.3 证书管理最容易被忽略的细节

这一节是全文最有价值的经验。

第一件事:私钥权限。OpenSSL生成的server.key和ca.key都是明文密钥,在Windows上如果存到项目目录且没有做权限限制,很容易被别的进程读走。我的处理是,把私钥文件放到只有管理员可读的目录,或者干脆在程序启动时从加密配置文件里加载密钥,不在磁盘落明文。

第二件事:证书序列号。自建CA签发证书时,如果每次启动后next_serial都从1开始,签出来的证书序列号会重复。虽然自建CA没有RFC对序列号强制唯一的要求,但某些Windows版本的证书服务会检测到序列号冲突,导致导入时报错。我用一个持久化文件记录当前序列号,每次签发前读取、签发后写入。

第三件事:系统时间。证书有效期判断依赖本机时间,如果Windows时间不准,特别是差了几分钟到几小时,某些加密站点会直接失败。这在虚拟机和休眠恢复的场景里经常出现,我排查过一次,客户机器休眠后恢复,时间偏了一个小时,所有TLS请求全部报错,重启机器后恢复。

第四件事:动态证书的缓存清理。内存缓存的证书如果长时间不清理,会吸纳大量域名。我自己看下来,一个代理跑了两个月,缓存里堆了几千张证书,内存占用其实还好,但每次查找证书时字符串比较的CPU开销会增加。建议对缓存加一个LRU淘汰,最多保留1000张,超出的自动释放。

第五件事:Windows系统对证书文件的编码敏感。生成的自签证书是PEM格式,而有些Windows程序导入PEM格式CA时,会遇到扩展名不是.crt就拒绝导入的情况。把ca.crt改名为ca.cer或ca.pem在部分系统上导入行为不一致。稳妥的做法是,先在本地生成完证书后用openssl x509 -outform der转一份DER格式,Windows对DER格式的兼容性更好。

5.4 性能与并发的一些实测心得

代理运行一段时间之后,会遇到高并发场景下握手成功率下降的问题。我这里实测过,Windows上默认创建的SSL_CTX没有开启会话缓存,每次TLS握手都要完整做一次RSA密钥交换,非常耗时。开启TLS会话复用可以明显降低握手成本,只需加一行:

SSL_CTX_set_session_cache_mode(ssl_ctx, SSL_SESS_CACHE_SERVER);

配合设置会话超时时间,比如SSL_CTX_set_timeout(ssl_ctx, 300),让会话在5分钟内可复用。

并发模型上,我第一版直接用的是“每连接一个线程”,Windows下创建线程成本较高,在1000并发时线程切换开销明显。后来改成固定线程池加队列的模式,线程数等于CPU核心数两倍,性能提升非常明显。如果你刚开始做,先用简单线程模型跑通功能,再优化并发,避免过早陷入性能泥潭。

另外有个值得注意的点:代理的日志。解密后能看到明文请求,意味着也看到了明文密码、Token等敏感信息。这里必须给日志系统加上脱敏机制,至少对Authorization头、Cookie、body里的password字段做掩码处理。做这个项目的都会踩这个坑,我第一版把完整的请求头直接打进日志文件,结果一条测试请求里带了内网账号密码,后来清理了日志并重新设计了日志格式,才算解决。


这个项目做下来,最大的感悟是:HTTPS代理的代码本身并不难,难在把证书和信任体系想清楚。只要动态证书签发和缓存这块理顺,整个代理就有了稳固的地基。cpp-httplib在转发层帮了大忙,它把HTTP报文的构造、解析、连接管理都封装得很干净,让我能集中精力处理TLS协议细节。如果后续要在团队内推广,可以考虑把动态证书的缓存策略再加一层持久化,代理重启后可以直接复用,减少第一次访问时的握手等待时间。另外,日志脱敏和权限控制要越早考虑越好,这类工具一旦接入业务环境,数据安全就是第一优先级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询