掘金 后端 ( ) • 2024-04-07 11:49

tracemalloc 是python官方提供的跟踪内存分配的工具,也包含了踩内存的相关检测。它会在内存Malloc申请时在申请的内存前后加一些特定的数据填充字节,在内存Realloc、Free时,检查这些填充的字节是否符合预期,如果不符合预期,那么认为这段内存是被其他函数修改过的,然后主动abort,并提示哪些字节与预期不符、以及提示出该段内存最近是在哪行python代码中申请的,如果我们的机器配置了可以生成core,那么就可以从后面的core文件中去分析内存数据。

踩内存检测原理

内存申请

obmalloc.c:_PyMem_DebugRawAlloc()

在n个字节的内存申请时,会在原有的nbtyes基础上多申请 3 * 8(SIZEOF_SIZE_T) = 24个字节的内存,总的内存申请大小就变成了 nbytes + 24 个字节。然后在存放存放数据data的nbytes前后,分别填充特定的数据,在内存释放 的时候,检测前后的填充数据是否符合预期,来判断内存是否被踩。

用到的宏及相关定义

#define PYMEM_CLEANBYTE      0xCD
#define PYMEM_DEADBYTE       0xDD
#define PYMEM_FORBIDDENBYTE  0xFD

typedef struct {
    /* We tag each block with an API ID in order to tag API violations */
    char api_id;
    PyMemAllocatorEx alloc;
} debug_alloc_api_t;
static struct {
    debug_alloc_api_t raw;
    debug_alloc_api_t mem;
    debug_alloc_api_t obj;
} _PyMem_Debug = {
    {'r', PYRAW_ALLOC},
    {'m', PYMEM_ALLOC},
    {'o', PYOBJ_ALLOC}
    };
// api_id有三种,'r'、'm'、'o'

以申请32字节(nbytes=8)内存为例,在调用完_PyMem_DebugRawAlloc方法且使用的是malloc而不是calloc之后,它的内存数据如下:

image.png

内存释放

obmalloc.c:_PyMem_DebugRawFree()

在内存释放时,会检查这个内存的填充值是否符合预期,如果不符合预期会主动abort,生成一个core。

检查逻辑:

obmalloc.c:_PyMem_DebugCheckAddress()

检查api id是否符合预期

/* Check the API id */
id = (char)q[-SST];
if (id != api) {
    msg = msgbuf;
    snprintf(msgbuf, sizeof(msgbuf), "bad ID: Allocated using API '%c', verified using API '%c'", id, api);
    msgbuf[sizeof(msgbuf)-1] = 0;
    goto error;
}

检查填充的字节是否都是PYMEM_FORBIDDENBYTE

/* Check the stuff at the start of p first:  if there's underwrite
 * corruption, the number-of-bytes field may be nuts, and checking
 * the tail could lead to a segfault then.
 */
for (i = SST-1; i >= 1; --i) {
    if (*(q-i) != PYMEM_FORBIDDENBYTE) {
        msg = "bad leading pad byte";
        goto error;
    }
}

nbytes = read_size_t(q - 2*SST);
tail = q + nbytes;
for (i = 0; i < SST; ++i) {
    if (tail[i] != PYMEM_FORBIDDENBYTE) {
        msg = "bad trailing pad byte";
        goto error;
    }
}

写一个代码验证下上述的原理

from ctypes import c_char

data = 999999

addr = id(data)  # cpython解释器id拿到的就是对象的地址
before_addr = addr - 16
after_addr = addr + 28
print(hex(addr))
print(hex(before_addr))
print(hex(after_addr))
print((c_char * 56).from_address(before_addr)[:56])
print((c_char * 16).from_address(before_addr)[:16])
print((c_char * 8).from_address(after_addr)[:8])

image.png

  • PyLongObject的大小是32,但是这个申请的内存是28字节,对应0x1c - 后续可以再研究下
  • api_id是'o'对应:{'o', PYOBJ_ALLOC}

踩内存分析实战

先写一个简单的.c函数

void test_tracemalloc(long long *p){
    *p = 0x1;
    *(p + 1) = 0x2;
}

然后编译成.so

gcc -Wall -g -fPIC -shared -o python_tracemalloctest.so.0 python_tracemalloctest.c

写一个测试的python,调用ctypes中的memset强制改写内存数据

from ctypes import c_char, memset

data = 999999

addr = id(data)  # cpython解释器id拿到的就是对象的地址
before_addr = addr - 16
after_addr = addr + 32
print(hex(addr))
print(hex(before_addr))
print(hex(after_addr))
print((c_char * 16).from_address(before_addr)[:16])
print((c_char * 8).from_address(after_addr)[:8])

# memset(before_addr, 0xAB, 16)  # 踩掉头
memset(after_addr, 0xAB, 8)  # 踩掉尾

踩掉头的报错

image.png

踩掉尾的报错

image.png

根据提示信息Debug memory block at address p=0x7f2055d346dc,可以知道0x7f2055d346dc这个地址指向的内存检查有问题,28 bytes originally requesetd告诉了本次访问的内存字节数是28.

后面提示的就是哪些字节与预期不符,可以先把怀疑范围缩小到某个函数或者某个接口调用,然后使用gdb,打印前后的内存值,看哪些内存被踩。然后进行修改