Чуть больше года назад я задался вопросом, из которого потом вырос целый небольшой пэт-проект: возможно ли прямо в рантайме установить питонячий пакет и воспользоваться им? Оказалось, что да, можно. Сегодня я расскажу, как это сделать, как это работает, и какие уязвимости открывает перед всей питонячьей экосистемой.
Первая доза бесплатно
Инструмент, который я по итогу написал, называется 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
обломился бы еще на этапе попытки скачать пакет. Однако полной защитой это не является, поскольку видится не совсем уж невозможным провести все манипуляции со скачиванием и установкой пакета прямо в оперативной памяти.
Закономерный финал
Итак, мы выяснили, как можно делать разные фокусы с установкой питонячьих пакетов. Оказывается, возможно устанавливать их прямо в рантайме — по требованию. Можно делать всякое невозможное типа использования нескольких разных версий одного и того же пакета в одном рантайме. Прикольно.
Но как и почти все прикольное в этом мире, это открывает новые опасности, против которых могут не сработать старые защиты. Надеюсь, вас коснется только прикольная сторона дела.
Оригинальная публикация статьи в журнале «Хакер», под пейволлом.