今日总结 – 记一次在Mac系统下因为栈上变量溢出导致的内存泄露问题

栈上变量溢出导致的内存泄漏问题

背景

在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欢迎联系腾讯安全云鼎实验室。

正文完