Pull to refresh

os.urandom, CPython, Linux и грабли

Reading time 5 min
Views 21K


Хочу поведать поучительную историю ошибки в реализации функции 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() при разработке библиотек, всегда комментировать багфиксы в коде и внимательно читать сообщения о проблемах пользователей (по крайней мере, если они программисты).
Tags:
Hubs:
+54
Comments 26
Comments Comments 26

Articles