Инженер по машинному обучению
20,8
рейтинг
24 мая 2014 в 16:10

Разработка → os.urandom, CPython, Linux и грабли



Хочу поведать поучительную историю ошибки в реализации функции urandom из модуля os в CPython на UNIX-подобных ОС (Linux, Mac OS X, etc.).

Цитата из документации по тройке:
Return a string of n random bytes suitable for cryptographic use.

This function returns random bytes from an OS-specific randomness source. The returned data should be unpredictable enough for cryptographic applications, though its exact quality depends on the OS implementation. On a Unix-like system this will query /dev/urandom, and on Windows it will use CryptGenRandom().
Документация по двойке добавляет:
New in version 2.4.
Другими словами, к примеру, под Linux, urandom читает и возвращает байты из системного устройства /dev/urandom. Напомню, что в этой ОС существуют два типичных устройства-источника энтропии: /dev/random и /dev/urandom. Как известно, первое устройство «медленное» и блокирующее, а второе «быстрое», и вопреки распространенному мнению, оба они криптостойкие источники (псевдо-)случайных чисел. Сразу скажу, КДПВ к статье отношения не имеет и речь пойдёт совсем не о криптографии, безопасности и об OpenSSL с Heartbleed-ом.

Казалось бы, как можно ошибиться в реализации столь простой рутины? Как это часто бывает, дооптимизировались…

2.4

Возвратимся в конец 2004, выходит Half-Life 2 CPython 2.4, добавляя такие привычные всем фичи как декораторы функций, множества (set), обратный порядок обхода (reversed) и list comprehensions, которые по ссылке названы generator expressions. Как люди без них могли вообще разрабатывать софт на Питоне?!

Выше уже писалось, что в том числе добавили os.urandom, имплементированную на самом Питоне. Давайте пофантазируем, как можно было бы написать urandom:
def urandom(n):
  with open('/dev/urandom', 'rb') as rnd:
    return rnd.read(n)
Вот так, три строчки. Причём, это абсолютно корректная реализация без ошибок, если не считать обработку исключений и прочие детали, чтобы соответствовать спецификации работы функции по докам. И тут чья-то светлая голова предлагает ускорить этот код. Как это возможно, спросите вы. Закешировав файловый объект, отвечает светлая голова.
rnd = None
 
def urandom(n):
  if rnd is None:
    rnd = open('/dev/urandom', 'rb')
  return rnd.read(n)
Какие проблемы появляются с такой реализацией? Скрипты, которые становятся демонами, падают при первом же вызове urandom после смерти родителя.

fork()

Многие в курсе, что системная функция fork(), входящая в стандарт POSIX 2001 года и появившаяся в самой первой версии Unix, предназначена для порождения новых процессов методом «раздваивания», когда в системе появляется близнец процесса с идентичным окружением, но отдельным адресным пространством, и начинает работу он ровно с того самого места в коде, где был вызов fork(). Как правило, форки используют механизм copy-on-write, благодаря которому при создании процесса-близнеца («ребёнка») память физически не копируется. Вместо этого, из памяти родителя копируются страницы, в которые пишет близнец по мере своей работы. Это всё лирика, а нас же интересует следующая цитата из man fork:
The child inherits copies of the parent's set of open file descriptors. Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the parent. This means that the two descriptors share open file status flags, current file offset, and signal-driven I/O attributes
Иначе говоря, файловые дескрипторы, принадлежащие питоновским file object-ам, после форка взаимосвязаны и ссылаются на один и тот же файл. Однако, если в одном процессе файл будет закрыт, то он не будет автоматически закрыт и в другом.

Ну fork и fork, скажете вы. Питон-то здесь причём? А при том что
  1. поверх него работает multiprocessing*
  2. через него происходит демонизация
* с исправлением #8713 уже не всегда

Благодаря fork-анию в multiprocessing-е дети изначально находятся в состоянии, которое было у главного процесса перед размножением. Что касается процесса демонизации (превращением в сервис в терминах Windows) — см. PEP 3143. Где-то в самом разгаре там происходит вызов fork(). И если по лучшим традициям закрывать в новоиспечённом демоне все файловые дескрипторы напрямую, не через close() (например, так: os.closerange(3,256)), то os.urandom() рушится.

Примерно этими словами объясняли пользователи CPython в начале 2005-го его разработчикам ошибку. Впрочем, Гвидо сначала пытался строить из себя дурака отнекиваться:
I recommend to close this as invalid. The daemonization code is clearly broken.
К счастью, люди смогли убедить царя в обратном, и, наконец, в июле кеширование /dev/urandom убрали — прошло более полугода. Обращаю внимание на то, как это сделали: в коде нет ни ссылки на номер бага, ни указания на причины патча, ни, в конце концов, просто поясняющего комментария. Работает, и хорошо.

3.4

Проходит 9 лет. В марте 2014 выходит CPython 3.4. Он добавляет такие нужные фичи, как… wait, oh shi
No new syntax features were added in Python 3.4.
Ладно-ладно, если серьёзно, прогресс большой: кучу библиотек приняли, к примеру, asyncio, о котором уже много писали на Хабре, безопасность улучшили, освобождение объектов подкрутили — не мне об этом рассказывать. Главное, что перед релизом нашлись люди, которые посчитали, что реализация /dev/urandom на Питоне адски медленная, и true performance может обеспечить только старый добрый C. В общем функцию переписали… и снова наступили на те же самые грабли. И никакой PEP 446 им не помог. Патч вышел 24 апреля и на этот раз уже содержал в изобилии комментарии, ссылку на баг и даже regression тесты.

Какое мне до этого дело

В качестве бонуса к статье, расскажу, как я споткнулся об эту ошибку. Рабочая система у меня Ubuntu 14.04 LTS, и, к сожалению, на ней
import platform
platform.python_build()
('default', 'Apr 11 2014 13:05:11')
У меня работал демонизирующий код, закрывающий все файловые дескрипторы. И вот ведь беда,
import os
print(os.listdir('/proc/self/fd'))
import random
print(os.listdir('/proc/self/fd'))
печатает
['0', '1', '2', '3']
['0', '1', '2', '3', '4']
Эксперимент не совсем чистый, т.к. os.listdir создаёт свой дескриптор в обоих случаях под последним номером. После импорта random открылся номер 3. Какому файлу он соответствует?
print(os.readlink('/proc/self/fd/3'))
/dev/urandom
Та-дам! Я всегда плохо относился к работе при импорте модулей… В данном случае, привожу окончание random.py:
from os import urandom as _urandom
 
class Random(_random.Random):
    # ...
    def __init__(self, x=None):
        # ...
        self.seed(x)
        self.gauss_next = None
 
    def seed(self, a=None, version=2):
        # ...
        if a is None:
            try:
                a = int.from_bytes(_urandom(32), 'big')
            except NotImplementedError:
                # ...
Остается заметить, что import random делают Tornado, Twisted, uuid, и целая куча других библиотек, стандартных и не очень.

Надо заметить, что сначала я не совсем верно понял суть проблемы, необоснованно решив, что файловые дескрипторы ребёнка и родителя закрываются одновременно. Спасибо kekekeks за восстановление полной картины этого бага.

Выводы

Следует всегда думать об извечных проблемах fork() при разработке библиотек, всегда комментировать багфиксы в коде и внимательно читать сообщения о проблемах пользователей (по крайней мере, если они программисты).
Вадим Марковцев @markhor
карма
118,0
рейтинг 20,8
Инженер по машинному обучению

Похожие публикации

Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (26)

  • +7
    Мне кажется, у Вас в конце ошибка.

    if __name__ == '__main__':
        _test()
    
    Блеск! Гонять тесты вычисления случайных чисел при импорте — это сильно.

    Вычисления случайных чисел гоняются не при импорте, а когда интерпретатор запускается, как python random.py (или, если не ошибаюсь, python -m random будет работать в случае модуля, который не в cwd, а где-то в PYTHONPATH). Строка

    if __name__ == "__main__":
    

    говорит именно об этом. Никто и не хочет запускать тесты при импорте =)
    • +1
      Тьфу, точно! Настоящий виновник — строка
      _inst = Random()
      которую я убрал в комментарий. Обновляю статью. Спасибо, все интереснее оказалось.
  • 0
    Блеск! Гонять тесты вычисления случайных чисел при импорте — это сильно. Самое главное, надёжно. Вдруг /dev/urandom, шутник, будет возвращать не случайные числа — тесты не пройдут! Остается заметить, что import random делают Tornado, Twisted, uuid, и целая куча других библиотек, стандартных и не очень.

    Тест будет выполнен не при иморте, а при выполнении этого модуля т.е. python random.py.
    • +1
      Теперь Вы тоже будете обновлять комментарии перед отправкой =)
      • +5
        С другой стороны: первые два комментатора прочитали пост до конца, что радует :)
        • +1
          … Или прочитали только начало и конец =D
    • +1
      Да, я уже исправил пост с указанием истинной причины. Посыпаю голову пеплом :)
    • +2
      Вдруг /dev/urandom, шутник, будет возвращать не случайные числа

      А вдруг будет?
      • 0
        Помню на БСД 4.3 версии была такая проблема, линканул /dev/null перепутав местами что с куда.
        В итоге везде где лился /dev/null файлы росли — /dev/null использовался в софте и софт залил кучу файлов смесью почтовых данных и мусора.
  • +3
    Как известно, первое устройство «медленное» и блокирующее, а второе «быстрое», и вопреки распространенному мнению, оба они криптостойкие источники (псевдо-)случайных чисел (http://www.2uo.de/myths-about-urandom/).


    Благодарю за ссылку, очень интересно, прочитал от начала и до конца, вместе со ссылкой на то, почему увеличение количества источников энтропии может повредить безопасности.

    Вопрос — есть ли где-то перевод? Захотелось вот перевести и запостить на Хабр. Есть ли смысл?
    • 0
      Не знаю есть ли перевод, но он будет точно полезным!
      • 0
        Google по запросу «site:habrahabr.ru мифы urandom» не находит.

        Ок, займусь в ближайшее время, наверное.
      • 0
        Я узнал у автора оригинала — он против перевода, но не против переосмысления на собственный лад с отсылкой к нему как к источнику.
  • +1
    Если в одном процессе файл будет закрыт, то он будет автоматически закрыт и в другом.
    Это заявление не соответствует действительности. Закрытие дескриптора в одном процесе не приводит к закрытию дескрипторов в другом. Собственно, при использовании связки fork+exec необходимо перед exec специально пробегаются по всем открытым дескрипторам (кроме stdin, stdout и stderr), закрывая их. Так же рекомендую посмотреть схему с перенаправлением вывода с pipe+fork+exec.
    • +6
      Собсно вот. Можете запустить и проверить, никаких проблем в сишном коде с дескриптором, закрытым в другом процессе нет:
      #include <stdio.h>
      #include <unistd.h>
      
      int main (int argc, char**argv)
      {
              FILE* handle = fopen("/tmp/test.txt", "w");
              if(fork()==0)
              {
                      sleep(2);
                      fprintf(handle, "test\n");
                      fclose(handle);
                      printf("2 finished\n");
              }
              else
              {
                      fclose(handle);
                      printf("1 finished\n");
              }
              return 0;
      }
      

      Описываемый вами баг вызван тем, что где-то в недрах питона какой-то криворукий кодер решил в дочернем процессе сразу после вызова fork() принудительно закрывать вообще все файловые дескрипторы без разбору, что можно делать только в одном случае — перед вызовом exec, потому что иначе нельзя гарантировать, что закрытые дескрипторы никому не нужны. Так что проблема эта не в модуле random, а в том, как используется fork.
      • +1
        Спасибо! Я ошибся и стал грешить на fork, в то время как суть была не в нём, а в демонизации. Во всех учебниках пишут, что нужно в демоне закрывать дескрипторы. Даже PEP это утверждает (и делает, как выяснилось).

        Проблема, всё же, в urandom, собсно сами разрабы об этом писали. Два раза)
        • +2
          Проблема всё же в том, что вместо использования функции daemon из libc (которая делает всё, что нужно для демонизации, а именно fork, setsid и закрытие stdin,stdout и stderr) начали городить свою кривую реализацию.
    • +2
      Всё правильно, перед exec-ом всё закрывают в целях безопасности, чтоб дескрипторы не утекали. PEP 446.
  • +2
    И тут чья-то светлая голова предлагает ускорить этот код. Как это возможно, спросите вы. Закешировав файловый объект, отвечает светлая голова.

    В современных ОС открытие файла является относительно дорогой операцией. В Linux может быть не такой дорогой, как в Windows, но довольно дорогой. Переоткрытие файла — зло.

    Согласен с теми, кто утверждает, что сломан код демонизации. Если закрывать файловые дескрипторы, созданные не твоим кодом, будешь регулярно натыкаться на ошибки.
  • +2
    Я ни разу не питонист, но немного понимаю в fork() и системных вызовах, поэтому оставлю несколько замечаний.

    Во первых, системный вызов read() на закрытом дескрипторе не может вызвать падение процесса. Он вернет EBADF, и это правильно. Падать может библиотека буфферизированого чтения.
    Проверять или не проверять работоспособность дескриптора перед каждым чтением — вопрос идеологии. Можно тихонько исправить (переоткрыть), но оставив источник проблемы, либо не проверять и упасть пораньше, но дав возможность обнаружить что где-то что-то сделано неправильно.

    Во вторых, если не использовать библиотеку буфферизированного чтения, то стоит буфферизировать его (чтение из urandom) самостоятельно. Каждый раз при запросе нескольких случайных байт делать syscall read() — это много лишней работы. И в обоих случаях нужно учитывать, что после форка обязательно нужно сбросить буффер, хотя бы для одного из процессов (логично делать это для child). Получается, при каждом вызове os.urandom нужно проверить свой PID, и если обнаружится, что имел место fork() — переоткрыть дескриптор (автоматически сбросив буффер). Ни в одном из вариантов реализации, описанных в статье, я этого не вижу.
    • 0
      Не пойдет ли буферизация во вред безопасности? Забуферизированный мегабайт может месяцами висеть в памяти.
      • 0
        Обычно используют куда меньше размеры, от нескольких килобайт до нескольких десятков килобайт. Надо быть ну очень крутым хакером-криптографом, чтобы не только получить доступ к этой памяти, но извлечь какую-то пользу из этого.
        Зато использование буферизации сильно ускорит интенсивное получение случайных чисел мелкими порциями, по сравнению с отсутствием оной.
    • 0
      Можно тихонько исправить (переоткрыть)
      Нельзя. Вообще нельзя использовать дескриптор после закрытия. С тем же номером может быть открыт уже другой файл. Надо чинить fork.
      • 0
        Под «можно» имелось ввиду что есть такой вариант развития событий, а не то что так разрешается делать.
        Согласен, что вмешиваться в закешированные файловый дескрипторы это лишнее (в данном случае закрывать их), результат может быть непредсказуем.

  • 0
    К счастью, люди смогли убедить царя в обратном, и, наконец, в июле кеширование /dev/urandom убрали — прошло более полугода. Обращаю внимание на то, как это сделали: в коде нет ни ссылки на номер бага, ни указания на причины патча, ни, в конце концов, просто поясняющего комментария. Работает, и хорошо.


    ??? Смотрим вашу же ссылку: hg.python.org/cpython/rev/c5888413412b

    commit message:
    bug #1177468: don't cache /dev/urandom file descriptor in os.urandom [#1177468]


    /Misc/NEWS:
    Bug #1177468: Don't cache the /dev/urandom file descriptor for os.urandom,
    as this can cause problems with apps closing all file descriptors.
  • 0
    выходит Half-Life 2 CPython 2.4, добавляя такие привычные всем фичи как декораторы функций, множества (set), обратный порядок обхода (reversed) и list comprehensions, которые по ссылке названы generator expressions.

    list comprehensions и generator expressions — разные вещи. Первые создают в памяти список с данными, вторые возвращают функцию-генератор для вычисления элементов.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.