Как обеспечить память "нулей" Python при сборке мусора?

У меня возникают проблемы с управлением памятью, связанными с bytes в Python3.2. В некоторых случаях буфер ob_sval, похоже, содержит память, которую я не могу объяснить.

Для конкретного защищенного приложения мне необходимо убедиться, что память "обнулена" и возвращается в ОС как можно скорее после того, как она больше не используется. Поскольку повторная компиляция Python на самом деле не вариант, я пишу модуль, который можно использовать с LD_PRELOAD, чтобы:

  • Отключить объединение пулов, заменив PyObject_Malloc на PyMem_Malloc, PyObject_Realloc на PyMem_Realloc и PyObject_Free на PyMem_Free (например: что вы получите, если вы скомпилируете без 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.

Я попытался количественно определить количество оставшихся байтов, оставшихся после сбора мусора, и, похоже, в некоторой степени предсказуемо.

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.

В реальном коде "безопасные" значения будут поступать из внешнего источника и никогда не будут жестко закодированы и впоследствии удалены так.

Изменить 2

Я также выразил недоумение по поводу поведения с strings. Было указано, что они, вероятно, также испытывают ту же проблему, что и bytes по отношению к жесткому их кодированию в функциях (например, артефакт моего тестового кода). С ними есть еще два риска, которые я не смог продемонстрировать как проблема, но буду продолжать расследование:

  • Интерпретация строк выполняется Python в разных точках, чтобы ускорить доступ. Это не должно быть проблемой, поскольку интернированные строки должны быть удалены при утрате последней ссылки. Если это окажется проблемой, следует заменить PyUnicode_InternInPlace так, чтобы он ничего не делал.
  • Строки и другие "примитивные" типы объектов в Python часто содержат "свободный список", чтобы ускорить получение памяти для новых объектов. Если это окажется проблемой, методы *_dealloc в Objects/*.c могут быть заменены.

Я также считал, что я вижу проблему с тем, что экземпляры классов не получают нуль правильно, но теперь я считаю, что это была ошибка с моей стороны.

Спасибо

Большое спасибо @Dunes и @Kevin за то, что они указали на проблемы, которые запутывали мой первоначальный вопрос. Эти проблемы были выше в разделе "редактировать" выше для справки.

Ответы

Ответ 1

Оказывается, проблема была абсолютно глупой ошибкой в ​​моем собственном коде, который сделал memset. Я собираюсь обратиться к @Calyth, который щедро добавил щедрость к этому вопросу, прежде чем "принять" этот ответ.

Короче говоря, упрощенные функции обертки malloc/free работают следующим образом:

  • Кодовые вызовы malloc запрашивают N байты памяти.
    • Обертка вызывает реальную функцию, но запрашивает N+sizeof(size_t) bytes.
    • Он записывает N в начало диапазона и возвращает указатель смещения.
  • В коде используется указатель смещения, не обращая внимания на то, что он прикреплен к немного большему фрагменту памяти, чем было запрошено.
  • Кодовые вызовы free с просьбой вернуть память и передать указатель смещения.
    • Обертка ищет перед указателем смещения, чтобы получить первоначально запрошенный размер памяти.
    • Он вызывает memset, чтобы все было установлено на ноль (библиотека скомпилирована без оптимизации, чтобы предотвратить компилятор от игнорирования memset).
    • Только тогда он вызывает реальную функцию.

Моя ошибка вызывала эквивалент memset(actual_pointer, 0, requested_size) вместо memset(actual_pointer, 0, actual_size).

Теперь я столкнулся с ошеломляющим вопросом о том, почему не осталось "3" оставшихся байтов (мои юнит-тесты подтверждают, что ни один из моих случайно созданных объектов байтов не содержит никаких нулей), и почему строки не будут также иметь это проблема (возможно, Python перераспределяет размер буфера строк). Тем не менее, это проблемы на следующий день.

Результатом всего этого является то, что оказалось относительно легко обеспечить, чтобы байты и строки были установлены на ноль после сбора мусора! (Существует множество предостережений о жестко закодированных строках, свободных списках и т.д., Поэтому любой, кто пытается это сделать, должен прочитать оригинальный вопрос, комментарии по этому вопросу и этот "ответ".)

Ответ 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 дает хорошее предложение в другом вопросе: используйте подпроцесс для выполнения работы и убивайте его с огнем, как только это будет сделано.