- 后端
- 网络
【免费下载链接】h2o
H2O - the optimized HTTP/1, HTTP/2, HTTP/3 server
本篇技术指南以 H2O 仓库内deps/ssl-conservatory/openssl/目录下的官方示例代码为核心,系统讲解在使用 OpenSSL 编写 HTTPS 客户端时如何正确执行服务端证书链验证与主机名(hostname)校验,覆盖核心 API、验证算法、跨平台编译(Linux / OS X / Windows)与错误处理,并对照 H2O 自身在 lib/common/socket.c 中的真实调用场景,帮助你写出安全、可复用的 OpenSSL 客户端代码。
一、背景:为什么需要专门的证书验证示例代码
SSL/TLS 客户端最常见的两个安全缺陷是:没有开启证书链验证(默认的 OpenSSL 客户端甚至不验证证书),以及验证了证书链却忘了校验主机名——攻击者只要能拿到任意一张受信任 CA 签发的证书(例如自己注册域名签发的证书),就能在中间人攻击中蒙混过关。
deps/ssl-conservatory/目录正是为此而设的"SSL 样本代码库"。项目级说明见 deps/ssl-conservatory/README.md,其中明确写道:其目标是"为正确实现 SSL 客户端的各种语言/框架提供文档完善且安全的示例代码"。openssl/子目录对应 C/OpenSSL 实现,ios/子目录则提供 iOS 端的证书锁定(certificate pinning)实现。
在openssl/目录中,除示例代码外还附有一份配套白皮书《Everything you've always wanted to know about certificate validation with OpenSSL (but were afraid to ask)》,PDF 文件位于 deps/ssl-conservatory/openssl/everything-you-wanted-to-know-about-openssl.pdf。源码头文件 openssl_hostname_validation.h 与示例程序 test_client.c 都明确要求读者在使用代码前先阅读该白皮书,因为它描述了代码的工作方式、正确用法与局限。该示例已在 Windows 7、OS X 与 Linux 上完成编译与测试(见 README.md)。
二、目录结构与两个核心文件
openssl/目录下的文件清单如下:
| 文件 | 作用 |
|---|---|
openssl_hostname_validation.c/.h | 主机名校验核心实现与头文件,可被任何 OpenSSL 客户端复用 |
test_client.c | 完整的 HTTPS 客户端示例:连接www.isecpartners.com:443并打印 HTTP GET 响应 |
Makefile | Unix 系(FreeBSD、Ubuntu、Cygwin)构建脚本 |
Makefile_mingw | Windows / MinGW 构建脚本 |
DigiCertHighAssuranceEVRootCA.pem | 示例所用信任锚(CA 根证书),供SSL_CTX_load_verify_locations()加载 |
everything-you-wanted-to-know-about-openssl.pdf | 配套白皮书 |
值得注意的一点是:这套代码并非"仓库里的摆设"。H2O 的 TLS 客户端代码在 lib/common/socket.c 中通过#include "../../deps/ssl-conservatory/openssl/openssl_hostname_validation.c"直接内联了主机名校验实现,并在握手完成后调用validate_hostname()(见下文第五节),说明该示例代码经受住了真实生产 HTTP 服务器的考验。
三、核心 API:validate_hostname()与HostnameValidationResult
主机名校验的对外接口非常简洁,只暴露一个函数(见 openssl_hostname_validation.h):
HostnameValidationResult validate_hostname(const char *hostname, const X509 *server_cert);hostname:客户端期望连接的服务端主机名;server_cert:从 TLS 握手拿到的服务端证书(X509 *,通常来自SSL_get_peer_certificate())。
返回类型HostnameValidationResult是一个枚举,共 5 个取值(见 openssl_hostname_validation.h):
| 枚举值 | 含义 |
|---|---|
MatchFound | 在证书中找到了与期望主机名匹配的名字 |
MatchNotFound | 证书中没有任何名字与期望主机名匹配 |
NoSANPresent | 证书中不存在 Subject Alternative Name(SAN)扩展(仅由 SAN 检查路径返回) |
MalformedCertificate | 证书中的某个主机名内嵌了 NUL 字符('\0'),视为畸形证书 |
Error | 入参为空或证书字段提取失败等内部错误 |
校验逻辑遵循 RFC 6125 的推荐顺序(见 openssl_hostname_validation.c):
- 首先在证书的Subject Alternative Name 扩展中查找匹配;
- 若 SAN 扩展不存在(返回
NoSANPresent),降级到 Subject 字段的Common Name(CN)中查找。
这是现代证书校验的标准做法:SAN 是签发服务器证书时的法定扩展,而 CN 只是历史遗留的降级路径。
3.1 SAN 检查:matches_subject_alternative_name()
该函数(见 openssl_hostname_validation.c)通过X509_get_ext_d2i(cert, NID_subject_alt_name, ...)提取 SAN 扩展:
- 若扩展不存在,返回
NoSANPresent; - 遍历
STACK_OF(GENERAL_NAME)中的每一项,只处理type == GEN_DNS的 DNS 名称(忽略 IP 地址等其他类型); - 只要任一 DNS 名称与期望主机名匹配成功(
validate_name返回MatchFound),立即终止循环并返回; - 遍历结束后若仍无匹配,返回
MatchNotFound; - 最后用
sk_GENERAL_NAME_pop_free()释放栈内存,避免泄漏。
3.2 CN 兜底:matches_common_name()
当 SAN 缺失时,该函数(见 openssl_hostname_validation.c)按以下步骤提取 CN:
X509_NAME_get_index_by_NID(X509_get_subject_name(cert), NID_commonName, -1)定位 CN 字段在 Subject 中的索引,找不到返回Error;X509_NAME_get_entry()取出该条目,再经X509_NAME_ENTRY_get_data()拿到 ASN.1 字符串;- 交由
validate_name()完成具体比较。
3.3 单条名称的比较规则:validate_name()
这是整个校验中最精细的部分(见 openssl_hostname_validation.c),它实现了几个关键安全细节:
- NUL 字符防护:先用
has_nul()检查证书中的名字是否内嵌'\0'。这是对历史上"前缀匹配 + NUL 截断"攻击(如www.attacker.com\0.trusted.com)的直接防御,发现即返回MalformedCertificate; - 域名末尾点归一化:如果期望主机名以
.结尾(FQDN 写法),比较前先去掉末尾点; - 通配符处理:仅当证书名字以
*.开头时,才跳过期望主机名的第一个标签段(第一个.之前的部分),且要求通配符名字本身长度大于 2(即至少是*.x形式)。例如证书*.example.com可匹配www.example.com,但不能匹配裸域example.com; - 大小写不敏感比较:通过
memeq_ncase()逐字节转小写后比较(DNS 域名本身不区分大小写); - 长度严格相等:
certname_len != hostname_len直接判为MatchNotFound,杜绝任何前缀/后缀的部分匹配。
四、示例客户端test_client.c的完整 HTTPS 流程
test_client.c 是一个可独立运行的 HTTPS 客户端,演示了"正确的 OpenSSL 客户端"应具备的完整步骤。其流程可拆解为如下八个阶段:
1. 初始化与 PRNG 检查
SSL_library_init(); SSL_load_error_strings(); if (RAND_status() != 1) { fprintf(stderr, "OpenSSL PRNG not seeded with enough data."); goto error_1; }OpenSSL 的 PRNG(伪随机数发生器)未获得足够熵时,随机数质量不可保证,直接影响密钥与随机挑战的强度,因此必须显式检查。
2. 创建 SSL 上下文并开启证书链验证
ssl_ctx = SSL_CTX_new(TLSv1_client_method()); SSL_CTX_set_verify(ssl_ctx, SSL_VERIFY_PEER, NULL);SSL_VERIFY_PEER是强制服务端提供证书并校验其证书链的开关——不设置它,下面的所有校验都形同虚设。
3. 加载信任库(CA 证书)
if (SSL_CTX_load_verify_locations(ssl_ctx, TRUSTED_CA_PATHNAME, NULL) != 1) { fprintf(stderr, "Couldn't load certificate trust store.\n"); goto error_2; }示例使用DigiCertHighAssuranceEVRootCA.pem作为信任锚。注意:这是"固定信任锚"的写法,与默认的系统信任库不同,属于最小化信任面的安全实践。
4. 限制密码套件
#define SECURE_CIPHER_LIST "RC4-SHA:HIGH:!ADH:!AECDH:!CAMELLIA" if (SSL_CTX_set_cipher_list(ssl_ctx, SECURE_CIPHER_LIST) != 1) goto error_2;该字符串表示:启用 RC4-SHA 与所有 HIGH 级套件,同时显式排除匿名 DH(!ADH,匿名套件不提供身份认证)、匿名 ECDH(!AECDH)与 Camellia。需要提醒的是,此清单是项目早期的示例产物,如今 RC4 已被业界废弃,实际生产代码应使用HIGH:!aNULL:!MD5之类更现代的配置。
5. 建立 SSL 连接并完成握手
sbio = BIO_new_ssl_connect(ssl_ctx); BIO_get_ssl(sbio, &ssl); BIO_set_conn_hostname(sbio, TARGET_SERVER); // "www.isecpartners.com:443" if (SSL_do_handshake(ssl) <= 0) { long verify_err = SSL_get_verify_result(ssl); if (verify_err != X509_V_OK) fprintf(stderr, "Certificate chain validation failed: %s\n", X509_verify_cert_error_string(verify_err)); else ERR_print_errors_fp(stderr); goto error_3; }这里展示了握手失败时区分错误原因的正确姿势:优先用SSL_get_verify_result()检查是否为证书链验证失败,并通过X509_verify_cert_error_string()输出人类可读的错误描述;若验证本身通过,才用ERR_print_errors_fp()打印 OpenSSL 错误队列。
6. 获取服务端证书
server_cert = SSL_get_peer_certificate(ssl); if (server_cert == NULL) { // 握手成功但服务端未提供证书——极可能使用了不安全的匿名密码套件,立即退出 goto error_4; }7. 主机名校验(关键一步)
if (validate_hostname(TARGET_HOST, server_cert) != MatchFound) { fprintf(stderr, "Hostname validation failed.\n"); goto error_5; }只有MatchFound才允许继续通信。这一步弥补了SSL_VERIFY_PEER的盲区——链验证只证明"证书由可信 CA 签发",主机名校验才证明"这张证书确实是发给目标服务器的"。
8. 发送请求并收尾
send_http_get_and_print(sbio); // BIO_puts 发送 "GET / HTTP/1.0",BIO_read 循环读取响应 // 依次释放 X509、关闭 SSL 通道、释放 BIO 链与 SSL_CTX、清理 OpenSSL 全局状态错误处理采用goto级联式清理(error_5 → error_4 → error_3 → error_2 → error_1),保证任意一步失败时所有已分配资源都被正确释放,这是 C 语言中常见的确定性清理模式。
五、H2O 项目中的真实调用:lib/common/socket.c
示例代码并非纸上谈兵。H2O 在 TLS 客户端握手的收尾阶段直接复用了这套实现:
- lib/common/socket.c 通过预处理器直接包含
openssl_hostname_validation.c; - 在 lib/common/socket.c 中,当
SSL_connect成功(ret == 1)且当前角色是客户端(!SSL_is_server(...))时,取出对端证书并调用validate_hostname():
X509 *cert = SSL_get_peer_certificate(sock->ssl->ossl); if (cert != NULL) { switch (validate_hostname(sock->ssl->handshake.client.server_name, cert)) { case MatchFound: /* ok */ break; case MatchNotFound: err = h2o_socket_error_ssl_cert_name_mismatch; break; default: err = h2o_socket_error_ssl_cert_invalid; break; } X509_free(cert); } else { err = h2o_socket_error_ssl_no_cert; }这段代码将枚举结果映射为三类 H2O 内部错误码:MatchNotFound对应h2o_socket_error_ssl_cert_name_mismatch(主机名不匹配),其余异常归为h2o_socket_error_ssl_cert_invalid,服务端未提供证书则报h2o_socket_error_ssl_no_cert。由此可以看出,这套主机名校验逻辑支撑着 H2O 反向代理等场景下对上游服务器的 TLS 身份验证。
六、跨平台编译指南
原 README 给出了三套平台的具体编译与测试说明(见 deps/ssl-conservatory/openssl/README.md)。
Linux
- 示例在 Ubuntu 11.04 上编译测试通过;
- 需要安装 libssl 与 libcrypto 的开发库及头文件,主流发行版中通常位于libssl-dev包;
- 使用仓库内的 Makefile 构建:
CC=gcc,编译参数-Wall -std=c99 -pedantic,链接参数-lcrypto -lssl,运行make即可生成test_client可执行文件。
OS X
- 示例在 OS X Mountain Lion 上编译测试通过;
- OS X 自带 OpenSSL 开发库,但 Apple 对 libssl 做了修改:自动使用系统信任库来校验证书链,且该行为无法更改。因此,在 OS X 上调用
SSL_CTX_load_verify_locations()指定信任库永远会被忽略——若你的代码依赖自定义信任锚,在 OS X 上必须改用其他机制(如证书锁定); - 编译时会输出大量 "is deprecated" 警告,因为 Apple 正从 OpenSSL 迁移到 Common Crypto 框架,属正常现象。
Windows(MinGW)
- 示例使用 MinGW 编译、Windows 7 上测试通过;
- 需要安装 MinGW 与 OpenSSL 开发库(OpenSSL 官方站点提供 Windows 预编译二进制);
- 若使用官方预编译二进制,需先把头文件与两个动态库放入 MinGW 目录:
Copy <OpenSSL_Folder>/include/ → <MinGW_Folder>/include/ Copy <OpenSSL_Folder>/libeay32.dll → <MinGW_Folder>/lib/libeay32.dll Copy <OpenSSL_Folder>/libssl32.dll → <MinGW_Folder>/lib/libssl32.dll- 随后使用 Windows 专用 Makefile 编译:
make -f Makefile_mingw对应的 Makefile_mingw 中链接参数为-leay32 -lssl32(对应上述两个 DLL),生成的可执行文件为test_client.exe。
七、使用这套代码的注意事项
综合头文件、实现与示例代码中的注释(参见 openssl_hostname_validation.c 各函数注释与 openssl_hostname_validation.h),使用时应留意以下几点:
- 校验顺序不可颠倒:必须先完成证书链验证(
SSL_VERIFY_PEER+ 信任库),再做主机名校验,两者缺一不可; - NUL 注入防护是硬性要求:
MalformedCertificate状态的引入说明实现者把"证书名字中嵌入 NUL"视为恶意输入而非普通不匹配,调用方应直接中止连接; - 降级路径的局限:仅当 SAN 完全缺失时才回退到 CN(RFC 6125 的宽松策略);对于 SAN 存在但无匹配的情况,不应再去 CN 中寻找,以避免放宽匹配规则;
- 通配符匹配范围:
*.example.com形式的证书只匹配一个子域层级,且不匹配裸域;实现中并无对*.com之类非法通配符的特判,接入方若面向公网,建议在上层补充校验; - 平台差异:OS X 上
SSL_CTX_load_verify_locations()失效是平台行为,移植代码时必须分支处理。
八、小结
从 deps/ssl-conservatory/openssl/README.md 出发,可以看到这套示例代码完整覆盖了 OpenSSL 客户端证书验证的全部关键环节:信任库配置、链验证、基于 RFC 6125 的主机名校验(SAN 优先、CN 兜底)、通配符与 NUL 注入防护,以及 Linux / OS X / Windows 三平台的构建方式。它既是学习 OpenSSL 安全客户端编程的最佳入门范本,也在 H2O 的 lib/common/socket.c 中被直接复用为生产代码。如果你正在编写需要连接 HTTPS 服务端的 C 程序,直接采纳这套模式即可规避绝大多数证书校验方面的经典陷阱。
- 后端
- 网络
【免费下载链接】h2o
H2O - the optimized HTTP/1, HTTP/2, HTTP/3 server
相关推荐
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考