【问题标题】:How do I ensure Python "zeros" memory when it is garbage collected?垃圾收集时如何确保Python“归零”内存?
【发布时间】:2015-02-23 14:36:32
【问题描述】:

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

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

  • 通过将PyObject_Malloc 替换为PyMem_Malloc、将PyObject_Realloc 替换为PyMem_Realloc 并将PyObject_Free 替换为PyMem_Free 来禁用内存池(例如:如果在不使用WITH_PYMALLOC 的情况下编译会得到什么)。我真的不在乎内存是否被池化,但这似乎是最简单的方法。
  • 包装mallocreallocfree,以便跟踪请求了多少内存,并在释放时跟踪memset0的所有内容。

粗略一看,这种方法似乎效果很好:

>>> 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'

事实证明,这是我的要求未涵盖的人为问题。您可以查看该问题的 cmets 以了解详细信息,但问题来自 hello_forever.__code__.co_consts 元组将包含对 Hello, World! 的引用,即使在 alocals 中删除之后也是如此。

在实际代码中,“安全”值将来自外部来源,永远不会像这样被硬编码和随后删除。

编辑 2

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

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

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

谢谢

非常感谢@Dunes 和@Kevin 指出了混淆我最初问题的问题。这些问题已留在上方的“编辑”部分以供参考。

【问题讨论】:

  • Python 可能正在对字符串进行实习。
  • Python 肯定会在此处实习字符串,它们保存在函数的常量列表中 -- hello_forever.__code__.co_consts
  • 您是否考虑过更改 _Py_DeallocPy_DECREF 宏以在释放后将内存归零?而不是搞乱内存分配。
  • @Dunes:我不熟悉自动实习;我将再看看这些宏,看看我是否能让它们工作。乍一看,它看起来并不乐观,因为我之前的笔记表明 Py_DECREF -> _Py_Dealloc -> tp_dealloc -> object_dealloc -> tp_free -> PyObject_Del -> PyObject_Free -> PyMem_FREE -> free(例如:如果调用 Py_DECREF 则内存应该已归零)。不过,我很可能错过了链条上的一些东西。
  • 我有点错过了你说重新编译不是一种选择的观点。此外,即使是相同类型的对象也可以有不同的大小,而查找对象大小的最简单和最干净的方法似乎是拦截对 malloc 的调用。也就是说,我认为你目前的方法是最好的。虽然我认为如果你可以重新编译 python,这会容易得多。

标签: python memory memory-pool


【解决方案1】:

事实证明,这个问题是我自己的代码中的一个绝对愚蠢的错误,它执行了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 过度分配了字符串缓冲区的大小)。然而,这些都是另一天的问题。

所有这一切的结果是,确保字节和字符串在被垃圾回收后被设置为零是相对容易的! (关于硬编码字符串、空闲列表等有很多注意事项,因此任何尝试这样做的人都应该仔细阅读原始问题、问题上的 cmets 以及这个“答案”。)

【讨论】:

  • 出于好奇,您究竟为什么需要在内存进入垃圾回收之前将其清零?这是安全问题吗?
  • @KronosS,是的,这是针对两种攻击的安全预防措施。 1) 通过确保在将内存返回给操作系统之前将内存设置为零,我们可以防止应用程序分配大量内存并通过它查找 SSL 密钥等内容。 2) 一种常见的黑客攻击方法是尝试利用应用程序中的错误并让它转储它所拥有的内存;通过避免使用 Python 的内存池,内存不会超过绝对需要的时间,因此此类攻击的脆弱性降低了。 (注意 gc.collect 必须在关键点手动调用。)
  • 可能是一个愚蠢的评论。但我认为大多数基于 gc 的语言实际上从不保证调用 gc.collect() 确保垃圾收集器将运行。许多运行时环境将其保持开放状态,因为他们预见到未来可能会建立智能 gc 调度策略,其性能将优于程序员干预。也许将安全性留给gc 不是一个好主意?您可以实现一个将位设置为空等的接口。
  • @CommuSoft,在 Python 中,明确调用 gc.collect 可以保证收集,除非在我们小心避免的某些有限场景(不可收集的垃圾)中。
  • @CommuSoft 在 Python 中很少需要 gc,除非您有循环引用。人们通常关心的内存类型(字节、字符串、大整数)通常在最后一次引用时立即被 del 删除。所以 del 足以保护某些东西。
【解决方案2】:

一般来说,您无法保证及时将内存清零甚至垃圾回收。有启发式方法,但如果您担心安全到这种程度,那可能还不够。

您可以做的是直接处理可变类型,例如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 在另一个问题中:使用子进程完成工作并在完成后将其杀死。

【讨论】:

  • 在我们的例子中,他们很乐意在处理完对象(将是字节和字符串,但不是字节数组)后立即调用 gc.collect()。我已经询问过提供这些类型的子类,例如,当它们被删除时,它们将使用 ctypes 和 memset 来清除内存,但它们不会工作,因为它们会被传递到可能会临时生成的第三方 Python 代码中副本。
  • @Trevor:如果对象是不可变的,则复制应该只返回对原始对象的引用。
  • @Kevin 这可能部分是偏执狂,但我相信他们也担心子字符串会以这种方式“泄漏”。
  • @Veedrac,子进程方法的问题在于内存确实会返回到未归零的操作系统。但是,他们可能能够将 malloc/realloc/free 包装器与子进程结合起来。我会调查的。
  • 有什么方法可以挂接到操作系统并在某些情况下自动将释放页面归零?将它与 subprocess 选项结合起来,您应该已经完成​​了 99% 的工作。
猜你喜欢
  • 1970-01-01
  • 2013-07-12
  • 1970-01-01
  • 2012-06-12
  • 2018-05-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多