[Перевод] Опасные pickles — вредоносная сериализация в Python

Всем привет!

Panta rhei и вот уже приближается запуск обновленного курса «Web-разработчик на Python» и у нас остался ещё материал, который мы нашли сильно небезынтересным и коим хотим поделиться с вами.

Чем опасны pickles?

Эти соленые огурчики крайне опасны. Я даже не знаю, как объяснить, насколько. Просто поверь мне. Это важно, понимаешь?

«Explosive Disorder» Pan Telare

Прежде чем с головой погрузиться в опкод, поговорим об основах. В стандартной библиотеке Python есть модуль под названием pickle (в переводе «соленый огурчик» или просто «консервация»), который используется для сериализации и десериализации объектов. Только называется это не сериализация/десериализация, а pickling/unpickling (дословно — «консервация/расконсервация»).

iigc_agx3ccqks5luqippjv0nv8.jpeg

Как человек, которого до сих пор мучают кошмары после использования Boost Serialization в C++, могу сказать, что консервация отличная. Что бы вы в нее не кинули, она продолжает Просто Работать. И не только с builtin типами — в большинстве случаев, можно сериализовать свои классы, без необходимости писать сериализационные консервирующие методы. Даже с такими объектами, как рекурсивные структуры данных (которые бы вызвали падение при использовании похожего marshal модуля), проблем не возникает.

Приведем быстрый пример для тех, кто еще не знакомым с модулем pickle:

import pickle
# начать с любого инстанса типа Python
original = { 'a': 0, 'b': [1, 2, 3] }
# преобразовать это в строку
pickled = pickle.dumps(original)
# преобразовать обратно в идентичный объект
identical = pickle.loads(pickled)


Этого достаточно в большинстве случаев. Консервация действительно классная…, но где-то в глубине скрывается тьма.

В одной из первых строк pickle модуля написано:
Внимание: Модуль pickle не защищен от ошибочных и вредоносных данных. Никогда не делайте расконсервацию данных из ненадежного и неавторизованного источника.

Я много раз перечитал это предупреждение и часто задавался вопросом, что же могут собой представлять вредоносные данные. И недавно я решил, что настало время это узнать. И не зря.
Мой квест по созданию вредоносных данных помог мне узнать многое о работе протокола pickle, обнаружить классные методы дебаггинга консервации и найти парочку дерзких комментариев в исходном коде Python. Если продолжите чтение, то получите те же преимущества (и скоро тоже начнете отправлять людям свои вредоносные консервационные файлы). Предупреждение: будут технические детали, единственный пререквизит — базовое знание Python. Но и поверхностное знание ассемблера не помешает.

Ненастоящая Pickle Bomb

Я начал с чтения документации pickle модуля, в надежде найти подсказки, как стать элитным хакером, и наткнулся для строку:
Модуль pickletools содержит инструменты для анализа потоков данных, сгенерированных консервацией. Исходный код pickletools содержит обширные комментарии об опкодах, используемых pickle протоколами.

Опкоды? Я совсем не рассчитывал, что имплементация pickle будет такой:

def dumps(obj):
    return obj.__repr__()

def loads(pickled):
    # Внимание: Модуль pickle не защищен...
    return eval(pickled)


Но я также не ожидал, что она определит собственный низкоуровневый язык. К счастью, вторая часть строки говорит правду — pickletools модули очень помогают понять, как работаю протоколы. Плюс, комментарии в коде оказались очень забавными.

К примеру, поставим вопрос, на какой версии протокола нам нужно сфокусироваться. В Python 3.6 их в общей сложности пять. Они пронумерованы от 0 до 4. Протокол 0 — очевидный выбор, потому что он назван «читабельным» в документации, а исходный код pickletools предлагает дополнительную информацию:

Опкоды pickle никогда не исчезают, даже когда появляются новые способы делать что-нибудь. Репертуар PM только растет со временем… «Вздутие опкода» — не тонкий намек, а источник изнуряющих сложностей.

Оказывается, каждый новый протокол является суперсетом предыдущего. Даже если не брать во внимание, что протокол 0 «читабельный» (это неважно, потому что мы декомпилируем инструкции), он также содержит наименьшее количество возможных опкодов. Что идеально подходит, если цель — понять, как создаются вредоносные pickle файлы.

Если вы запутались с опкодами, не переживайте. Сейчас мы вернемся к Python, а после я в деталях объясню, как опкоды соотносятся с кодом Python. Создадим простой класс Python без опкодов.

class Bomb:
    def __init__(self, name):
        self.name = name

    def __getstate__(self):
        return self.name

    def __setstate__(self, state):
        self.name = state
        print(f'Bang! From, {self.name}.')

bomb = Bomb('Evan')


Методы __setstate__() и __getstate__() используются в модуле pickle для сериализации и десериализации классов. Часто не нужно определять их самостоятельно, потому что имплементации по умолчанию просто сериализуют __dict__ инстанса. Как видим, я прямо определил их здесь, чтобы спрятать небольшой сюрприз в момент десериализации объекта Bomb.

Проверим, работает ли код десериализации с сюрпризом. Мы законсервируем и расконсервируем объект с помощью:

import pickle

pickled_bomb = pickle.dumps(bomb, protocol=0)
unpickled_bomb = pickle.loads(pickled_bomb)


Получаем:

# Пиф-паф! От Эвана.
Bang! From, Evan.    


Точно по плану! Есть только одна проблема: если мы попытаемся десериализовать строку pickled_bomp в контексте, где Bomb не определена, ничего не выйдет. Вместо этого появится ошибка:

AttributeError: Can't get attribute 'Bomb' on 


Оказывается, мы можем запустить наш кастомный метод __setstate__(), только если у расконсервирующего контекста уже есть доступ к коду с нашим вредоносным print выражением. А если у нас уже есть доступ к коду, запущенному жертвой, зачем вообще заморачиваться с pickle? Мы можем просто написать вредоносный код в любом другом методе, которым воспользуется жертва. И это верно — я просто хотел наглядно продемонстрировать.

В конце концов, совсем не напрасно подозревать, что Pyton может поддерживать консервационный байт-код для метода десериализации объекта. К примеру, модуль marshal может сериализовать методы, и многие альтернативы pickle: marshmallow, dill, и pyro, также поддерживают сериализацию функции.

Тем не менее, зловещее предупреждение в документации pickle говорит не об этом. Нужно погрузиться чуть глубже, чтобы узнать опасности десериализации.

Декомпилируем Pickle

Настало время попытаться понять, как на самом деле работает консервация. Начнем с рассмотрения объекта из предыдущего раздела — pickled_bomb.

b'ccopy_reg\n_reconstructor\np0\n(c__main__\nBomb\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\nVEvan\np5\nb.'


Постойте… мы же использовали протокол 0? Разве это «читабельно»?

Но ничего страшного, в исходном коде pickletools мы должны найти «обширные комментарии об опкодах, используемых pickle протоколами». Они должны помочь нам разобраться в проблеме!

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

 — комментарий в исходном коде pickletools

Боже. Во что мы вписались?

Шутки в сторону, исходный код pickle tools действительно отлично прокомментирован. И сами инструменты не менее полезны. Например, есть метод для разборки pickle под названием pickletools.dis (). Он поможет перевести наш pickle на более понятный язык.

Для разборки нашей строки pickled_bomb, просто запустим следующее:

import pickletools

pickletools.dis(pickled_bomb)


В результате получим:
0: c    GLOBAL     'copy_reg _reconstructor'
   25: p    PUT        0
   28: (    MARK
   29: c        GLOBAL     '__main__ Bomb'
   44: p        PUT        1
   47: c        GLOBAL     '__builtin__ object'
   67: p        PUT        2
   70: N        NONE
   71: t        TUPLE      (MARK at 28)
   72: p    PUT        3
   75: R    REDUCE
   76: p    PUT        4
   79: V    UNICODE    'Evan'
   85: p    PUT        5
   88: b    BUILD
   89: .    STOP
highest protocol among opcodes = 0


Если вы имели дело с языками вроде x86, Dalvik, CLR, то все вышеописанное может показаться знакомым. Но даже если не имели — не беда, разберем все по шагам. Сейчас достаточно знать, что заглавные слова вроде GLOBAL, PUT, и MARK — опкоды, и инструкции, которые интерпретируются почти как функции в высокоуровневых языках. Все что правее — аргументы этих функций, а левее показано, как они были зашифрованы в оригинальной «читабельной» строке.

Но перед тем как начать пошаговый разбор, представим еще одну полезную вещь из pickletools: pickletools.optimize (). Этот метод удаляет неиспользуемые опкоды из pickle. На выходе получается упрощенный, но аналогичный pickle. Можем разобрать оптимизированную версию pickled_bomb, запустив следующее:

pickled_bomb = pickletools.optimize(pickled_bomb)
pickletools.dis(pickled_bomb)


И получим упрощенную версию серии инструкций:

 0: c    GLOBAL     'copy_reg _reconstructor'
   25: (    MARK
   26: c        GLOBAL     '__main__ Bomb'
   41: c        GLOBAL     '__builtin__ object'
   61: N        NONE
   62: t        TUPLE      (MARK at 25)
   63: R    REDUCE
   64: V    UNICODE    'Evan'
   70: b    BUILD
   71: .    STOP
highest protocol among opcodes = 0


Можно заметить, что от оригинала это отличается только отсутствием всех PUT опкодов. Что оставляет нам 10 инструкционных шагов, которые нужно понять. Вскоре, мы рассмотрим их по отдельности и вручную «разберем» в Python код.

Во время расконсервации опкоды обычно интерпретируются сущностью под названием Pickle Machine (PM). Каждый pickle — программа, запущенная на PM, примерно как скомпилированный Java код запускается на Java Virtual Machine (JVM). Чтобы разобрать наш pickle код, нужно разобраться в работе PM.

В PM есть две области для хранения данных и взаимодействия с ними: memo и stack. Memo предназначен для долговременного хранения, и похож на словарь Python, сопоставляющий целые числа и объекты. Stack подобен списку Python, с которым взаимодействуют многие операции, добавляя и вытаскивая вещи. Мы можем эмулировать эти области данных Python следующим образом:

# долговременная память/хранилище PM
memo = {}
# Stack PM, с которым взаимодействует большая часть опкодов
stack = []


Во время расконсервации PM читает pickle программу и последовательно выполняет каждую инструкцию. Он завершается всякий раз, когда достигает опкода STOP; любой объект, находящийся наверху стека, является финальным результатом расконсервации. Используя наши сэмулированные memo и stack хранилища, попробуем перевести наш pickle на Python… инструкция за инструкцией.

  • GLOBAL пушит класс и функцию в стэк, передавая модуль и имя в качестве аргументов. Заметим, что сообщение немного вводит в заблуждение, потому что в Python 3 copy_reg был переименован в copyreg.
  • MARK пушит в стэк особый markobject, чтобы впоследствии мы могли использовать его для уточнения части стэка. Мы воспользуемся строкой «MARK» для репрезентации markobject.
    # Пушит markobject в стэк.
    # 25: (    MARK
    stack.append('MARK')
    


  • GLOBAL опять. Но в этот раз с модулем __main__, поэтому нам не нужно проводить import.
    # Пушит глобальный объект (module.attr) в стэк.
    # 26: c        GLOBAL     '__main__ Bomb'
    stack.append(Bomb)
  • GLOBAL опять. И нам не нужно явно импортить object.
    # Пушит глобальный объект (module.attr) в стэк.
    # 41: c        GLOBAL     '__builtin__ object'
    stack.append(object)


  • NONE просто пушит None в стэк.
    # Пушит None в стэк.
    # 61: N        NONE
    stack.append(None)


  • TUPLE немного сложнее. Помните, как мы раньше добавляли «MARK» в стэк? Эта операция переместит все из стэка после «MARK» в кортеж. После этого она удалит «MARK» и заменит его на кортеж.
    # Создать кортеж из верхней части стэка, после markobject.
    # 62: t        TUPLE      (MARK at 28)
    last_mark_index = len(stack) - 1 - stack[::-1].index('MARK')
    mark_tuple = tuple(stack[last_mark_index + 1:])
    stack = stack[:last_mark_index] + [mark_tuple]
    Будет полезным посмотреть, как это преобразуется в стэке.
    # стэк перед операцией TUPLE:
    [, 'MARK', __main__.Bomb, object, None]
    # стэк после операции TUPLE:
    [, (__main__.Bomb, object, None)]


  • REDUCE убирает последние две вещи из стэка. После этого она вызывает предпоследний объект, используя позиционное расширение последней вещи, и полученный результат размещает в стэке. Словами сложно объяснить, но в коде все понятно
    # Пушит объект, полученный из callable и tuple аргумента.
    # 63: R    REDUCE
    args = stack.pop()
    callable = stack.pop()
    stack.append(callable(*args))


  • UNICODE просто пушит строку юникода в стэк (очень неплохую строку юникода, к слову!)
    # Пушит объект строк Python Unicode.
    # 64: V    UNICODE    'Evan'
    stack.append(u'Evan')


  • BUILD убирает последний объект из стэка и затем передает его в качестве аргумента в __setstate__() новой последней вещью стэка
    # Завершает создание объекта через обновление __setstate__ или dict.
    # 70: b    BUILD
    arg = stack.pop()
    stack[-1].__setstate__(arg)


  • STOP просто означает, что любой предмет вверху стэка — наш финальный результат.
    # Останавливает PM.
    # 71: .    STOP
    unpickled_bomb = stack[-1]


Фух, мы закончили! Не уверен, что наш код особенно Python«ный…, но он эмулирует работу PM. Можно заметить, что мы ни разу не воспользовались memo. Помните все те PUT опкоды, которые были удалены при pickletools.optimize ()? В них могло происходить взаимодействие с momo, но в нашем простом примере это не понадобилось.

Попробуем упростить код, чтобы наглядно показать его работу. По факту, кроме перемешивания данных, происходит только три операции: импорт _reconstructor в инструкции 1, вызов _reconstructor в инструкции 7 и вызов __setstate__() в инструкции 9. Если мысленно представить перемешивание данных, то можно выразить все тремя строками Python.

# Инструкция 1, где произошел импорт `_reconstructor`
from copyreg import _reconstructor
# Инструкция 7, где `_reconstructor` был вызван
unpickled_bomb = _reconstructor(cls=Bomb, base=object, state=None)
# Инструкция 9, где `__setstate__` был вызван
unpickled_bomb.__setstate__('Evan')


Взгляд изнутри на исходный код copyreg._reconstructor () выявляет, что мы просто вызываем object.__new__(Bomb). Пользуясь этим знанием, можем упростить все до двух строк.

unpickled_bomb = object.__new__(Bomb)
unpickled_bomb.__setstate__('Evan')


Поздравляю, вы только что декомпилировали pickle!

Настоящая Pickle Бомба

Я не pickle эксперт, но уже представляю в общих чертах, как сконструировать вредоносный pickle. Можно использовать опкод GLOBAL для импорта любой функции — os.system и __builtin__.eval кажутся подходящими кандидатами. А после воспользуемся REDUCE для его выполнения с произвольным аргументом. Но только… погодите, что это?

Если не isinstance (callable, тип), REDUCE не будет ругаться только в том случае, когда callable был зарегистрирован в словаре safe_constructors модуля copyreg, или у callable есть волшебный атрибут __safe_for_unpickling__ с истинным значением. Не знаю, почему так происходит, но я видел достаточное количество жалоб <подмигивает>.

Подмигиваем в ответ. Похоже документация pickletools подсказывает, что только разрешенные callable могут быть выполнены REDUCE. На мгновение это заставило меня поволноваться, но поиск «safe_constuctors» быстро помог найти PEP 307 из 2003.

В прошлых версиях Python расконсервация имела «проверку безопасности» на отдельных операциях, отказываясь вызывать функции или конструкторы, которые не были отмечены «безопасными для расконсервации» за наличие атрибута __safe_for_unpickling__ равного 1, или регистрации в глобальном регистре copy_reg.safe_constructors.

Эта функция создает ложное ощущение безопасности: никто никогда не проводил необходимую обширную проверку кода, чтобы доказать, что расконсервация pickle из ненадежных источников не может вызвать нежелательный код. Фактически, баги в модуле pickle.py Python 2.2 позволяют легко обойти эти меры предосторожности.

Мы твердо убеждены, что при использовании интернета, лучше знать, что ваш протокол небезопасен, чем доверять безопасности протокола, чья имплементация не была досконально проверена. Даже высококачественная имплементация популярных протоколов зачастую содержит ошибки; без больших временных вложений имплементация pickle в Python просто не может дать гарантий. Поэтому, начиная с версии Python 2.3, все проверки безопасности расконсервации официально исключены и заменены на предупреждение:
Предупреждение: Не расконсервируйте данные, полученные из ненадеждых и не прошедших проверку источников.

Здравствуй, тьма, наш старый друг. Здесь все и началось.

Вот и все, мы нашли ключевой ингредиент, и не осталось ложного чувства защищенности от того, что мы планируем сделать. Начнем с написания нашей бомбы:

# добавить функцию в стэк для выполнения arbitrary python
GLOBAL     '__builtin__ eval'
# отметить старт кортежа наших аргументов
MARK
    # добавить код Python, который мы хотим выполнить в стэке
    UNICODE    'print("Bang! From, Evan.")'
    # завернуть код в кортеж, чтобы его можно было распарсить через REDUCE
    TUPLE
# вызвать `eval()` с нашим кодом Python в качестве аргумента
REDUCE
# использовать STOP, чтобы сделать PM код валидным
STOP


Чтобы превратить это в настоящий pickle, нужно заменить каждый опкод на соответствующий ASCII код: c для GLOBAL, (для MARK, V для UNICODE, t для TUPLE, R для REDUCE, и. для STOP. Заметим, что это те же самые значения, что были прописаны слева от опкодов в выводе pickletools.dis () ранее. Аргументы анализируются после каждого опкода с учетом комбинации позиции и ограничения новой строки. Каждый аргумент расположен либо сразу после соответствующего опкода, либо после предыдущего аргумента, и читается непрерывно до тех пор, пока не будет найден символ новой строки. Перевод в машинный код pickle дает следующее:

c__builtin__
eval
(Vprint("Bang! From, Evan.")
tR.


Наконееец-то, мы можем это проверить:

# Запусти меня дома!
# Я безопасен, обещаю!
pickled_bomb = b'c__builtin__\neval\n(Vprint("Bang! From, Evan.")\ntR.'
pickle.loads(pickled_bomb)


Иии…

# Пиф-паф! От Эвана.
Bang! From, Evan.


Знаю, что у вас нет причин мне верить, но это действительно сработало с первого раза.
Легко понять, что кто-нибудь может с легкостью придумать более вредоносный аргумент для eval (). PM можно заставить делать буквально все что угодно, что может выполнить код Python, включая системные команды os.system ().

Все хорошее когда-нибудь заканчивается

Я планировал узнать, как сделать опасный pickle, но случайно в процессе понял, как pickle«ы работают. Признаюсь, мне понравилось копаться в этой Pickle Machine. Исходный код pickletools ощутимо помог, и я рекомендую его, если вам интересно узнать больше о pickle протоколе и PM.

THE END

Как всегда ждём пожелания и вопросы, которые можно задать тут или лично Илье Лебедеву на Дне открытых дверей.

© Habrahabr.ru