如何在垃圾收集时确保Python“零”内存?

时间:2015-02-23 14:36:32

标签: python memory memory-pool

我在Python3.2中遇到与bytes相关的内存管理问题。在某些情况下,ob_sval缓冲区似乎包含我无法解释的内存。

对于特定的安全应用程序,我需要能够确保内存是"归零"不再使用后,尽快返回操作系统。由于重新编译Python并不是一个真正的选择,我正在编写一个可以与LD_PRELOAD一起使用的模块:

  • PyObject_Malloc替换为PyMem_Malloc,将PyObject_Realloc替换为PyMem_Realloc,将PyObject_Free替换为PyMem_Free,以禁用内存池(例如:你想做什么)如果您编译时没有WITH_PYMALLOC)。如果记忆是否合并,我真的不在乎,但这似乎是最简单的方法。
  • 包含mallocreallocfree,以便跟踪请求的内存量,以及memset发布时0的所有内容。

粗略地看一眼,这种做法似乎很有效:

>>> from ctypes import string_at
>>> from sys import getsizeof
>>> from binascii import hexlify
>>> a = b"Hello, World!"; addr = id(a); size = getsizeof(a)
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4j\xb2x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
>>> del a
>>> print(string_at(addr, size))
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00'

最后的错误\x13是奇怪的,但并非来自我的原始值,所以一开始我认为它没问题。我很快找到了不太好的例子:

>>> a = b'Superkaliphragilisticexpialidocious'; addr = id(a); size = getsizeof(a)
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4j\xb2x#\x00\x00\x00\x9cb;\xc2Superkaliphragilisticexpialidocious\x00'
>>> del s
>>> print(string_at(addr, size))
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00))\n\x00\x00ous\x00'

这里最后三个字节ous幸免于难。

所以,我的问题:

bytes个对象的剩余字节进行了哪些操作,为什么在调用del时它们不会被删除?

我猜测我的方法缺少与realloc类似的内容,但我无法看到bytesobject.c中的内容。

我试图量化“剩余”的数量。垃圾收集后剩余的字节,在某种程度上似乎是可预测的。

from collections import defaultdict
from ctypes import string_at
import gc
import os
from sys import getsizeof

def get_random_bytes(length=16):
    return os.urandom(length)

def test_different_bytes_lengths():
    rc = defaultdict(list)
    for ii in range(1, 101):
        while True:
            value = get_random_bytes(ii)
            if b'\x00' not in value:
                break
        check = [b for b in value]
        addr = id(value)
        size = getsizeof(value)
        del value
        gc.collect()
        garbage = string_at(addr, size)[16:-1]
        for jj in range(ii, 0, -1):
            if garbage.endswith(bytes(bytearray(check[-jj:]))):
                # for bytes of length ii, tail of length jj found
                rc[jj].append(ii)
                break
    return {k: len(v) for k, v in rc.items()}, dict(rc)

# The runs all look something like this (there is some variation):
# ({1: 2, 2: 2, 3: 81}, {1: [1, 13], 2: [2, 14], 3: [3, 4, 5, 6, 7, 8, 9, 10, 11, 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, 83, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]})
# That is:
#  - One byte left over twice (always when the original bytes object was of lengths 1 or 13, the first is likely because of the internal 'characters' list kept by Python)
#  - Two bytes left over twice (always when the original bytes object was of lengths 2 or 14)
#  - Three bytes left over in most other cases (the exact ones varies between runs but never has '12' in it)
# For added fun, if I replace the get_random_bytes call with one that returns an encoded string or random alphanumerics then results change slightly: lengths of 13 and 14 are now fully cleared too. My original test string was 13 bytes of encoded alphanumerics, of course!

修改1

我最初表示担心如果在一个函数中使用bytes对象,它根本不会被清除:

>>> def hello_forever():
...     a = b"Hello, World!"; addr = id(a); size = getsizeof(a)
...     print(string_at(addr, size))
...     del a
...     print(string_at(addr, size))
...     gc.collect()
...     print(string_at(addr, size))
...     return addr, size
...
>>> addr, size = hello_forever()
b'\x02\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'

事实证明,这是一个人为关注的问题,并不是我的要求所涵盖的。您可以查看此问题的评论以获取详细信息,但问题来自hello_forever.__code__.co_consts元组即使在Hello, World!a中删除后仍会引用locals的方式}。

在实际代码中,&#34; secure&#34;值将来自外部源,永远不会被硬编码,之后会被删除。

修改2

我还对strings的行为表示了困惑。已经指出它们在函数中对它们进行硬编码(例如:我的测试代码的工件)也可能遇到与bytes相同的问题。他们还有另外两个风险,我无法证明这是一个问题,但会继续调查:

  • 字符串实习由Python在各个点完成,以加快访问速度。这不应该是一个问题,因为当最后一个引用丢失时,应该删除实习字符串。如果它被证明是一个问题,应该可以替换PyUnicode_InternInPlace,以便它不会做任何事情。
  • 字符串和其他&#39;原始&#39; Python中的对象类型通常会保留一个免费列表&#39;使新对象获得内存更快。如果证明这是一个问题,*_dealloc中的Objects/*.c方法可以替换。

我还认为我看到类实例没有正确归零的问题,但我现在认为这是我的错误。

感谢

非常感谢@Dunes和@Kevin指出了混淆我原来问题的问题。这些问题已经留在&#34;编辑&#34;以上部分供参考。

2 个答案:

答案 0 :(得分:3)

一般情况下,您没有这样的保证,即内存将被清零,甚至无法及时收集垃圾。有启发式方法,但如果你担心这种程度的安全性,那可能还不够。

你可以做的是直接处理可变类型,例如bytearray,并明确地将每个元素归零:

# Allocate (hopefully without copies)
bytestring = bytearray()
unbuffered_file.readinto(bytestring)

# Do stuff
function(bytestring)

# Zero memory
for i in range(len(bytestring)):
    bytestring[i] = 0

安全地使用它将要求您只使用您不知道不会制作临时副本的方法,这可能意味着自己动手。但这并不妨碍某些缓存搞乱。

zdan gives a good suggestion在另一个问题中:使用子进程完成工作,一旦完成就用火来杀死它。

答案 1 :(得分:2)

事实证明,问题在我自己的代码中是memset的一个绝对愚蠢的错误。在“接受”这个答案之前,我将联系@Calyth,慷慨地为这个问题增加了一笔赏金。

简而言之,malloc / free包装函数的工作原理如下:

  • 代码调用malloc询问N字节的内存。
    • 包装器调用实际函数,但要求N+sizeof(size_t)字节。
    • 它将N写入范围的开头并返回偏移指针。
  • 代码使用偏移指针,忽略了它附加到比请求的内存稍大的内存这一事实。
  • 代码调用free要求返回内存并传入该偏移量指针。
    • 包装器在偏移指针之前查找以获取最初请求的内存大小。
    • 调用memset以确保所有内容都设置为零(编译库时不进行优化以防止编译器忽略memset)。
    • 只有这样才能调用真正的功能。

我的错误是相当于memset(actual_pointer, 0, requested_size)而不是memset(actual_pointer, 0, actual_size)

我现在面临着一个令人难以置信的问题:为什么没有总是'3'剩余字节(我的单元测试验证我的随机生成的字节对象都不包含任何空值)和为什么字符串也不会有这个问题(Python可能会过度分配字符串缓冲区的大小)。然而,那些是另一天的问题。

所有这一切的结果是,一旦垃圾收集,确保字节和字符串设置为零相对容易! (关于硬编码字符串,免费列表等等,有很多警告,所以试图这样做的任何人都应该仔细阅读原始问题,问题评论和这个“答案”。)