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

92171599bb466f12265c1074370ddcb5.png

Введение

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

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

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

c7fa9b8744c67bb3283948d99ec39f18.png

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

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

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

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

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

А вот по запросу «mhddos_proxy» уже можно найти репозиторий кастомизированного проекта mhddos_proxy и его описание в Telegraph от авторов, сетующих на то, что оригинальный mhddos уже перестал выдавать хорошую производительность, и предоставляющих новую, более удобную версию скрипта, в которой список целей выбирается самими разработчиками и поставляется с конфигурацией. Что ж, эффективно защитить исходники на Python невозможно, так ведь? Тогда просто найдём конфигурацию со списком целей в исходниках, делов на пару минут!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

URLs

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

Ознакомиться с полным списком и проверить наличие в нём интересующего ресурса можно в файле.

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

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

from yarl import URL
suffix = '.ua'
params = [
(URL('https://profile.sber.ru'), '84.252.144.102'),
(URL('https://3dsec.sberbank.ru'), '62.76.205.110'),
(URL('https://cdek.ru'), '178.248.238.208'),
(URL('https://lk.platon.ru'), '83.169.194.22'),
(URL('https://auth.kontur.ru'), '46.17.206.15'),
]

В файле src/vendor/useragents.py также находятся упакованные Useragent-ы для подключения к сайтам, однако это стандартная информация для мимикрии под легитимные устройства, и не представляет интереса.

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

mhddos_proxy/src/utils.py
class GOSSolver:
DEFAULT_A = 1800
MAX_RPC = 100
OWN_IP_KEY = "OWN"
_path = 'https://www.gosuslugi.ru/__jsch/schema.json'
_verifier = b'__jsch/static/script.js'
#...
def solve(self, ua, resp, *, cache_key: str) -> Tuple[int, Dict[str, str]]:
    a, ip, cn = resp["a"], resp["ip"], resp["cn"]
    bucket = self.time_bucket(a)
    value = f"{ua}:{ip}:{bucket}"

    hasher = md5
    for pos in range(10_000_000):
        response = hasher(f'{value}{pos}'.encode()).hexdigest()
        if response[6:10] == '3fe3':
            cookies = {
                cn: response.upper(),
                f"{cn}_2": pos,
                f"{cn}_3": crc32(value.encode())
            }
            self._cache[cache_key] = (bucket + a, ua, cookies)
            return bucket + a, cookies
    raise ValueError("invalid input")

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

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

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

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

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

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

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

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

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

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

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

К счастью, для 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

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

Фрагмент pyinstxtractor.py

Фрагмент pyinstxtractor.py

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

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

loc_4024E5:             ; n
mov     edx, 8
mov     rsi, rsp        ; s2
mov     [rsp+28h+cookie], 74h ; 't' ; char
mov     [rsp+28h+var_27], 2Fh ; '/'
mov     [rsp+28h+var_26], 27h ; '''
mov     [rsp+28h+var_24], 1Bh
mov     [rsp+28h+var_23], 6Dh ; 'm'
mov     [rsp+28h+var_22], 0D3h
mov     [rsp+28h+var_21], 62h ; 'b'
mov     [rsp+28h+var_25], 93h
call    find_cookie
test    rax, rax
mov     rbx, rax
jz      loc_4026B0

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

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

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

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

Фрагмент процедуры разбора заголовка

Фрагмент процедуры разбора заголовка

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

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

Фрагмент дополненной процедуры 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 — фреймворк для генерации вымышленных персональных данных, в том числе российских. Очевидно, что такой фреймворк используется в данном случае для повышения эффективности DDoS-атаки.

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

Распаковка PYZ

К счастью, гугл подсказывает, что мы не первые столкнувшиеся с такой проблемой. Оказывается, что с определённой версии PyInstaller позволяет встроить ключ шифрования для PYZ, он находится в файле pyimod00_crypto_key.pyс. Декомпилируем его с помощью декомпилятора Python — Decompyle++, используем версию для 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? Получается очень коротко и аккуратно:

from pyimod02_archive import ZlibArchiveReader
import sys, os
arch = ZlibArchiveReader("PYZ-00.pyz")
os.makedirs("PYZ-00.pyz_extracted")
for toc_name in arch.contents():
typ, obj = arch.extract(toc_name)
filename = "./PYZ-00.pyz_extracted/" + toc_name.replace(".", "/")
if typ == 1:
os.makedirs(filename, exist_ok=True)
filename += "/init"
filename += ".pyc"
with open(filename, 'wb') as f:
f.write(obj)

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

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

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

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

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

Структура каталога /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т. неразборчивых байт...>'

2e9c4c9368ac051efd9c33593587fe2a.png

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

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

143a308ddd2c69b46baf581a0912cf98.png

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

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

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

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

Чтобы понять, как работает 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__
", 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 предоставляют документацию по использованию своего продукта (она изменяется от версии к версии, как и режимы обфускации). По ней можно выделить, что PyArmor осуществляет ряд обратимых и необратимых преобразований над кодом:

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

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

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

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

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

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

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

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

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

Первая же ссылка в гугле по запросу «pyarmor unpacker» приведёт вас в репозиторий PyArmor-Unpacker. Это полезное место чтобы начать наше исследование, т.к. в нём перечислены особенности работы PyArmor и там же есть ссылка на топик на форуме tuts4you, где люди делятся способами вскрытия данной нечисти.

Из этих источников можно выделить несколько методов распаковки 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 — один из самых популярных и успешных проектов, имеет открытый исходный код и лучшую документацию.

Вооружившись кодом и документацией, попробуем ответить на простой вопрос — есть ли такая функция, которой на вход подается объект кода для исполнения? Наверняка он уже будет хотя бы расшифрован, тут-то мы его и сдампим!

Поиски приводят к функции 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 
#include 
#include 
#include 
#include 
#include 
#include 
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, возвращаем значение, полученное с помощью вызова реальной функции, и всё, наш хук готов! Осталось лишь скомпилировать:

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. Это та самая основная структура скомпилированного кода (мы дизассемблировали такую с помощью dis), которая содержит в том числе и ссылку на байткод Python:

typedef struct attribute((aligned(4))) code_obj
{
  PyObject ob_base;
  int co_argcount;
  // <...>
  PyObject co_code;
  // <...>
} PyCodeObject;

Хорошо, значит мы можем сдампить с помощью  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;

К сожалению, даже сдампив эти объекты на входе PyEval_EvalCode мы с вами обошли лишь «внешнее» шифрование модуля и получим множество зашифрованных объектов:

>>> import marshal, dis
>>> f = open("./", "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».

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

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

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

bd563c1eaa9289d5209886a69596260f.png

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

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

; фрагмент секции данных pytransform.so
.data:002B5D00 new_python_method dq offset __armor_wrap__
.data:002B5D00                                         ; DATA XREF: sub_19180+2B↑o
.data:002B5D00                                         ; sub_19180+49↑r
.data:002B5D00                                         ; "__armor_wrap__"
.data:002B5D08                 dq offset __armor_wrap__func
.data:002B5D10                 dd 4
.data:002B5D14                 dd 0
.data:002B5D18                 dd 0
.data:002B5D1C                 dd 0

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

; .text:0000000000018F70 фрагмент __armor_wrap__func
__armor_wrap__func proc near            ; DATA XREF: .data:00000000002B5D08↓o
buffer          = qword ptr -38h
len             = qword ptr -30h
; __unwind {
                push    r13
                push    r12
                push    rbp
                push    rbx
                sub     rsp, 18h
                call    _PyEval_GetFrame
                mov     rbp, [rax+20h]
                lea     rdx, [rsp+38h+len]
                mov     rsi, rsp
                mov     rbx, rax
                mov     r12, [rax+40h]
                mov     r13d, [rax+68h]
                mov     rdi, [rbp+30h]
                call    _PyBytes_AsStringAndSize

Код получает некий фрейм с помощью вызова функции 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__:

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

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

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

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

CFG функции pyarm

CFG функции pyarm

c1edf8f3e030c01c6c4e18bad6491a6f.png

Без опыта и погружен

© Habrahabr.ru