Обложка

Чуть больше года назад я задался вопросом, из которого потом вырос целый небольшой пэт-проект: возможно ли прямо в рантайме установить питонячий пакет и воспользоваться им? Оказалось, что да, можно. Сегодня я расскажу, как это сделать, как это работает, и какие уязвимости открывает перед всей питонячьей экосистемой.

Первая доза бесплатно

Инструмент, который я по итогу написал, называется INSTLD. Устанавливается он одной командой:

pip install instld

Давайте проверим, что он работает. Вбив команду instld в консоль, вы увидите приглашение ко вводу текста, которое выглядит примерно так:

$ instld
⚡ INSTLD REPL based on
Python 3.11.9 (v3.11.9:de54cf5be3, Apr  2 2024, 07:12:50) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.

>>>

То, что вы сейчас видите, на самом деле — кусок охреневшей магии. Попробуем вбить пару простых питонячьих выражений:

>>> 5 + 5
10
>>> import sys
>>> sys.version
'3.11.9 (v3.11.9:de54cf5be3, Apr  2 2024, 07:12:50) [Clang 13.0.0 (clang-1300.0.29.30)]'
>>> print("it's", 'just', 'a', 'REPL')
it's just a REPL
>>>

В общем, мы видим, что эта штука ведет себя как обычный питонячий REPL. Давайте попробуем импортировать какой-нибудь пакет:

>>> import pandas as pd
Collecting pandas
  Downloading pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl.metadata (89 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 89.9/89.9 kB 143.4 kB/s eta 0:00:00
Collecting numpy>=1.23.2 (from pandas)
  Using cached numpy-2.1.1-cp311-cp311-macosx_14_0_arm64.whl.metadata (60 kB)
Collecting python-dateutil>=2.8.2 (from pandas)
  Using cached python_dateutil-2.9.0.post0-py2.py3-none-any.whl.metadata (8.4 kB)
Collecting pytz>=2020.1 (from pandas)
  Downloading pytz-2024.2-py2.py3-none-any.whl.metadata (22 kB)
Collecting tzdata>=2022.7 (from pandas)
  Downloading tzdata-2024.2-py2.py3-none-any.whl.metadata (1.4 kB)
Collecting six>=1.5 (from python-dateutil>=2.8.2->pandas)
  Using cached six-1.16.0-py2.py3-none-any.whl.metadata (1.8 kB)
Downloading pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl (11.3 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11.3/11.3 MB 153.5 kB/s eta 0:00:00
Using cached numpy-2.1.1-cp311-cp311-macosx_14_0_arm64.whl (5.4 MB)
Using cached python_dateutil-2.9.0.post0-py2.py3-none-any.whl (229 kB)
Downloading pytz-2024.2-py2.py3-none-any.whl (508 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 508.0/508.0 kB 80.8 kB/s eta 0:00:00
Downloading tzdata-2024.2-py2.py3-none-any.whl (346 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 346.6/346.6 kB 178.2 kB/s eta 0:00:00
Using cached six-1.16.0-py2.py3-none-any.whl (11 kB)
Installing collected packages: pytz, tzdata, six, numpy, python-dateutil, pandas
Successfully installed numpy-2.1.1 pandas-2.2.3 python-dateutil-2.9.0.post0 pytz-2024.2 six-1.16.0 tzdata-2024.2

[notice] A new release of pip is available: 24.0 -> 24.2
[notice] To update, run: python3.11 -m pip install --upgrade pip
>>> pd.DataFrame({'lol': [1, 2, 3], 'kek': [4, 5, 6]})
   lol  kek
0    1    4
1    2    5
2    3    6
>>> pd.__version__
'2.2.3'
>>>

Так, стоп… pandas не был установлен на моем компьютере. Он установился прямо в процессе работы программы, и он работает.

Теперь попробуем запустить небольшой скрипт через INSTLD. Создадим файл script.py с похожим содержимым, давайте для простоты тоже используем pandas:

import pandas as pd

print(pd.DataFrame({'lol': [1, 2, 3], 'kek': [4, 5, 6]}))

Запускаем:

$ instld script.py
...
   lol  kek
0    1    4
1    2    5
2    3    6

Короче, опять работает.

И под конец еще немного магии:

pip list | grep pandas

Пусто! То есть мы импортировали pandas прямо из сети, поигрались с ним, а когда закончили — никакого мусора не осталось. Безотходное производство.

Переходим на более тяжелые наркотики

Фокусы, что я показал выше — любопытные и местами полезные в повседневной жизни, однако я пишу в журнал “Хакер”, а не в клуб любителей прикольных утилит. И в самом начале я обещал показать что-то хакерское, что делает уязвимой всю экосистему Python-пакетов. Штош, давайте приступим.

Создадим новый файл вот с таким содержимым:

import instld

with instld('pandas', catch_output=True):
    import pandas as pd
    print(f'The version of pandas is: {pd.__version__}')

Запустим его через обычную команду python (или python3, если у вас что-то типа линукса), работает:

$ python3 script.py
The version of pandas is: 2.2.3

А еще можно вот так:

import instld

with instld('flask==3.0.0', catch_output=True) as context:
    flask_1 = context.import_here('flask')
    print(f'The version of the first flask: {flask_1.__version__}')  # 3.0.0

with instld('flask==3.0.3', catch_output=True) as context:
    flask_2 = context.import_here('flask')
    print(f'The version of the second flask: {flask_2.__version__}')  # 3.0.3

Запускаем, смотрим:

$ python3 script.py
The version of the first flask: 3.0.0
The version of the second flask: 3.0.3

Давайте осмыслим, что сейчас произошло. Мы написали скрипт и запустили его через обычный питон, никаких специальных команд. Прямо внутри скрипта мы подключили сторонний пакет, который прямо на месте скачался из сети, был использован и затем автоматически удален, когда поток выполнения вышел из контекста. Никаких следов. Больше того, мы смогли установить в одном рантайме 2 разных версии одного и того же пакета. Мы можем делать с пакетами невозможное.

Это — фундаментальная уязвимость. В наших примерах строчки с именами пакетов, которые были установлены внутри контекста, существовали в виде литералов, т.е. заранее известно, где какой будет пакет и какая у него будет версия. Однако совсем не обязательно, чтобы это было так. Название пакета может, к примеру, запрашиваться откуда-то из сети, либо как-то иначе генерироваться прямо на месте, создавая условное поведение. Когда нужно — скачивается пакет с безобидным содержимым, все работает как того ожидает пользователь; в иной ситуации скачивается другой пакет, содержащий внутри себя зловред:

import instld

def is_user_a_bad_boy():
    ...

if is_user_a_bad_boy():
    package_name = 'bad_package'
else:
    package_name = 'good_package'

with instld(package_name, catch_output=True) as context:
    some_module = context.import_here('some_module')

Особенности такого рода зловредов:

  • Пакет может не содержать внутри себя опасного кода непосредственно. Опасный код скачается из сети, когда он будет нужен.
  • Обычный тулинг для сканирования исходного кода на уязвимости может такие проблемы не заметить, т.к. сканируемый код не вредоносен сам по себе.
  • Анализ графа зависистей библиотеки бесполезен, т.к. зависимости, явно объявленные пакетом, не полны. Динамический граф зависимостей может быть слишком сложен для анализа.
  • Возможны очень таргетированные атаки. Код основного пакета может подтягивать безопасную дочернюю зависимость 99% пользователей, но когда оказывается на целевых устройствах — скачает опасную зависимость и запустит вредоносный код.
  • Возможны “спящие” уязвимости. Во многих крупных корпорациях есть собственные “отстойники” для внешних пакетов. Обычно после выхода новой версии любого пакета мы некоторое время ждем, пока где-то успеют появиться сообщения об уязвимостях в ней, и если их нет — даем сотрудникам корпорации использовать новую версию пакета. Однако с такой технологией возможен обход карантинов, можно легко сделать уязвимость, которая “проснется” через месяц или более, и начнет скачивать из сети опасный код вместо безопасного.

Короче, это проблема.

Проходим реабилитацию

Как защитить свою организацию или личные проекты от подобных опасностей? Первое, что приходит в голову: а давайте запретим INSTLD! Довольно легко посмотреть в список зависимостей пакета, и обнаружив там INSTLD — просто не дать его установить. Однако это, к сожалению, плохой путь. В действительности INSTLD не делает никакой сложной магии, под капотом это довольно примитивный кусок кода, опирающийся на стандартную библиотеку. Ничего такого, что злоумышленник не мог бы скопипастить, или на худой конец — придумать.

Как это все работает, если вкратце?

  • В контекста создается временная директория для установки пакетов.
  • В эту директорию устанавливается пакет. Обычным pip’ом, который дергается через subprocess.
  • Директория, куда был установлен пакет, временно (на время действия контекста) добавляется в sys.path — глобальную переменную со списком всех директорий, где происходит поиск пакета при каждом импорте. Когда вы пишете в своем скрипте import something, интерпретатор проходится по всем директориям из этого списка, пока не найдет там пакет под названием something.

Как видим, сам по себе механизм не хитрый. Банить конкретно INSTLD — очень плохая защита, так что же может помочь? У меня есть пара вариантов решений, если вам придут в голову другие — пишите в комментариях:

  • Банить любой пакет за любые манипуляции с sys.path. К сожалению, этот способ может прикрыть и кучу вполне безобидных библиотек. Самые нужные из них можно внести в белый список, а остальные — тупо банить.
  • Не давать программе взаимодействовать с диском, запускать ее в контейнере с рид-онли файловой системой. В этом случае INSTLD обломился бы еще на этапе попытки скачать пакет. Однако полной защитой это не является, поскольку видится не совсем уж невозможным провести все манипуляции со скачиванием и установкой пакета прямо в оперативной памяти.

Закономерный финал

Итак, мы выяснили, как можно делать разные фокусы с установкой питонячьих пакетов. Оказывается, возможно устанавливать их прямо в рантайме — по требованию. Можно делать всякое невозможное типа использования нескольких разных версий одного и того же пакета в одном рантайме. Прикольно.

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

Оригинальная публикация статьи в журнале «Хакер», под пейволлом.