文章总结: 本文系统剖析OpenSSL3.x在国密算法实现中的12个关键陷阱,涵盖SM2签名格式互转、EVP接口选择、SM4默认填充、随机数DRBG差异及线程安全等核心问题。文档通过代码实例对比错误与正确做法,提供SM2签名DER/r||s转换、EVPDigestSign与EVPPKEY_sign适用场景、SM4-CBC填充控制等实操方案,并揭示OpenSSL与SDF混合栈的典型配置误区。 综合评分: 92 文章分类: 应用安全,安全开发,WEB安全,技术标准,实战经验
那些年我们踩过的坑——OpenSSL
原创
利刃信安 利刃信安
利刃信安
2026年6月5日 08:08 北京
在小说阅读器读本章
去阅读
那些年我们踩过的坑——OpenSSL
摘要:OpenSSL 3.x 已原生支持 SM2/SM3/SM4,但”支持”和”用对”之间隔着无数坑。SM2 签名到底是 DER 还是 r||s?EVP 加密默认帮你填 PKCS#7 你知不知道?
EVP_DigestSign和EVP_PKEY_sign的区别是什么?本文从 SM2 签名验签、SM3 杂凑、SM4 加解密、随机数、EVP 线程安全五大模块,逐一拆解 OpenSSL 在国密场景中的 12 个接口陷阱与正确用法,附赠一个 OpenSSL + SDF 混合栈的真实踩坑现场。
一、SM2:三套接口,三种签名格式
OpenSSL 给 SM2 提供了三层接口,每层的行为不同:
| 层级 | 接口 | 签名格式 | 适用场景 |
| — | — | — | — |
| 底层 | BIGNUM + EC_POINT_mul | 裸 r s(BIGNUM) | 教学、自定义协议 |
| 中层 | ECDSA_do_sign / ECDSA_SIG | r | |
| 高层 |EVPSignFinal/ EVPDigestSign| **DER 编码** (SEQUENCE { r, s }`) | TLS、X.509 |
坑 1:签名格式不互通。
同一份私钥签同一段消息,高层 EVP 接口输出的是 DER 格式(30 46 02 21 00 ... 02 21 00 ...),而中层 ECDSA_do_sign 返回的是 ECDSA_SIG* 结构体,提取 r 和 s 后拼接为 64 字节裸数据。如果下游的国密签名验证接口期望 r||s(64 字节),直接把 DER 丢进去——长度对不上,验签失败。
// 现代 API:EVP_DigestSign 一键完成 SM3 哈希 + SM2 签名 → DER 格式
EVP_MD_CTX *md_ctx = EVP_MD_CTX_new();
EVP_DigestSignInit(md_ctx, NULL, EVP_sm3(), NULL, pkey);
EVP_DigestSignUpdate(md_ctx, msg, msg_len);
size_t sig_len = 256;
EVP_DigestSignFinal(md_ctx, sig_der, &sig_len);
// sig_der 是 DER,不是 r||s,需要转换
// 转换:DER → r||s
const unsigned char *ptr = sig_der;
ECDSA_SIG* sig = d2i_ECDSA_SIG(NULL, &ptr, sig_len);
const BIGNUM *r, *s;
ECDSA_SIG_get0(sig, &r, &s);
BN_bn2binpad(r, raw_r, 32); // 固定 32 字节
BN_bn2binpad(s, raw_s, 32);
ECDSA_SIG_free(sig);
EVP_MD_CTX_free(md_ctx);
正确做法:先用 EVP_DigestSign 签名(自动做 SM3 哈希),再用 d2i_ECDSA_SIG 转成 r||s。或者直接用 ECDSA_do_sign——但后者走的是底层 API,需要手动做 SM3 哈希再传入。
坑 2:EVP_DigestSign vs EVP_PKEY_sign——一字之差,签名全错。
EVP_DigestSign 会自动计算 SM3 哈希,你传入的是原始消息,不是摘要。如果你已经手动算好了 SM3 摘要(32 字节),则应该用 EVP_PKEY_sign——后者跳过哈希步骤,直接对摘要签名。混淆这两者会导致”对摘要再做一次哈希”,签名结果与预期完全不同。这是国密联调中最隐蔽的错误之一:签名函数返回成功,但验签方永远对不上。
坑 3:SM2 加密的 C1 分量含不含 0x04?
SM2 公钥/密文的 C1 分量是否带 0x04 非压缩标志,取决于接口实现。OpenSSL 的 EVP_PKEY 系列输出含 0x04(65 字节),而某些国密专用接口输出裸坐标(64 字节)。对接时先确认对方接口的格式约定,不要假设。
坑 4:SM2 的低层实现陷阱。
如果不用 EVP,自己用 BIGNUM + EC_POINT 实现 SM2,需要手动处理:
- • 签名时
s = (1 + d)^(-1) * (k - r * d) mod n(SM2 公式),不是 ECDSA 的s = k^(-1) * (z + r * d) mod n - • 摘要不是裸 SHA256,SM2 签名标准要求
Z_A || M拼接后再哈希 - • 验签时
R = e' + x1' mod n与r比较,不是 ECDSA 的u1 * G + u2 * Q
二、SM3:EVP 最稳,注意 HMAC 的 key 长度
SM3 是 OpenSSL 国密支持中最”省心”的模块,但仍有两个点:
坑 5:EVP_DigestInit_ex vs EVP_DigestInit。
前者支持 ENGINE(硬件引擎),后者是简化版。如果要用密码卡硬件加速 SM3,必须用 _ex 版本并传入 ENGINE。
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(ctx, EVP_sm3(), NULL); // NULL = 软件实现
EVP_DigestUpdate(ctx, data, len);
EVP_DigestFinal_ex(ctx, digest, &digest_len);
EVP_MD_CTX_free(ctx);
坑 6:HMAC-SM3 的 key 长度影响结果。
SM3 分组长度 64 字节。如果 key > 64 字节,HMAC 会先对 key 做一次 SM3 哈希再使用。如果 key < 64 字节,补零到 64 字节。这个行为是 HMAC 标准规定的,但常有人以为 key 长度不影响结果——其实影响 ipad/opad 异或后的值,不同长度的 key 即使内容相同(补零后),产生的 HMAC 也不同。
HMAC_CTX *hctx = HMAC_CTX_new();
HMAC_Init_ex(hctx, key, key_len, EVP_sm3(), NULL);
HMAC_Update(hctx, msg, msg_len);
HMAC_Final(hctx, hmac, &hmac_len);
HMAC_CTX_free(hctx);
三、SM4:EVP 默认帮你填充,好坏取决于你是否知道
这是 SM4-CBC 加密场景中最常见的踩坑现场。
坑 7:EVP_EncryptFinal_ex 默认 PKCS#7 填充。
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, key, iv);
EVP_EncryptUpdate(ctx, ct, &ct_len, pt, pt_len);
EVP_EncryptFinal_ex(ctx, ct + ct_len, &final_len); // ← 自动 PKCS#7
ct_len += final_len;
EVP_CIPHER_CTX_free(ctx);
OpenSSL EVP 对分组密码默认启用 PKCS#7 填充。要禁用必须显式调用:
EVP_CIPHER_CTX_set_padding(ctx, 0); // 不写这行 = 默认填充
对比 SDF:SDF_Encrypt 不填充,要求明文已是分组整数倍。详见《SDF_Encrypt 函数对数据填充处理方式》。
结论:如果你对接的下游是 SDF 解密(不填充),OpenSSL EVP 加密的密文会多一个填充块,解密失败。要么 OpenSSL 侧关填充,要么 SDF 侧自己补填充——两边必须统一。
坑 8:CBC 加密用同一个 ctx 加密两轮。
以下代码展示了一个密钥备份场景——同一个 EVP_CIPHER_CTX 被用了两轮加密(第一轮全零 IV 做 MAC,第二轮随机 IV 做密文产出):
unsigned char iv[16], mac[16], ciphertext[256];
int ciphertext_len = 0, final_len = 0;
// 第一轮:全零 IV,取最后一个密文分组作为 MAC
memset(iv, 0x00, 16);
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, backup_key, iv);
EVP_EncryptUpdate(ctx, ciphertext, &ciphertext_len, plain_key, key_len);
EVP_EncryptFinal_ex(ctx, ciphertext + ciphertext_len, &final_len);
ciphertext_len += final_len;
memcpy(mac, ciphertext + ciphertext_len - 16, 16); // 保存 MAC,之后 ciphertext 会被覆盖
// 第二轮:随机 IV,产生实际密文输出(ciphertext 从首地址覆盖写入)
RAND_bytes(iv, 16);
ciphertext_len = 0; // 重置计数器,关键!
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, backup_key, iv); // 重新 Init,关键!
EVP_EncryptUpdate(ctx, ciphertext, &ciphertext_len, plain_key, key_len);
EVP_EncryptFinal_ex(ctx, ciphertext + ciphertext_len, &final_len);
ciphertext_len += final_len; // 加上填充块长度
中间必须调用 EVP_EncryptInit_ex 重新初始化,且 ciphertext_len 必须重置为 0。如果忘记重新 Init,第二轮的 IV 和内部状态会从第一轮残留,密文完全错误。
注意:加密和MAC不要选用同一个密钥
坑 9:SM4 密钥长度自动截断。
EVP 约定 SM4 密钥为 128 位(16 字节)。如果传入更长密钥(如 PBKDF2 派生 32 字节密钥后直接传给 EVP),OpenSSL 3.x 会根据算法自动截取前 16 字节——不会报错,但后 16 字节白算了,还让你误以为用了 256 位密钥。
四、随机数:RAND_bytes 的三种 DRBG
坑 10:RAND_bytes 和 RAND_priv_bytes 的区别。
OpenSSL 3.x 内部维护三个 DRBG:
| DRBG | 用途 | 接口 |
| — | — | — |
| master | 种子两个子 DRBG,不直接给应用 | 内部 |
| public | 一般随机数 | RAND_bytes() |
| private | 敏感随机数(密钥) | RAND_priv_bytes() |
RAND_priv_bytes 分配在安全内存中(OPENSSL_secure_zalloc),不会被 swap 到磁盘。对于密钥生成,应该用 RAND_priv_bytes,但代码中常用 RAND_bytes——功能上也能用,安全性上差一档。
五、EVP 的线程安全与 ctx 复用
坑 11:EVP_CIPHER_CTX 复用时残留状态。
OpenSSL 允许同一个 EVP_CIPHER_CTX 多次调用 EVP_EncryptInit_ex 切换密钥或 IV。但 EVP_CIPHER_CTX 内部有 buf[EVP_MAX_BLOCK_LENGTH] 缓冲区,如果上一次加密未完成(没有调用 Final),buf_len 残留非零值,下一次 Init 后首个 Update 的输出会包含上一次的残留数据。
// 危险:ctx 复用前未 Final
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, key1, iv1);
EVP_EncryptUpdate(ctx, ct1, &len1, pt1, 16); // 只 Update 未 Final
// buf_len 残留,ctx 状态不干净
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, key2, iv2); // 第二次 Init
EVP_EncryptUpdate(ctx, ct2, &len2, pt2, 16); // 输出可能包含 key1 的残留
正确做法:要么每次 Init → Update → Final 完整走完,要么不复用 ctx,每次 EVP_CIPHER_CTX_new 新建。
坑 12:EVP 的线程安全。
EVP_CIPHER_CTX 和 EVP_MD_CTX 不是线程安全的。多线程共享同一个 ctx 会导致内部状态竞争。每个线程必须持有独立的 ctx。不要试图加锁保护 ctx 来共享——EVP 层的 ctx 内部不做同步,且 ctx 的状态机语义(Init → Update → Final)本身就假设了单线程调用。
六、真实案例:OpenSSL + SDF 混合密码栈
以下是一个典型的 OpenSSL + SDF 混合密码栈场景,来自一个密钥分发与备份的代码实现:
随机数生成: SDF_GenerateRandom (硬件熵源)
↓
加密操作: OpenSSL EVP SM4-CBC (软件算法)
↓
ASN.1 封装: OpenSSL ASN.1 OCTET STRING
↓
密钥派生: PBKDF2_HMAC + EVP_sm3()
这种混合栈的隐患:
- 1. SDF 设备只用于随机数,加密却走软件——密码卡买来只当随机数发生器,核心加密运算没用到硬件加速,违背了部署密码设备的初衷
- 2. ASN1_OCTET_STRING_set 浅拷贝——栈变量作为 data 源,返回后悬空。这是 OpenSSL ASN.1 API 的经典陷阱:
_set只拷贝指针,不拷贝数据。以下代码片段展示了这一陷阱:
// SM4 密钥分发函数:将密钥用 SM4-CBC 加密后封装为 ASN.1 结构
unsigned char local_iv[16];
unsigned char ciphertext[256];
if (!iv) {
SDF_GenerateRandom(hSession, sizeof(local_iv), local_iv);
iv = local_iv; // iv 指向栈变量,函数返回后悬空!
}
// 加密
EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, enc_key, iv);
EVP_EncryptUpdate(ctx, ciphertext, &ciphertext_len, plain_key, key_len);
EVP_EncryptFinal_ex(ctx, ciphertext + ciphertext_len, &final_len);
// ASN.1 封装 — 浅拷贝陷阱
ASN1_OCTET_STRING_set((*out_env)->iv, iv, 16); // iv 指向 local_iv,栈变量
ASN1_OCTET_STRING_set((*out_env)->ciphertext, ciphertext, ciphertext_len); // 同样栈变量
// 函数返回后,(*out_env)->iv->data 和 (*out_env)->ciphertext->data 全部悬空
- 3. EVP 返回值未检查——多个国密代码示例的通病,EVP 函数的返回值检查是国密代码中最容易被忽略的防御性编程环节
更深层的问题:EVP 设计初衷是”统一接口,屏蔽底层差异”,但在国密场景中,它的层次”不上不下”——既不像 SDF 那样直接调用密码卡硬件,又不像 BIGNUM + EC_POINT 那样提供完全可控的底层操作。用 EVP 做 SM2 签名,输出格式是 DER 而非国密标准的 r||s;用 EVP 做 SM4 加密,默认填充 PKCS#7 而非留给你自己决定。EVP 是为 TLS 和 X.509 设计的,不是为国密应用协议设计的。
七、自查清单
写完 OpenSSL 国密代码后,逐条过一遍:
- • SM2 签名输出格式:DER 还是 r||s?与对端一致吗?
- • SM2 加密 C1 分量:含 0x04(65B)还是裸坐标(64B)?
- •
EVP_DigestSign和EVP_PKEY_sign用对了吗?(前者自动哈希,后者直接签名摘要) - • SM4 加密:EVP 默认填充 PKCS#7,对端是否期望填充?
- • SM4 加密:如果不想要填充,调用
EVP_CIPHER_CTX_set_padding(ctx, 0)了吗? - • SM4 加密和SM4-CBC MAC是否使用相同的密钥?
- • 密钥生成:用的是
RAND_priv_bytes还是RAND_bytes? - • 每个 EVP_* 函数的返回值都检查了吗?
- • EVP_MD_CTX / EVP_CIPHER_CTX / EVP_PKEY_CTX 都 free 了吗?
- • 如果混用 SDF 和 OpenSSL,两边的填充/格式约定一致吗?
- • 多线程环境下,每个线程有独立的 EVP_*_CTX 吗?
- • SM2 底层实现时,公式是 SM2 的还是 ECDSA 的?摘要包含了 Z_A 吗?
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:利刃信安 利刃信安 利刃信安《那些年我们踩过的坑——OpenSSL》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论