Windows下基于OpenSSL的HTTPS代理与MITM证书管理实战指南
手头接到一个需求在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 安装OpenSSLWindows下装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.h2.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 /CNLocal Debug CA/OLocalDev解释一下参数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 /CNexample.com cat server.ext EOF subjectAltNameDNS: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版本开始对不含SANSubject 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中添加subjectAltNameFirefox单独报错但Chrome正常Firefox使用NSS证书库不读Windows信任库在Firefox设置里单独导入ca.crtSSL_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协议细节。如果后续要在团队内推广可以考虑把动态证书的缓存策略再加一层持久化代理重启后可以直接复用减少第一次访问时的握手等待时间。另外日志脱敏和权限控制要越早考虑越好这类工具一旦接入业务环境数据安全就是第一优先级。