栈上变量溢出导致的内存泄漏问题
背景
在Mac上测试TSM SDK C语言版本的SM2Encrypt接口时,遇到一个内存无法释放的问题:
这个截图里面的意思就是说,我的程序尝试去动态释放一块堆上的内存时报错了,因为这块内存没有被动态分配出来。
Mac机器信息为:
os name: macOS,
os release: 12.4,
os version: 21F79,
os platform: x86_64,
processor name: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
Apple clang version 12.0.0 (clang-1200.0.32.29)
Target: x86_64-apple-darwin21.5.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
场景还原
SM2Encrypt接口的定义是这样的:
int SM2Encrypt(struct__anonymous *ctx,
const unsigned char *in, size_t inlen,
const char *strPubKey, size_t pubkeyLen,
unsigned char *out, size_t *outlen)
ctx
函数入参 - 上下文
in
函数入参 - 待加密消息
inlen
函数入参 - 消息长度(字节单位)
strPubKey
函数入参 - 公钥
pubkeyLen
函数入参 - 公钥长度
out
函数出参 - 密文 - 应当为out分配的内存长度遵循以下规则:密文长度 = 明文长度 + 96 + ASN1编码增量,其中ASN1编码增量长度不定,为简单起见,可直接分配 密文长度 = 明文长度 + 200,此长度可保证安全
outlen
函数入参和出参 - 这是一个UNIX C风格的函数参数用法,入参请将*outlen置为out指针所指向内存的分配大小,函数返回后*outlen将被置为输出密文的实际长度
我的核心测试代码大概是长这样的(为了方便分析做了一些小修改,主要是打印输出上的修改,不影响逻辑):
void sm2_test() {
// 分配内存
size_t test_plain_len = 16;
unsigned char *test_plain = malloc(test_plain_len * sizeof(unsigned char)); // 动态分配内存
printf("malloc test_plain success, size:%lu bytes, test_plain pointer value:%x\n",
test_plain_len * sizeof(unsigned char), test_plain);
/* *****
* SM2密文主要由C1、C2、C3三部分构成,
* 其中C1是随机数计算出的椭圆曲线、C2是密文数据、C3是SM3杂凑值,
* C1固定为32字节,C2的长度与明文相同,C3的长度固定为64字节,
* 如果涉及到 ASN.1 编码,则整个密文长度将会膨胀,
* 由于 ASN.1 编码带来的膨胀长度不固定,但是长度值绝对小于 104 字节(200 - C1长度 - C3长度)。
* 因此密文长度的计算,可以按照 明文长度 + 200 个字节来估算。
* *****/
int cipher_len = test_plain_len + 32 + 64 + 104; // 注意!!!这里使用int类型,而非size_t类型
unsigned char *cipher = malloc(cipher_len * sizeof(unsigned char));
printf("malloc cipher success, size:%lu bytes, cipher pointer value:%x\n",
cipher_len * sizeof(unsigned char), cipher);
// 必须初始化sm2_ctx_t,否则会报错-10012
sm2_ctx_t global_sm2_ctx;
int sm2_ctx_ret = SM2InitCtx(&global_sm2_ctx);
printf("init sm2 ctx without pub key, ret=%d\n", sm2_ctx_ret);
printf("sizeof(int)=%lu, sizeof(size_t)=%lu, sizeof(int*)=%lu, sizeof(size_t*)=%lu\n",
sizeof(int), sizeof(size_t), sizeof(int *), sizeof(size_t *));
/* *****
* SM2Encrypt 默认使用 C1C3C2_ASN1 模式进行加密
* *****/
int encrypt_ret = SM2Encrypt(&global_sm2_ctx,
(unsigned char *) (test_plain), (size_t) (test_plain_len * sizeof(unsigned char)),
SM2_TEST_DEMO_PUB_KEY, SM2_PUBKEY_LEN,
(unsigned char *) (cipher), (int *) (&cipher_len));
printf("sm2 encrypt, ret code:%d, cipher len:%d\n", encrypt_ret, cipher_len);
sm2_ctx_ret = SM2FreeCtx(&global_sm2_ctx);
printf("free sm2 ctx, ret=%d\n", sm2_ctx_ret);
printf("cipher pointer value:%x\n", cipher);
free(cipher);
printf("free cipher success, size:%lu bytes\n", cipher_len * sizeof(unsigned char));
printf("test_plain pointer value:%x\n", test_plain);
free(test_plain);
printf("free test_plain success, size:%lu bytes\n", test_plain_len * sizeof(unsigned char));
}
如背景中所描述的那样,在Mac Intel x86_64环境上运行时,得到了报错:
sm2_test_Darwin_x86_64(49620,0x115fec600) malloc: *** error for object 0x600000000000: pointer being freed was not allocated
sm2_test_Darwin_x86_64(49620,0x115fec600) malloc: *** set a breakpoint in malloc_error_break to debug
而这段代码在Linux上运行时,却可以正确运行???
这里强调下,在Linux系统上,也是intel x86_64的cpu:
os name: Linux,
os release: 3.10.107-1-tlinux2_kvm_guest-0055,
os version: #1 SMP Sat Oct 9 14:12:34 CST 2021,
os platform: x86_64,
processor name: Unknown P6 family
gcc (GCC) 4.8.5
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
对Mac上运行的结果进行分析
毫无疑问,直观的来看,test_plain是通过malloc进行分配的,在没有重复free的情况下,free(test_plain)应该是没有问题的;
但是现在free(test_plain)时,却报错pointer being freed was not allocated,在充分检查了代码中没有主动对test_plain做修改后,那么只剩下一种可能,那就是test_plain在运行过程中被修改了!
再次检查了代码,发现,test_plain除了被打印之外,只在调用SM2Encrypt时,作为入参被传进去:
难道是在调用了SM2Encrypt之后,test_plain就被改了???
我们先加两句打印语句,看看是否值真的变化了:
运行结果:
test_plain指针果然变化了!!!
基于Mac环境下的深层次原因分析
通过对现象的分析,大致可以确定,就是在执行了SM2Encrypt之后,test_plain指针被改变了,那么为什么test_plain会被改变呢?
通过仔细对比SM2Encrypt的接口定义与实际传参的类型,可以发现,在传入cipher_len这个参数时,类型上有点点区别:
传入的参数cipher_len定义的是int型,而接口定义的类型是size_t*,通过打印这两种类型,可以发现,在mac上,int与size_t所占用的字节数是不同的:
printf("sizeof(int)=%lu, sizeof(size_t)=%lu, sizeof(int*)=%lu, sizeof(size_t*)=%lu\n",
sizeof(int), sizeof(size_t), sizeof(int *), sizeof(size_t *));
sizeof(int)=4, sizeof(size_t)=8, sizeof(int*)=8, sizeof(size_t*)=8
int型变量占用的字节是4个字节,而size_t型变量占用的字节是8个字节。
而在SM2Encrypt接口中,对于cipher_len是按照size_t的变量进行赋值的,也就是说默认会有8个字节长度的值给到cipher_len,而cipher_len本身定义为int型,只有4个字节,因此必然会多出4个字节。
由于我们是在Mac Intel x86_64的硬件架构上进行编译和运型,x86_64是小端系统,也就是说,变量值0x01020304的排列顺序是:
04 03 02 01
假设SM2Encrpt中,对cipher_len赋值的值为1024(十六进制表示为0x000000000400),
那么其在小端系统的内存中排列为:
00 40 00 00 00 00 00 00
cipher_len只有4个字节,因此只能接收前4个字节:00 00 40 00, 那么多出来的4个字节将会溢出,写入到别的内存中。
再回到这段代码的内存分布,我们会发现,核心测试代码中,以cipher_len被定义为分界点,按照顺序定义了以下变量:
size_t test_plain_len; -- 8字节
unsigned char *test_plain; -- 指针,8字节
int cipher_len; -- 4字节
unsigned char *cipher; -- 指针,8字节
C程序的内存空间分布如图所示:
由于test_plain在栈上与cipher_len相邻,而test_plain在某次运行时,其指向的地址刚好为:0x00000000013e0070,内存排列为:
70 00 3e 01 00 00 00 00
最终导致其在栈上的分布应该如图所示:
因此,当最后去free(test_plain)时,相当于free的是一个只想地址为0的内存块,进而就会导致文中一开始描述的报错信息。
为了验证这个猜想,我们可以尝试把cipher_len类型改为size_t试试看:
通过运行结果可以看到,test_plain addr为0xb48d3768,而cipher_len addr为0xb48d3760,刚好相差8个字节,因此对cipher_len进行填充时,不会覆盖test_plain。
问题的再进一步抽象与简化
上述基于tsm库的分析,其实可以再次对逻辑进行简化,不依赖外部第三方库进行这种现象的复现。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void overflow_int(size_t *y) {
*y = 0x1122334455667788;
}
int main() {
// 定义一个指针,指针本身占用8字节,只向一个8字节的内存空间
unsigned char *test_plain = malloc(8 * sizeof(unsigned char));
printf("test_plain addr:%x, test_plain value:%x\n", &test_plain, test_plain);
// 定义一个int型变量,向这个变量的地址空间拷贝2 * sizeof(x) 个字节
// 然后观察test_plain的值是否被覆盖
int x = 0; // 本身4个字节
printf("x addr:%x\n", &x);
overflow_int(&x);
printf("test_plain addr:%x, test_plain value:%x\n", &test_plain, test_plain);
free(test_plain);
}
在Mac上验证:
在Linux上验证:
如之前分析的那样,test_plain 的值,在经过overflow_int赋值之后,变成了0x11223344。
最后一个疑问:为什么一开始在Linux上运行不会报错?
在文章的最开始,我们提到过,同样的代码,在Mac上运行会报错,但是在Linux上不会报错:
借由前面分析的经验,我们同样适用打印地址的方式,来进行排查,只不过,这次打印地址,我们需要打印完整地址,也就是说,在代码中,将%x替换为%p,代码类似于:
之所以这里需要以%p的形式来打印指针的值,主要是希望获取到完整地址值,避免%x只取低地址位造成的地址截断,话不多说,跑代码看效果:
Mac下的效果:<img src=”栈上变量溢出导致的内存泄漏问题.assets/image-20220601222737843.png” alt=”image-20220601222737843″ style=”zoom:50%;” />
Linux下的效果:
通过对指针值的完整打印,我们可以发现:
在Mac下,test_plain指向的地址的值,其高位始终都是0x6000开头,虽然由于cipher_len溢出,造成了4个字节被覆写为0x00,但是高地址位仍然是0x6000,这样最终去free时实际访问的是0x600000000000,而这个地址可能指向其他有效的内存空间,因此会触发OS的异常终止。
而在Linux下,我们会发现,test_plain指向的地址的值,其高位始终都是0x0000,只有低位是有效位,同样由于cipher_len溢出,造成了4个字节被覆写为0x00,最终导致free时,其实是对指向0地址的内存空间做free,而这个动作对于free函数来数,是被允许的,虽然这样最终还是会造成内存泄漏,但是并不会触发OS的异常终止。
至于为什么Linux下指针值只有低位地址,而Mac下却有高位地址呢?这个应该与OS内存管理的设计有关,也与OS是否开启地址随机化有关系,这块后面有时间再慢慢研究吧!
关于TSM
腾讯国密算法(TencentSM)基于《中华人民共和国密码行业标准》研发,算法性能十分优异,在业界处于领先水平。单个方法的执行效率较目前主流国密算法均有提升,特别在加解密方面的性能提升明显。
咨询TSM欢迎联系腾讯安全云鼎实验室。