erid: 2SDnje9hinm erid: 2SDnje9hinm

Вскрываем средство для DDoS-атак на российскую ИТ-инфраструктуру UserGate MRC

erid: 2SDnje7M2nD
Вскрываем средство для DDoS-атак на российскую ИТ-инфраструктуру UserGate MRC
Вскрываем средство для DDoS-атак на российскую ИТ-инфраструктуру UserGate MRC
25.10.2022

В начале апреля 2023 года на одном из корпоративных хостов был обнаружен подозрительный файл mhddos_proxy_linux_arm64 (MD5: 9e39f69350ad6599420bbd66e2715fcb), загружаемый вместе с определенным Docker-контейнером. По открытым источникам стало понятно, что данный файл представляет из себя свободно распространяемый инструмент для осуществления распределённой атаки на отказ в обслуживании (DDoS), направленный против российской ИТ-инфраструктуры.

После запуска программа получает все необходимые настройки и автоматически инициирует массированные сетевые подключения к целевым хостам на различных уровнях TCP/IP для осуществления отказа в обслуживании.

Так как данная программа не является вредоносной в привычном для антивирусных продуктов смысле – не осуществляет закрепления и самораспространения, не пытается скрыть своего присутствия на устройстве, и на текущий момент не используется для управления устройством или похищения информации с него – ни один антивирус не считает этот файл вредоносным и не пытается предотвратить его выполнения. А ведь в отличие от обычного вредоноса, выполнение такой программы приводит к непредумышленному участию в действиях, наказуемых по законодательству РФ, что может быть критичнее, чем компрометация личного устройства или корпоративной сети.

1.png

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

Данный материал будет полезен для специалистов по ИБ/ИТ, а также для всех интересующихся внутренним устройством языка Python и обфускацией ПО. Помимо исследования предоставляется список целей, извлеченный из внутренней конфигурации инструмента.

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

Level 1: Easy. Расшифровываем L7 конфигурацию

Спустя пару секунд в гугле по запросу “mhddos” легко находится информация об инструменте mhddos: https://github.com/MatrixTM/MHDDoS. Это проект с открытым исходным кодом, предоставляющий широкий функционал по сетевому стресс-тестированию на различных уровнях OSI (Layer 4 - транспортный и Layer 7 - приложений) и множеством поддерживаемых протоколов, с возможностью обхода некоторых капч для защиты сайтов от DDoS-атак, и использованием многочисленных прокси-серверов. То есть функционал инструмента известен, и любой желающий со знанием Python может его изучить. Однако MHDDoS распространяется с исходными кодами, а не в виде бинарного файла…

А вот по запросу mhddos_proxy уже можно найти репозиторий кастомизированного проекта https://github.com/porthole-ascend-cinnamon/mhddos_proxy и его описание от авторов https://telegra.ph/MHDDoS-Proxy-03-13, сетующих на то, что оригинальный mhddos уже перестал выдавать хорошую производительность, и предоставляющих новую, более удобную версию скрипта, в которой список целей выбирается самими разработчиками и поставляется с конфигурацией. Что ж, эффективно защитить исходники на Python невозможно, так ведь? Тогда просто найдём конфигурацию со списком целей в исходниках, делов на пару минут!

2.png

1 – Нейросетевой питон

Открываем репозиторий – в глаза сразу же бросается файл config.json:

3.png

2 – Конфигурация инструмента

Списки проксей по этим ссылкам уже недоступны – теперь в указанных репозиториях вместо файлов “1(2,3,4).txt”, располагаются файлы “11.txt”, однако они зашифрованы и не предназначены для данной версии mhddos_proxy.

URL с целями (файл “11.txt”) все ещё можно скачать, и эти файлы постоянно обновляются. Однако после скачивания файла 11.txt становится понятно, что это совсем не текст:

4.png

3 – Содержимое файла 11.txt

Следовательно, программа каким-то образом декодирует данный файл. Поэтому нам нужно найти процедуры этого декодирования или расшифрования. Поиск по коду строки “config.json” приводит к нужному методу _possibly_decrypt в файле src/targets.py:

5.png

4 – Фрагмент файла src/targets.py

Данный метод сравнивает первые 4 байта файла со списком версий в словаре ENC_KEYS, и если есть совпадение, то расшифровывает оставшиеся данные файла соответствующим ключом из словаря с использованием алгоритма шифрования ChaCha20Poly1305. Сам словарь при этом содержит всего одну версию с ключом:

{b'\xe4\xdc\xf7\x1f': b'fZPK2OTLiNdqVDBxJTSMuph/rfLzpFWHDmHC1/+rR1s='}

И она в точности совпадает с первыми 4-мя байтами файла конфигурации из файла 11.txt. Что ж, нам повезло, ведь это значит, что и мы тоже можем повторить то же самое локально: просто копируем данный фрагмент кода, и запускаем на своей машине (возможно, потребуется скачать пакет cryptography для python). На выходе получаем что-то интересное:

6.png

5 – Фрагмент расшифрованного файла с целями для DDoS-атаки

А именно – список из около четырёхсот URL-ов сайтов российских федеральных и муниципальных учреждений, образовательных организаций, провайдеров интернет-услуг. Дополнив этот список другими файлами, закодированными base64 или зашифрованными данным алгоритмом, получаем около 500 URL-ов, вот лишь некоторые из них:

https://lk.mid.ru/
https://dgp.mid.ru/
http://www.college-mid.ru/ HTTP_TEMPLATE
https://zp2020.midpass.ru/
https://biopassportmid.midpass.ru/
https://www.muiv.ru/abiturient/epk/#epk_form
http://nnovcons.ru/obrazovanie/abiturientam/
http://inn.fsb.ru/pages/02-rules.html
https://ngieu.ru/algoritm_postupleniya
https://nnov.hse.ru/bacnn
https://pk.hse.ru/
https://niu.ranepa.ru/abitur/bachelor/
https://lk.ranepa.ru/pk/auth.php
https://lka.nngasu.ru/register
https://lk.belgorod.ru post
http://beladm.ru get
https://tdpra.ru get
http://93.170.82.246 post

Ознакомиться с полным списком и проверить наличие в нём интересующего ресурса можно тут (https://github.com/ogre2007/mhddos_proxy_config/blob/main/mhddos_urls.txt).

Но тут всего лишь 500 ссылок. Исключая многочисленные домены МИД РФ и сервера Билайна, остаётся и того меньше – что-то не густо. Следует отметить, что по ссылкам из конфига можно найти и другие файлы, также зашифрованные, но уже на другом ключе, которые так и не удалось расшифровать. Возможно, в них содержится ещё большее число доменов.

Разработчиком предпринята попытка исключения использования инструмента против определённых целей: в файле https://github.com/porthole-ascend-cinnamon/mhddos_proxy/blob/main/src/exclude.py указаны соответствующие IP (например, внутренние сетевые адреса, Cloudflare, DNS-сервера Google), а в обфусцированном файле https://github.com/porthole-ascend-cinnamon/mhddos_proxy/blob/main/src/vendor/rotate.py исключается атака по доменам зоны “.ua”. Можем деобфусцировать его вручную, просто последовательно применяя base64 (например, с помощью https://www.base64decode.org/), декодируя текст в экранированных hex-строках (например, через https://codepen.io/kamakalolii/pen/RKNoMr), и смещая текст с помощью rot13 (https://rot13.com/). Либо можно воспользоваться любым онлайн-интерпретатором Python и скопировать туда обфусцированный код. На выходе получится следующее:

  7.png

6 – Деобфусцированный код rotate.py

Если встречается URL оканчивающийся на suffix, то цель заменяется на одну из целей в списке params.

В файле https://github.com/porthole-ascend-cinnamon/mhddos_proxy/blob/main/src/vendor/useragents.py также находятся упакованные Useragent-ы для подключения к сайтам, однако это стандартная информация для мимикрии под легитимные устройства, и не представляет интереса.

В файле src/utils.py также можно обнаружить код для обхода защиты от ботов на Госуслугах (код создания правильной Cookie):

8.png

7 – Фрагмент файла utils.py

Хорошо, мы получили и расшифровали конфигурацию. Но обнаруженный изначально файл mhddos_proxy_arm64 не является питоновским скриптом, так откуда же он взялся? Ответ находится в том же репозитории, разработчик указывает, что python-проект с открытым исходным кодом уже устарел, и призывает всех переходить на новую версию: https://github.com/porthole-ascend-cinnamon/mhddos_proxy_releases. К сожалению, в данном репозитории отсутствуют исходные коды, и инструмент распространяется только в виде исполняемых программ. Значит, придётся применять методы реверс-инжиниринга.

Скачиваем версию для linux под x86 (mhddos_proxy_linux v81, MD5: a004b948f72c6eb14f348cc698bda16e), возможно, её будет проще исследовать, чем бинарь для ARM. Открываем в дизассемблере, смотрим строки, и видим характерные строки начинающиеся с _PYI_:

9.png

8 – Фрагмент строк программы

Данные строки указывают на то, что исходный код был упакован с помощью PyInstaller. Это проект с открытым исходным кодом (https://github.com/pyinstaller/pyinstaller) предназначенный для компиляции Python-проектов в исполняемые файлы с целью удобного распространения, и защиты исходного кода от копирования и модификации.

Level 2: Medium. Распаковываем модифицированный PyInstaller

Функционал упаковщика PyInstaller заключается в том, чтобы скомпилировать весь исходный код (включая зависимости) в файлы байткода .pyc, и упаковать его вместе с библиотекой интерпретатора Python в самораспаковывающийся архив в виде исполняемого файла. При запуске файла PyInstaller подключает исполняемый модуль интерпретатора, распаковывает архив с байткодом во временную папку (кроме main-скрипта), и запускает main-скрипт без распаковки, настроив его окружение таким образом, чтобы зависимости корректно подключались из временного каталога.

10.png

9 – Нейросетевой упакованный питон

Следовательно, мы можем осуществить обратные действия и извлечь скомпилированный байткод (насколько он окажется полезным для анализа – уже другой вопрос).

Распаковка из исполняемого файла

К счастью, для PyInstaller уже есть распаковщик с открытым исходным кодом – https://github.com/extremecoders-re/pyinstxtractor. Запускаем, и получаем следующую ошибку:

$python3.9 pyinstxtractor/pyinstxtractor.py mhddos_proxy_linux

[+] Processing mhddos_proxy_linux

[!] Error : Missing cookie, unsupported pyinstaller version or not a pyinstaller archive

Лезем в исходный код распаковщика, и видим:

11.png

10 – Фрагмент pyinstxtractor.py

Константа MAGIC обозначает начало заголовка архива упакованных Python-файлов – “MEI\014\013\012\013\016”. Что ж, оказалось, что не всё так просто, видимо разработчик модифицировал PyInstaller для упаковки mhddos_proxy, а значит придётся лезть в дизассемблер.

Изучая процедуру main, находим процедуру по адресу 0x4024C0, разбирающую заголовок архива, в которой оказывается новое, нестандартное магическое число 0x742F271B6DD36293:

12.png

11 – Фрагмент процедуры, разбирающей заголовок архива

Поправляем pyinstxtractor.py тут же в исходном коде:

13.png

12 – Добавление корректной сигнатуры заголовка архива

Если более внимательно рассмотреть исходный код pyinstxtractor и декомпилированную процедуру разбора заголовка, то можно заметить, что важные для распаковки значения преобразованы XOR-ом с различными константными значениями:

14.png

13 – Фрагмент декомпилированной процедуры, разбирающей заголовок архива

Поправляем pyinstxtractor ещё раз, теперь в методах parseTOC и getCArchiveInfo:

15.png

14 – Фрагмент дополненной процедуры parseTOC

Запускаем пропатченный pyinstxtractor ещё раз:

$python3.9 pyinstxtractor.py mhddos_proxy_linux

[+] Processing mhddos_proxy_linux__

[+] Pyinstaller version: 2.1+

[+] Python version: 3.9

[+] Length of package: 25802384 bytes

[+] Found 102 files in CArchive

[+] Beginning extraction...please standby

[+] Possible entry point: pyiboot01_bootstrap.pyc

[+] Possible entry point: pyi_rth_inspect.pyc

[+] Possible entry point: pyi_rth_subprocess.pyc

[+] Possible entry point: pyi_rth_pkgutil.pyc

[+] Possible entry point: pyi_rth_multiprocessing.pyc

[+] Possible entry point: runner.pyc

[+] Found 695 files in PYZ archive

[!] Error: Failed to decompress PYZ-00.pyz_extracted/OpenSSL/__init__.pyc, probably encrypted. Extracting as is.

[!] Error: Failed to decompress PYZ-00.pyz_extracted/OpenSSL/SSL.pyc, probably encrypted. Extracting as is.

...

$ls

faker/ libssl.so.1.0.0.i64 pyimod00_crypto_key.pyc PYZ-00.pyz frozenlist/ libtinfo.so.5 pyimod01_os_path.pyc _cffi_backend.cpython-39-x86_64-linux-gnu.so libz.so.1 pyimod02_archive.pyc aiohttp/ lib-dynload/ pyimod03_importers.pyc libbz2.so.1.0 markupsafe/ pyimod04_ctypes.pyc base_library.zip libcrypto.so.1.0.0 pytransform.so bin libcrypto.so.1.0.0.i64 certifi libffi.so.6 multidict/ cryptography/ libgcc_s.so.1 cryptography-37.0.2.dist-info/ liblzma.so.5 psutil/ libncursesw.so.5 pyi_rth_inspect.pyc struct.pyc libpython3.9.so.1.0 pyi_rth_multiprocessing.pyc libpython3.9.so.1.0_copy pyi_rth_pkgutil.pyc tinyaes.cpython-39-x86_64-linux-gnu.so libpython3.9.so.1.0_copy.idc pyi_rth_subprocess.pyc uvloop libssl.so.1.0.0 pyiboot01_bootstrap.pyc yarl

Уже лучше: были извлечены основные библиотеки приложения и скрипты распаковки PYZ (ещё один формат самораспаковывающихся Python-архивов в нашей матрёшке). Сразу можем отметить некоторые интересные зависимости, например – faker, фреймворк для генерации вымышленных персональных данных, в том числе российских (https://faker.readthedocs.io/en/master/locales/ru_RU.html). Очевидно, что такой фреймворк используется в данном случае для повышения эффективности DDoS-атаки.

Однако сам архив PYZ не распакован. Видимо, нами учтены не все модификации кода PyInstaller.

Распаковка PYZ

К счастью, гугл подсказывает, что мы не первые столкнувшиеся с такой проблемой (https://0xec.blogspot.com/2017/02/extracting-encrypted-pyinstaller.html). Оказывается, с определённой версии PyInstaller позволяет встроить ключ шифрования для PYZ, он находится в файле pyimod00_crypto_key.pyс. Декомпилируем его с помощью декомпилятора Python – Decompyle++ (https://github.com/zrax/pycdc), используем версию для Python3.9, т.к. именно она использована авторами для разработки mhddos_proxy.

$pycdc pyimod00_crypto_key.pyc

# Source Generated with Decompyle++

# File: pyimod00_crypto_key.pyc (Python 3.9)

key = '7848c0e62fdae63e'

Бинго! Однако взять этот ключ и просто вставить его в соответствующую функцию распаковки в pyinstxtractor у вас не получится. А всё потому, что схемы и режимы использования AES шифрования PYZ-архива в PyInstaller разнятся от версии к версии, и в данном случае тоже могли быть модифицированы разработчиком. После нескольких тщетных попыток подобрать соответствующую библиотеку AES и нужный режим шифрования, переходим к другому способу: анализируем исходники PyInstaller и распаковщика и приходим к выводу, что распаковка реализуется в классе ZlibArchiveReader, который находится в уже извлеченном нами файле pyimod02_archive.pyc:

$pycdc pyimod02_archive.pyc

# Source Generated with Decompyle++

# File: pyimod02_archive.pyc (Python 3.9)

...

class Cipher:

  '''

  This class is used only to decrypt Python modules.

  '''

  def __create_cipher(self, iv):

  return self._aesmod.AES(self.key.encode(), iv)

  def decrypt(self, data):

  cipher = self.__create_cipher(data[:CRYPT_BLOCK_SIZE])

  return cipher.CTR_xcrypt_buffer(data[CRYPT_BLOCK_SIZE:])

...

class ZlibArchiveReader(ArchiveReader):

  '''

  ZlibArchive - an archive with compressed entries. Archive is read from the executable created by PyInstaller.

  This archive is used for bundling python modules inside the executable.

  NOTE: The whole ZlibArchive (PYZ) is compressed, so it is not necessary to compress individual modules.

  '''

  MAGIC = b'PYZ\x00'

  TOCPOS = 8

  HDRLEN = ArchiveReader.HDRLEN + 5

  def extract(self, name):

  ...

Почему бы тогда просто не переиспользовать его тут же, подключив этот скомпилированный файл из скрипта Python? Получается очень коротко и аккуратно:

16.png

15 – Исходный код unpacker.py

Запускаем скрипт и распаковываем PYZ, получая все скомпилированные исходники и многочисленные зависимости mhddos_proxy.

17.png

16 – Распакованное содержимое PYZ

Обратите внимание на директорию src. Вспоминаем код mhddos_proxy прошлых версий, в ней должен находится байткод самого проекта:

18.png

17 – Структура каталога /src/

Структура проекта немного усложнилась, и в папке bypass теперь находится множество скриптов для обхода различных средств защиты от DDoS атак, в том числе – DDOS-Guard, Variti, Qrator, Stormwall.

Вот и всё, наши старания окупились, используем декомпилятор, или же, в крайнем случае, дизассемблер байткода Python, и получаем исходники, в которых сможем обнаружить конфигурацию, да? Пробуем:

$pycdc runner.pyc

# Source Generated with Decompyle++

# File: runner.pyc (Python 3.9)

from pytransform import pyarmor

pyarmor(__name__,__file__,b'PYARMOR\x00\x00\x03\t\x00a\r\r\n\x08\xa0\x01\x01\x00\x00\x00\

x01\x00\x00\x00@\x00\x00\x00aP\x00\x00\x0b\x00\x00z\xe9\xb4G\

x1e\xd1\x1b\xe9\x1b\x9d\xf4\x86\xf5\x19V\x18<\x00\x00\

x00\x00\x00\x00\x00\x00\x97\xf1\xaa!h\x0fu\xaeIO\t\x98\

xcf\xd6\xd5\xb8O\xb7\xdd\xe8\x00\x15\xc4\xe3v\x98\xca\

xdd\xf5xO0V\x1e\x0b\x12?\xba_i\x7fX\x84X\x0bmW\x9dA}

1\xfd\xa1\x10\x08.\x98\x87\x83\xe1\\[\n\x90K\x19:\xb2

\xbex\x99\xbe\xbd\xf6\x84\xa2\'E\x05\rB\xe8\x8e\xc0\xc33Y\

x7f\xea\xcf]f\xccb\xbb\xa7\x8c\xfa\xba\xf0\xa5\xb2@1~\xa8\xbc\x97|

<оставшиеся ~17т. неразборчивых байт...>

19.png

Не очень похоже на обычный питоновский исходник. По итогу – runner.pyc и все файлы каталога src из PYZ-архива невозможно декомпилировать. Виден лишь вызов некой функции pyarmor из библиотеки pytransform.

Пара минут в гугле по запросу “pyarmor” – и натыкаемся на коммерческий популярный проект по обфускации Python – http://pyarmor.dashingsoft.com/, https://github.com/dashingsoft/pyarmor.

20.png

Level 3: Hard. Обходим Pyarmor и изучаем внутренности реализации Питона для получения L4 конфигурации

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

21.png

18 – Нейросетевой бронированный питон

Чтобы понять, как работает PyArmor для начала вспомним, что из себя представляет язык Python, а точнее его эталонная открытая реализация на языке С – CPython. Именно с ней работают большинство людей, когда говорят о том, что “пишут на питоне”. Есть и другие реализации – Jython, PyPy, IronPython.

Принцип работы CPython

В реализации CPython исходный код сначала транслируется в байткод – низкоуровневый промежуточный язык. Вы можете убедиться в этом сами с помощью стандартной библиотеки dis, позволяющей дизассемблировать модули этого байткода:

$python3.9

Python 3.9.16 (main, Dec 7 2022, 01:12:08)

[GCC 11.3.0] on linux

Type "help", "copyright", "credits" or "license" for more information.

>>> import dis

>>> def main(): print("Hello, world!")

...

>>> main.__code__

<code object main at 0x7fd40cd5e5b0, file "<stdin>", line 1>

>>> main.__code__.co_code

b't\x00d\x01\x83\x01\x01\x00d\x00S\x00'

>>> dis.dis(main.__code__)

  1 0 LOAD_GLOBAL 0 (print)

  2 LOAD_CONST 1 ('Hello, world!')

  4 CALL_FUNCTION 1

  6 POP_TOP

  8 LOAD_CONST 0 (None)

  10 RETURN_VALUE

LOAD_GLOBAL, LOAD_CONST, и т.д. – это имена инструкций данного байткода. Как и машинный код, байткод CPython имеет двоичную и удобочитаемую формы. Большинство инструкций при этом двубайтны – первый байт кодирует саму команду, а второй байт – её аргумент. Например “LOAD_CONST 1” означает загрузить в стек первую константу из списка констант (в нашем случае – ‘Hello, world!’). С двоичной формой байткода разработчики сталкиваются постоянно – именно она содержится в файлах .pyc, создающихся после запуска программы.

Полученный после трансляции байткод интерпретируется (выполняется) на интерпретаторе CPython, поэтому Python называют интерпретируемым, подразумевая его эталонную реализацию CPython. Интерпретатор также называют виртуальной машиной для заданного набора инструкций, так что в дальнейшем будем использовать эти понятия как взаимозаменяемые. По сути, это программный аналог процессора со своим набором команд и форматом двоичного кода.

Функционал PyArmor

Создатели обфускатора PyArmor предоставляют документацию по использованию своего продукта https://pyarmor.readthedocs.io/en/stable/tutorial/getting-started.html (она изменяется от версии к версии, как и режимы обфускации). По ней можно выделить, что PyArmor осуществляет ряд обратимых и необратимых преобразований над кодом:

rftmode – переименование функций, классов и аргументов. Действительно, названия нужны только людям для понимания исходников, от них можно избавиться и переименовать всё в X1, X2, X3 или любым другим способом.

bccmode – трансляция большинства функций в C и последующая компиляция в машинный код. Как интерпретатор будет их вызывать? Просто управление из интерпретатора будет передаваться в машинный код и обратно. Так же, как он постоянно вызывает функции из различных библиотек системы.

Модульная обфускация – каждый модуль (исходный текст .py) шифруется и распространяется в зашифрованном виде (что можно заметить по неразборчивым байтам, которые мы уже видели). При запуске, разумеется, осуществляется расшифровка и выполнение кода.

Обфускация на уровне объектов – обфускация самого байткода каждой функции и класса. Способ обфускации, разумеется, не разглашается.

Обёртка объектов – функции и классы хранятся в зашифрованном виде, расшифровываются на лету и зашифровываются обратно после выполнения.

Защита библиотеки pytransform – проверки целостности кода, JIT-генерация исполняемого кода, антиотладочные механизмы опциональное использование виртуализации кода (использование другой, дополнительной виртуальной машины) Themida для защиты рантайма PyArmor на Windows.

Упаковка с помощью PyInstaller, которую мы разобрали в предыдущей части статьи.

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

Поиск способа обхода PyArmor

Первая же ссылка в гугле по запросу “pyarmor unpacker” приведёт вас в репозиторий https://github.com/Svenskithesource/PyArmor-Unpacker. Это полезное место чтобы начать наше исследование, т.к. в нём перечислены особенности работы PyArmor и там же есть ссылка на топик в tuts4you (https://forum.tuts4you.com/topic/41945-python-pyarmor-my-protector/) где люди делятся способами вскрытия данной нечисти.

Из этих источников можно выделить несколько методов распаковки PyArmor:

1) внедрить в исполняющийся процесс специально разработанную библиотеку, для того чтобы сдампить главный исполняемый модуль, расшифрованный в памяти интерпретатора (обход внешней, модульной обфускации). Затем деобфусцировать его по возможности;

2) то же самое что и в первом методе, но деобфусцировать на лету и дампить уже готовый код;

3) статически подать интерпретатору Питона обфусцированный модуль, запустить его, и с помощью https://docs.python.org/3/library/sys.html#sys.addaudithook перехватить выполнение модуля на десериализации расшифрованных исполняемых модулей, сразу же деобфусцировать их и завершить выполнение программы.

Последний метод не обходит привязку PyArmor к интерпретатору (в распакованном архиве мы могли увидеть библиотеку libpython – именно для этого она распространяется вместе с обфусцированным кодом). У остальных методов можно заметить множество недочетов, например – необходимость запуска кода. Для нашего случая это некритично, так как исследуемая программа не малварь, но в общем случае это непрактично. Также не очень удобна необходимость подключения к работающему процессу сторонней программой для внедрения библиотеки – может наша программа отработает за секунду, а мы даже не успеем ничего сделать. И отметим сразу, что для нашего случая ни одно из представленных средств не работает (ввиду настроек или версии PyArmor). Это логично, разработчики PyArmor также следят за подобными репозиториями и от версии к версии усложняют жизнь своим оппонентам.

Несмотря на недостатки, заметим важную деталь – PyArmor не защищает от внедрения кода через подгрузку сторонней библиотеки. Мы не будем пользоваться сторонними программами для её внедрения, ведь в Linux есть удобный механизм внедрения библиотеки через переменную окружения LD_PRELOAD. Достаточно просто указать в этой переменной свою библиотеку перед запуском программы, и ваша библиотека загрузится вместе при запуске. В дальнейшем, когда программа запросит какой-либо функционал из других библиотек (например, функцию memcpy из libc), динамический загрузчик проверит и вашу библиотеку, и если в ней найдется соответствующая функция – то вызовет её, а не функцию из настоящей библиотеки.

Таким образом можно перехватить вызовы к libc или например, к интерпретатору CPython, содержащемуся в libpython. Ведь код, всё-таки, изначально написан на Python, значит он как-то должен обращаться к стандартному интерпретатору? Тогда-то мы и перехватим эти обращения, и возможно их анализ поможет обойти PyArmor, или забыть о нём вовсе.

Реализация перехвата API CPython

Разработать перехват вызовов и анализ структур неизвестной библиотеки – тоже нетривиальная задача, но CPython – один из самых популярных и успешных проектов, имеет открытый исходный код (https://github.com/python/cpython/) и лучшую документацию (https://docs.python.org/3.9/c-api/index.html). Вооружившись кодом и документацией, попробуем ответить на простой вопрос – есть ли такая функция, которой на вход подается объект кода для исполнения? Наверняка он уже будет хотя бы расшифрован, тут-то мы его и сдампим!

Поиски приводят к функции PyEval_EvalCode (https://docs.python.org/3.9/c-api/veryhigh.html#c.PyEval_EvalCode). Вот её сигнатура:

PyObject* PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals)

Что за PyObject? Это дефолтная структура CPython, от которой наследуются все остальные типы, вот её определение:

typedef ssize_t Py_ssize_t;

typedef struct _object

{

  Py_ssize_t ob_refcnt;

  struct _object *ob_type;

} PyObject;

Определяем то же самое в нашей библиотеке, плюс не забываем подключить заголовочные файлы библиотек, которые понадобятся в дальнейшем:

#include <ucontext.h>

#include <dlfcn.h>

#include <fcntl.h>

#include <link.h>

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

static void _libhook_init() __attribute__((constructor));

static void _libhook_init() { printf("[*] Hook actviated.\n"); }

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

static long long (*PyEval_EvalCode_real)(PyCodeObject *, void *, void *) = NULL;

long long PyEval_EvalCode(PyCodeObject *co, void *globals, void *locals) {

  if (!PyEval_EvalCode_real) {

  PyEval_EvalCode_real = dlsym(-1, "PyEval_EvalCode");

  }

  printf("[*] hooked PyEval_EvalCode(%p, %p, %p)", co, globals, locals);

  PyObject *retval = PyEval_EvalCode_real(co, globals, locals);

  return retval;

}

Сначала мы определяем локальный символ PyEval_EvalCode_real, который будет содержать адрес реальной функции. Затем определяем функцию PyEval_EvalCode с тем же названием, что у перехвачиваемой. В теле функции инициализируем реальный символ, если он ещё не инициализирован (функция вызывается в первый раз), выводим адреса аргументов через printf, возвращаем значение, полученное с помощью вызова реальной функции, и всё, наш хук готов! Осталось лишь скомпилировать:

$gcc pyarmor_hook.c -o pyarmor_hook.so -D__USE_GNU -D_GNU_SOURCE -fPIC -shared -ldl

Важно: mhddos во всех дальнейших экспериментах запускаем в отключенной от сети машине (например, на выделенной виртуалке), так вы исключите его сетевую активность. Получится что-то такое:

$LD_PRELOAD=../../src/ldpreloadhook/pyarmor_hook.so ./mhddos_proxy_linux

[*] Hook actviated.

[*] Hook actviated.

[*] Hook actviated.

[*] hooked PyEval_EvalCode(0x7f4cd56bfa80, 0x7f4cd56bef80, 0x7f4cd56bef80)

[*] hooked PyEval_EvalCode(0x7f4cd56693a0, 0x7f4cd56ce440, 0x7f4cd56ce440)

[*] hooked PyEval_EvalCode(0x7f4cd56902f0, 0x7f4cd5684e80, 0x7f4cd5684e80)

...<множество других перехваченных обращений>...

Отлично! Первый шаг сделан. Теперь разберемся, что же действительно получает на вход данная функция. Она определена в файле Python/ceval.c репозитория CPython, и как видно из исходного кода, её вызов приводит к вызову процедуры _PyEval_EvalCode, в которой аргумент _co приводится к типу PyCodeObject. (https://github.com/python/cpython/blob/1528b420b33ae5c636b6016ce6c6afb0b09e7694/Python/ceval.c#L4087.... Это та самая основная структура скомпилированного кода (мы дизассемблировали такую с помощью dis), которая содержит в том числе и ссылку на байткод Python:

typedef struct __attribute__((aligned(4))) code_obj

{

  PyObject ob_base;

  int co_argcount;

<...>

PyObject *co_code;

  <...>

} PyCodeObject;

Хорошо, значит мы можем сдампить с помощью PyMarshal_WriteObjectToFile (https://docs.python.org/3/c-api/marshal.html#c.PyMarshal_WriteObjectToFile), которую мы также подгрузим через dlsym. Для этого добавим в нашу функцию следующие строки:

FILE * fp = fopen(((PyBytesObject*)co->co_name)->ob_sval, "wb");

  PyMarshal_WriteObjectToFile(co, fp, 0);

  fclose(fp);

Для этого не забудем определить тип PyBytesObject, в котором Python хранит все строки следующим образом:

typedef struct _varobj

{

  PyObject ob_base;

  Py_ssize_t ob_size;

} PyVarObject;

typedef struct {

  PyVarObject ob_base;

  Py_ssize_t ob_shash[3];

  char ob_sval[1];

} PyBytesObject;

Однако, даже сдампив эти объекты мы обойдем лишь “внешнее” шифрование модуля и получим множество зашифрованных объектов:

>>> import marshal, dis

>>> f = open("./<frozen src.crypto>", "rb")

>>> co = marshal.load(f)

>>> dis.dis(co)

  1 0 LOAD_GLOBAL 35 (__armor_wrap__)

  2 CALL_FUNCTION 0

  4 NOP

6 RETURN_VALUE

  2 8 NOP

  10 NOP

  12 <0>

  14 <0>

  3 16 <149> 24

                    ...<мусорный байткод>...

В хекс-редакторе видим то же самое: кучу зашифрованного кода и имена, среди которых некая функция “__armor_wrap__”.

22.png

19 – Шестнадцатеричный дамп файла

То есть даже на вход интерпретатора CPython поступает зашифрованный код? Наверняка, он каким-то образом расшифровывается в функции __armor_wrap__. Откуда она взялась? Придётся изучить его PyArmor ещё глубже, и этот небольшой манёвр будет стоить нам пары минут.

23.png

Внутренности PyArmor

Функции __armor_wrap__ в этом файле вы не найдете, однако есть соответствующая строка, если посмотреть ссылки на неё, то можно увидеть, что по адресу 02B5D00h находится ссылка на эту строку, а далее по адресу 02B5D08h этой строкой ссылка на функцию, которую мы сами назовём “__armor_wrap__func”:

24.png

20 – Фрагмент секции данных pytransform.so

Эта функция добавляется в окружение интерпретатора при импорте библиотеки pytransform.so. Дизассемблируем её:

25.png

21 – Фрагмент дизассемблированного кода процедуры __armor_wrap__func

Код получает некий фрейм с помощью вызова функции PyEval_GetFrame. Но что это за фреймы?

Объекты PyCodeObject по своей сути – статические, как машинный код в исполняемом файле. Выполнение такой функции зависит от контекста – состояния регистров памяти, в которой находятся объекты, и к которым функция обращается (например, работая с аргументами). Память в интерпретаторе CPython определяется стеком (интерпретатор CPython – это стековая виртуальная машина). И стековая память каждого отдельного исполняемого объекта байткода в рантайме определяется фреймом – PyFrameObject, задающим, какую часть стека использует объект, вот его дефиниция:

typedef struct _frame

{

  PyVarObject ob_base;

  struct _frame *f_back;

  PyCodeObject *f_code;

  PyObject *f_builtins;

  PyObject *f_globals;

  PyObject *f_locals;

  PyObject **f_valuestack;

  PyObject **f_stacktop;

  PyObject *f_trace;

  char f_trace_lines;

  char f_trace_opcodes;

  PyObject *f_gen;

  int f_lasti;

  int f_lineno;

  int f_iblock;

  char f_executing;

  PyTryBlock f_blockstack[20];

  PyObject *f_localsplus[1];

} PyFrameObject;

Как видим из определения, PyFrameObject – динамический объект, который также содержит указатель на объект байткода. Именно фреймами оперирует интерпретатор CPython при выполнении программы. Кстати, для упрощения анализа рекомендется добавить эти структуры и в ваш дизассемблер/декомпилятор. В IDA Pro это делается очень просто, в Ghidra – куда более неудобно. А взять эти типы можно из библиотеки libpython.so, которую мы так же распаковали ранее из исполняемого архива mhddos_proxy, ведь как оказалось, там есть отладочные символы и типы! Так что просто экспортируйте их из одной IDB и добавьте в другую (и в свой код, конечно же).

Но зачем PyArmor получает к нему доступ в __armor_wrap__? Ответ ждёт нас дальше в функции по адресу 18AC0h, которая вызывается из __armor_wrap__:

26.png

22 – Фрагмент функции по адресу 18AC0h

Если её декомпилировать, то можно обнаружить, что над байткодом фрейма осуществляются некоторые преобразования, очень похожие на криптографию, затем вызывается некая функция по адресу 9190h, которую я назвал pyarm, а затем, как ни странно, криптографические операции над байткодом повторяются снова. Если предположить, что сначала осуществляется расшифрование байткода, а затем снова его шифрование, то что может потенциально происходить между этими двумя процедурами? То есть зачем его сначала расшифровывают, а затем зашифровывают обратно? Уже догадались?

Лично я – не догадался, пока не увидел, что функция pyarm, вызываемая между этими двумя действиями, весит целых 50 (!) КБ. Чтобы вы понимали – 1 машинная инструкция занимает в среднем 4-5 байт, то есть наша функция выполняет более 10 тысяч Операций, при этом её декомпилированный код занимает ~146 тысяч строк. При этом большую часть этих строк занимают операторы switch-case в паре с goto. К сожалению, графическое представление CFG этой функции просто невозможно сделать информативным в масштабах обычных мониторов:

27.png

23 – CFG функции pyarm

28.png

Без опыта и погружения в CPython нам было бы очень сложно понять, что делает эта функция. Но прочитав тот же самый eval.c из CPython, можно понять (не буду вас томить), что самая большая функция в нем, занимающая несколько тысяч строк исходного кода, это _PyEval_EvalFrameDefault(PyThreadState *, PyFrameObject *, int) (https://github.com/python/cpython/blob/1528b420b33ae5c636b6016ce6c6afb0b09e7694/Python/ceval.c#LL920C1-L920C25), то есть, реализация интерпретатора байткода. Почему 3 тысячи строк превратились в 146 тысяч? Это инлайнинг функций. Вместо того чтобы оставлять в машинном коде вызов “call funcA(x)”, funcA просто встраивается в тело вызывающей функции, таким образом можно увеличить её размеры до невообразимых 50 КБ и сократить время выполнения программы. В libpython.so, разумеется, также присутствует эта функция, но её декомпилированный код занимает в 3 раза меньше, всего ~50 тысяч строк.

В результате нашего исследования мы уже можем заключить, что PyArmor не отдаёт расшифрованный код интерпретатору CPython, чтобы потом зашифровать его снова, после выполнения. Он исполняет этот код самостоятельно, в своей собственной реализации интерпретатора. А это значит, что вместо байткода Python там может содержаться что угодно, и разработчики могли изменить и обфусцировать байткод Python каким угодно образом. Но если мы сравним pyarm и _PyEval_EvalFrameDefault из libpython.so, то мы можем найти похожие блоки кода:

29.png

24 – Сравнение похожих блоков кода в интерпретаторах pytransform.so и libpython.so

Все имена и локации в pytransform выставлены вручную, но можно сразу заметить, что если в libpython.so указанный блок кода это case 0x14 в некой таблице switch-case, то в pytransform.so это case 5. Эта таблица switch-case – выбор опкода и кода операнда, то есть в реализации интерпретатора pytransform запутаны опкоды, и, например, операция BINARY_MULTIPLY имеет опкод 5, а не 0x14h. Поэтому даже если бы мы сдампим расшифрованный байткод, нормально декомпилировать его без новой таблицы опкодов не выйдет. Ситуация осложняется размером функций – IDA Pro работает в однопоточном режиме, и если вы попытаетесь переименовать какие-либо переменные в функции pyarm, чтобы обозначить места соответствия с настоящей _PyEval_EvalFrameDefault, то каждый такой небольшой манёвр обойдётся вам в несколько лет (интерфейс IDA Pro зависнет на 3-10 минут при каждом изменении декомпилированного кода). Тем не менее, это возможно, но у нас сейчас более простая задача – получить доступ хотя бы к расшифрованному коду и данным. Кстати, Ghidra вообще не сможет декомпилировать эту функцию – слишком уж много больших switch-case операторов (возможно, ситуация изменится с последним патчем 10.3.1, позволяющим задать максимальный размер jump-table).

Реализация перехвата байткода и данных в PyArmor

Итак, цель понятна – есть неэкспортируемая, внутренняя функция библиотеки, и нужно перехватить её аргументы (получаем доступ к PyFrameObject = получаем доступ к байткоду, стеку, аргументам байткода, и т.д.). Как это осуществить из нашей библиотеки, внедряемой в LD_PRELOAD? Самый очевидный и правильный вариант – софтовые брейкпоинты (точки останова). Однако он требует реализации обработчиков в коде. Допустим мы найдем какую-нибудь простенькую реализацию библиотеки-отладчика. Но с софтовыми BP несложно бороться, и PyArmor может легко им противодействовать, поэтому был выбран более “грязный” трюк.

Очевидно, что интерпретатор в pytransform.so будет обращаться к libpython.so через API CPython, которое мы умеем перехватывать. Можем ли мы из вызываемой функции (callee) получить доступ к внутренним данным вызывающей функции (caller)? Легко!

Сначала выберем цель: в самом начале своего выполнения интерпретатор вызывает PyThreadState_Get, получая доступ к ещё одной рантайм-структуре PyThreadState:

30.png

25 – Начало функции pyarm

Сама структура PyThreadState нас пока не интересует, но функцию мы эту перехватим, определив в нашей библиотеке:

void *PyThreadState_Get(void) {

if (!PyThreadState_Get_real) {

  PyThreadState_Get_real = dlsym(-1, "PyThreadState_Get");

}

return PyThreadState_Get_real();

}

Как же осуществить ту самую заветную магию – получить доступ к фрейму интерпретатора из нашего перехватчика? Обратим внимание на сигнатуру pyarm:

31.png

26 – Сигнатура pyarm

Заметим, что аргумент PyFrameObject *a1 передаётся в регистре rdi. При этом в машинном коде видим, что в прологе функции rdi тут же сохраняется в регистр r13 перед вызовом PyThreadState_Get:

32.png

27 – Пролог pyarm

Отлично, мы можем просто вытащить это значение из регистров rdi или r13 в нашем перехватчике! Как? С помощью ассемблерных вставок!

PyFrameObject *dst = 0;

__asm__ __volatile__("mov %%rdi, %0" : "=r"(dst));

Вот и всё, фрейм с расшифрованным байткодом в наших руках, и мы можем делать с ним всё, что захотим. Однако тут же возникает ряд новых проблем: очевидно, что настоящий интерпретатор тоже вызывает эту PyThreadState_Get, причём, возможно, из многих других функций. Как отфильтровать эти вызовы? Ведь нам нужен только вызов из pytransform.so. Узнать адрес возврата текущей функции (адрес, с которого продолжится выполнение после завершения функции) можно с помощью магии компилятора gcc – интринзики __builtin_return_address(0), но базовый адрес динамической библиотеки pytransform.so нам неизвестен. Можно распарсить /proc/self/maps, а можно посыпать ещё немного магии перехвата: переопределим dlopen:

void *dlopen(const char *fname, int flag) {

  if (!real_dlopen) {

  real_dlopen = dlsym(REAL_LIBC, "dlopen");

  }

  void *result = real_dlopen(fname, flag);

  if (fname) {

  printf("%.*s\n", 256, fname);

  struct link_map *lm = (struct link_map *)result;

  if (ends_with(fname, "pytransform.so")) {

  printf("PYTRANSFORM LOADED at %p\n", lm->l_addr);

  PYTRANSFORM_ADDRESS = lm->l_addr;

...

Теперь, когда программа подгрузит pytransform.so, мы сохраним её адрес в глобальную переменную, и можем наконец определить наш перехват:

void *PyThreadState_Get(void) {

  PyFrameObject *frame = 0;

  __asm__ __volatile__("mov %%rdi, %0" : "=r"(frame));

  void *result = PyThreadState_Get_real();

  if (PYTRANSFORM_ADDRESS) {

  void *retaddr = __builtin_return_address(0);

  if (retaddr == PYTRANSFORM_ADDRESS + PYTRANSFORM_INTERP_HOOK /*адрес возврата в pyarm*/) {

  printf("\n[*][%d]Hooked obfuscated interpreter. Frame %d”, NUM++);

  // делаем с frame всё что нужно

  }

  }

return result;

}

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

PyObject * _Py_CheckFunctionResult(PyObject *tstate,

PyObject *с,

PyObject *result,

const char *where) {

  if (__builtin_return_address(0)==PYTRANSFORM_ADDRESS + 0x9B3F /*адрес возврата в pyarm*/) {

  if (where)

  printf("%s\n", where);

  if (tstate) {

  PyFrameObject frame = (*(PyFrameObject**)((void*)tstate + 0x18));

// делаем с frame всё что нужно

При извлечении информации из PyFrameObject придётся также столкнуться с некоторыми проблемами: frame->f_code->co_consts (массив констант кода) для любого расшифрованного фрейма будет представлять из себя массив из одного элемента, вроде (2,), (1,) . Ответ на эту загадку можно также обнаружить в коде pyarm, вот как он обращается к константам:

33.png

28 – Доступ к константам в pyarm

То есть реальный адрес массива констант вычисляется выражением:

(frame->f_code->f_consts->ob_refcnt – 0x7f38) ^ a2

Где аргумент a2 в pyarm – указатель на значение по адресу 314FE8h. Повторяем это у себя в коде:

  PyCodeObject * co = frame->f_code;

PyObject* consts = co->co_consts;

  unsigned long key = *(unsigned long *)(PYTRANSFORM_ADDRESS + 0x314FE8);

  consts = (consts->ob_refcnt - 0x7F38)^key;

Вот и всё, теперь можем спокойно дампить объекты фрейма и байткода с помощью функций, использующих CPython:

  print_repr(frame->f_globals);

  print_repr(frame->f_locals);

  print_repr(co->co_names);

  print_repr(co->co_varnames);

  print_repr(co->co_freevars);

  print_repr(co->co_cellvars);

  dump_stack(frame);

Реализация print_repr получилось немного мудрёной, но это всё потому, что в голом Си нельзя узнать из-под коробки, является ли значение валидным указателем на куче/стеке, и для этого пришлось реализовать костыль по проверке указателя. А CPython, в свою очередь, не предоставляет средств проверки валидности того, что данные по указателю являются корректным PyObject-ом (это усложнило бы реализацию и добавило лишних данных в структуры PyObject, программист сам знает, какие объекты он выделил на куче). Поэтому всё это пытаемся валидировать эвристически (костылём):

void print_repr(PyObject *obj) {

  if (!check_ptr(obj) || !obj->ob_refcnt) {

  return;

  }

  PyObjectType * type = obj->ob_type;

  if (!check_ptr(type)) {

  return;

  }

  PyObject * repr = PyObject_Repr_real(obj);

  if (repr) {

  const char * bytes = ((PyBytesObject*)repr)->ob_sval;

  printf("%s", bytes);

  }

  else {

  printf("<unreprable>");

}

}

Стек перебираем с ещё более строгими эвристическими проверками, так как пока не знаем его границы и содержимое:

static void dump_stack(PyFrameObject *frame) {

  PyObject **sp = frame->f_valuestack;

  int size = frame->f_code->co_stacksize + frame->f_code->co_nlocals;

  int i = 0;

  printf("\nstack(%p-%p, %d)=[\n", frame->f_stacktop, frame->f_valuestack ,size);

  for (PyObject **ptr = sp; i < size; ptr--, i++) {

  printf(", <");

  PyObject * obj = *ptr;

  if (check_ptr(obj)){

  PyObjectType * type = obj->ob_type;

  if (check_ptr(type)) {

  char * tp_name = type->tp_name;

  if (check_ptr(tp_name) && strlen(tp_name)>2&&(strcmp(tp_name, "13'}"))){

  if (strcmp(tp_name, "code"))

print_repr(obj);

  }

  printf(">\n");

}

  }

  //...

Теперь – точно всё! Запускаем, и можем снимать сливки. Для этого придётся долго блуждать в логах и выцеплять нужную информацию, но что-то сразу бросается в глаза:

HOOKED ./co_marshaled/ffffffff_src.misc.exclude___load_ru_ranges

co_attrs: argcnt=0, posonlyacnt:0, kwonlyacnt:0, nlocals:3, stacksize:7, flags:1644167235,fl:142

consts:

(None, <code object <listcomp> at 0x7fa190d8a500, file "<frozen src.misc.exclude>", line 143>, '_load_ru_ranges.<locals>.<listcomp>', 0, 5, <code object <listcomp> at 0x7fa190dadea0, file "<frozen src.misc.exclude>", line 148>, <code object <listcomp> at 0x7fa190dadf50, file "<frozen src.misc.exclude>", line 152>)

('range', 'len', '_RANGES', 'ONLY_BYPASS', 'DDOS_GUARD', '_collapse_ranges', '__armor_wrap__')('networks', 'ranges', 'range_starts')()()

stack((nil)-0x7fa191093990, 7)=[

<([(34608128, 34608639), (34616576, 34616831), (34629376, 34629631), (34642432, 34642687), (34643712, 34644479), (34646016, 34646271),

По сигнатуре бинарных файлов сразу понятно, что это BZ2, копируем и распаковываем, получаем:

$cat bz2_decomp.py

data = b'BZh91AY&SY\xb6\x83&o\x00\x03\xd6\xcf\x80@\x10\x7f\xf0+\xfd]...

import bz2;

print(bz2.decompress(data))

$python3 bz2_decomp.py

066a08c18735422080a9cf82dfed4589bf98114c:JYCX4FL5

518faf987ab05ebed19ee83ef658efa5bbd0bf38:5JCF4YLX

e47110d17629b2a03998b5667a7c833040e8d5ad:J4X5LFCY

3316b11b92c392744b06c6395021985963570b22:JC5FY4XL

...

В сжатых данных оказались 50 тысяч решений для капчи stormwall. Собственно, так инструмент и обходит защиту от DDOS-а многих провайдеров – разработчики просто набирают базу нужных решений. Кстати, что самое приятное в разработанном нами перехватчике – иногда он позволяет получить данные сразу в декодированном/расшифрованном виде, так как мы можем анализировать стек с аргументами и возвращаемыми значениями.

Если ещё немного полистать логи, то можно заметить строки:

'33ebd69a', 'o14q3151nq6p45o795o03654656p4ro58o5n4o6961q1nq51o14

q0p71569377o977n14r6o6s644982744n5365696549o36sn44q7n72or46oq

9q9o5n55776q45o691o76o708o8o79o367o74r8r6p9054n76soo538s775po

1o8869o6o2n82oq5491onnr6972p792764p734n4570748q44538o4o4s697

95o58o592o47432535r544s7po667896992o88s715374916o90568noo4n8

98opn6qoq41q44q3151nqq145nr8n5n66595n7249oonro3595qnqq1nq51

o1', 'qpr97n44p3s46so811r20299nrrq71q293r0r5r8q741pr32r5n69r9s17q67714',

Они соответствуют переменным ENC_KEYS и SIGN_KEYS. Сначала можно не понять, что не так с этими ключами шифрования. 33ebd69a – Это первые байты конфигурации новых версий, но с ключами явно что-то не так (символы q, p, r, o и т.д. – не входят в шестнадцатеричный алфавит). Вспоминаем про кодировку rot13:

34.png

29 – Декодирование ключа шифрования

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

35.png

Решения капчи и ключи шифрования – это интересно, но самое интересное нас ждёт, когда мы найдем в логах строку “ranges”:

HOOKED ./co_marshaled/ffffffff_src.misc.exclude___load_ru_ranges

co_attrs: argcnt=0, posonlyacnt:0, kwonlyacnt:0, nlocals:3, stacksize:7, flags:1644167235,fl:142

consts:

(None, <code object <listcomp> at 0x7fa190d8a500, file "<frozen src.misc.exclude>", line 143>, '_load_ru_ranges.<locals>.<listcomp>', 0, 5, <code object <listcomp> at 0x7fa190dadea0, file "<frozen src.misc.exclude>", line 148>, <code object <listcomp> at 0x7fa190dadf50, file "<frozen src.misc.exclude>", line 152>)

('range', 'len', '_RANGES', 'ONLY_BYPASS', 'DDOS_GUARD', '_collapse_ranges', '__armor_wrap__')('networks', 'ranges', 'range_starts')()()

stack((nil)-0x7fa191093990, 7)=[

<([(34608128, 34608639), (34616576, 34616831), (34629376, 34629631), (34642432, 34642687), (34643712, 34644479), (34646016, 34646271),

Что за числа на стеке при выходе из функции с незатейливым названием load_ru_ranges? Вы узнаете ответ, когда промотаете логи чуть ниже, и увидите:

< [ IPv4Network('2.16.20.0/23'), IPv4Network('2.16.53.0/24'), 

IPv4Network('2.16.103.0/24'), IPv4Network('2.16.154.0/24'), 

IPv4Network('2.16.159.0/24'), IPv4Network('2.16.160.0/23'), 

IPv4Network('2.16.168.0/24'), IPv4Network('2.17.144.0/23')

Действительно, 3460812810 = 0210140016 = 2.16.20.0, а 34608639 это широковещательный адрес 2.16.21.255, то есть эти два числа задают подсеть 2.16.20.0/23. Итак, вопрос знатокам, для чего же в инструменте для DDOS атак используются эти 18 тысяч преимущественно российских IP-адресов (как подсетей, так и отдельных хостов)? Вопрос риторический, очевидно, что это – возможные цели DDOS-атаки.

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

luganet.ru

mxc.ru

transtelecom.net

fiord.ru

matrixhome.net

kamensktel.ru

alfatelplus.ru

in-tel.ru

345000.ru

stavropol.ru

cdn.ngenix.net

rascom.as20764.net

dr.yandex.net

С полным списком IP-адресов можно ознакомиться по ссылке [https://github.com/ogre2007/mhddos_proxy_config/blob/main/mhddos_ips.txt]. mhddos_proxy сканирует все эти подсети, пытается просканировать их, и в случае нахождения доступных сервисов на каком-либо IP – пытается осуществлять DDoS атаку.

Для дальнейшего анализа можно продолжить восстанавливать функционал инструмента, анализировать таблицу опкодов в PyArmor, попытаться дать инструменту скачать конфигурацию и перехватить её обработку в нашей библиотеке, но так как мы не ставили своей задачей полный анализ инструмента, то на этот раз остановимся на данной точке, а то для одной статьи на хабре и так перебор. Если вдруг кто-то захочет продолжить анализ PyArmor, то исходный код перехватчика доступен в репозитории [https://github.com/ogre2007/pyarmor_hook] (осторожно, много неприглядного кода на Си!).

Заключение

С помощью некоторых приёмов реверс-инжиниринга и программирования нам удалось извлечь список атакуемых хостов данного инструмента (или большую их часть). Для этого потребовалось:

1) расшифровать L7 конфигурацию вшитыми в исходники ключами;

2) распаковать файл, упакованный модифицированным PyInstaller;

3) обойти защиту PyArmor путём перехвата функций интерпретатора байткода.

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

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

Обнаружить использование инструмента на хосте несложно – так как перед разработчиком стоит задача по распространению этого ПО среди обычных пользователей, сейчас в ПО не обнаруживается функционала по сокрытию и закреплению, и оно распространяется свободно, в связи с чем возможно детектировать его по доступным хэшам и имени файла “mhddos_proxy_.*”. Обнаружение потенциальных ITW модификаций, которые могут не подчиняться этим правилам можно реализовать эвристически, путём поиска в бинарном файле сигнатур PyInstaller совместно с именами файлов проекта (src/*), так как PyInstaller должен распаковать весь внутренний архив во временные каталоги ОС перед исполнением кода.

В коде инструмента обнаружены попытки обхода капч и защит от распределённых атак в DDOS Guard, Stormwall, iHead, QRator, Variti, а также капчи реализованной на сайте Госуслуг. Это ещё раз подчеркивает важность постоянного обновления этих механизмов защиты разработчиками и необходимость инвалидации устаревших капч.

Проверить наличие интересующих ресурсов можно в списке URL-ов [https://github.com/ogre2007/mhddos_proxy_config/blob/main/mhddos_urls.txt] и в списке IP-адресов [https://github.com/ogre2007/mhddos_proxy_config/blob/main/mhddos_ips.txt].

erid: 2SDnjc4Nt7b erid: 2SDnjc4Nt7b

Комментарии 0