垃圾收集时如何确保 Python "zeros" 内存?
How do I ensure Python "zeros" memory when it is garbage collected?
我 运行 在与 Python3.2 中的 bytes
相关的内存管理方面遇到了一些麻烦。在某些情况下,ob_sval
缓冲区似乎包含我无法解释的内存。
对于特定的安全应用程序,我需要能够确保内存 "zeroed" 并在不再使用后尽快返回 OS。由于重新编译 Python 并不是一个真正的选择,我正在编写一个可以与 LD_PRELOAD 一起使用的模块:
- 通过将
PyObject_Malloc
替换为 PyMem_Malloc
、将 PyObject_Realloc
替换为 PyMem_Realloc
以及将 PyObject_Free
替换为 PyMem_Free
来禁用内存池(例如:what如果你在没有 WITH_PYMALLOC
的情况下编译,你会得到)。我真的不在乎内存是否被合并,但这似乎是最简单的方法。
- 包装
malloc
、realloc
和 free
以便跟踪请求了多少内存,并在需要时 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
.
中的内容
我试图量化垃圾回收后剩余的 'leftover' 字节数,它似乎在某种程度上是可以预测的。
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
从 [=40= 中删除后也是如此].
在实际代码中,"secure" 值将来自外部源,永远不会像这样被硬编码和删除。
编辑 2
我也对 strings
的行为表示困惑。有人指出,在函数中对它们进行硬编码时,它们可能也遇到与 bytes
相同的问题(例如:我的测试代码的产物)。它们还有另外两个风险,我无法证明这是一个问题,但会继续调查:
- 字符串驻留由 Python 在不同点完成以加快访问速度。这应该不是问题,因为当最后一个引用丢失时,驻留字符串应该被删除。如果它被证明是一个问题,应该可以替换
PyUnicode_InternInPlace
这样它就什么都不做。
- Python 中的字符串和其他 'primitive' 对象类型通常保留一个 'free list' 以便更快地为新对象获取内存。如果这被证明是一个问题,
Objects/*.c
中的*_dealloc
方法可以被替换。
我还认为我看到 class 个实例没有正确归零的问题,但我现在认为这是我的错误。
谢谢
非常感谢@Dunes 和@Kevin 指出了混淆我最初问题的问题。这些问题已留在上面的 "edit" 部分以供参考。
一般来说,您无法保证内存会被清零,甚至无法及时收集垃圾。有启发式方法,但如果您担心安全性到这种程度,可能还不够。
您可以做的是直接处理可变类型,例如 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在另一个问题中:使用子进程来完成工作并在完成后用火杀死它。
事实证明,这个问题是我自己的代码中的一个绝对愚蠢的错误,它执行了 memset
。在'accepting'这个答案之前,我将联系@Calyth,他慷慨地为这个问题增加了赏金。
简而言之,malloc
/free
包装函数的工作方式如下:
- 代码调用
malloc
要求 N
字节内存。
- 包装器调用实际函数但要求
N+sizeof(size_t)
字节。
- 它将
N
写入范围的开头并且 return 是一个偏移指针。
- 代码使用偏移指针,而没有注意到它附加到比请求的内存块稍大的内存块这一事实。
- 代码调用
free
询问 return 内存并传入该偏移指针。
- 包装器在偏移指针之前查找以获得最初请求的内存大小。
- 它调用
memset
以确保所有内容都设置为零(库是在没有优化的情况下编译的,以防止编译器忽略 memset
)。
- 然后才调用真正的函数
我的错误是调用了 memset(actual_pointer, 0, requested_size)
而不是 memset(actual_pointer, 0, actual_size)
的等价物。
我现在面临一个令人难以置信的问题,即为什么 总是 '3' 个剩余字节(我的单元测试验证了我的 none随机生成的字节对象包含任何空值)以及为什么字符串也不会出现此问题(Python 是否过度分配了字符串缓冲区的大小)。然而,这些都是另一天的问题。
所有这一切的结果是,事实证明,一旦字节和字符串被垃圾回收,就可以相对容易地确保它们被设置为零! (关于硬编码字符串、空闲列表等有很多注意事项,所以任何其他试图这样做的人都应该仔细阅读原始问题、问题的评论和这个 'answer'。)
我 运行 在与 Python3.2 中的 bytes
相关的内存管理方面遇到了一些麻烦。在某些情况下,ob_sval
缓冲区似乎包含我无法解释的内存。
对于特定的安全应用程序,我需要能够确保内存 "zeroed" 并在不再使用后尽快返回 OS。由于重新编译 Python 并不是一个真正的选择,我正在编写一个可以与 LD_PRELOAD 一起使用的模块:
- 通过将
PyObject_Malloc
替换为PyMem_Malloc
、将PyObject_Realloc
替换为PyMem_Realloc
以及将PyObject_Free
替换为PyMem_Free
来禁用内存池(例如:what如果你在没有WITH_PYMALLOC
的情况下编译,你会得到)。我真的不在乎内存是否被合并,但这似乎是最简单的方法。 - 包装
malloc
、realloc
和free
以便跟踪请求了多少内存,并在需要时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
.
我试图量化垃圾回收后剩余的 'leftover' 字节数,它似乎在某种程度上是可以预测的。
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
从 [=40= 中删除后也是如此].
在实际代码中,"secure" 值将来自外部源,永远不会像这样被硬编码和删除。
编辑 2
我也对 strings
的行为表示困惑。有人指出,在函数中对它们进行硬编码时,它们可能也遇到与 bytes
相同的问题(例如:我的测试代码的产物)。它们还有另外两个风险,我无法证明这是一个问题,但会继续调查:
- 字符串驻留由 Python 在不同点完成以加快访问速度。这应该不是问题,因为当最后一个引用丢失时,驻留字符串应该被删除。如果它被证明是一个问题,应该可以替换
PyUnicode_InternInPlace
这样它就什么都不做。 - Python 中的字符串和其他 'primitive' 对象类型通常保留一个 'free list' 以便更快地为新对象获取内存。如果这被证明是一个问题,
Objects/*.c
中的*_dealloc
方法可以被替换。
我还认为我看到 class 个实例没有正确归零的问题,但我现在认为这是我的错误。
谢谢
非常感谢@Dunes 和@Kevin 指出了混淆我最初问题的问题。这些问题已留在上面的 "edit" 部分以供参考。
一般来说,您无法保证内存会被清零,甚至无法及时收集垃圾。有启发式方法,但如果您担心安全性到这种程度,可能还不够。
您可以做的是直接处理可变类型,例如 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在另一个问题中:使用子进程来完成工作并在完成后用火杀死它。
事实证明,这个问题是我自己的代码中的一个绝对愚蠢的错误,它执行了 memset
。在'accepting'这个答案之前,我将联系@Calyth,他慷慨地为这个问题增加了赏金。
简而言之,malloc
/free
包装函数的工作方式如下:
- 代码调用
malloc
要求N
字节内存。- 包装器调用实际函数但要求
N+sizeof(size_t)
字节。 - 它将
N
写入范围的开头并且 return 是一个偏移指针。
- 包装器调用实际函数但要求
- 代码使用偏移指针,而没有注意到它附加到比请求的内存块稍大的内存块这一事实。
- 代码调用
free
询问 return 内存并传入该偏移指针。- 包装器在偏移指针之前查找以获得最初请求的内存大小。
- 它调用
memset
以确保所有内容都设置为零(库是在没有优化的情况下编译的,以防止编译器忽略memset
)。 - 然后才调用真正的函数
我的错误是调用了 memset(actual_pointer, 0, requested_size)
而不是 memset(actual_pointer, 0, actual_size)
的等价物。
我现在面临一个令人难以置信的问题,即为什么 总是 '3' 个剩余字节(我的单元测试验证了我的 none随机生成的字节对象包含任何空值)以及为什么字符串也不会出现此问题(Python 是否过度分配了字符串缓冲区的大小)。然而,这些都是另一天的问题。
所有这一切的结果是,事实证明,一旦字节和字符串被垃圾回收,就可以相对容易地确保它们被设置为零! (关于硬编码字符串、空闲列表等有很多注意事项,所以任何其他试图这样做的人都应该仔细阅读原始问题、问题的评论和这个 'answer'。)