Введение
В начале апреля 2023 года на одном из хостов был обнаружен подозрительный файл mhddos_proxy_linux_arm64 (MD5: 9e39f69350ad6599420bbd66e2715fcb), загружаемый вместе с определенным Docker-контейнером. По открытым источникам стало понятно, что данный файл представляет из себя свободно распространяемый инструмент для осуществления распределённой атаки на отказ в обслуживании (DDoS), направленный против российской ИТ-инфраструктуры.
После запуска программа получает все необходимые настройки и автоматически инициирует массированные сетевые подключения к целевым хостам на различных уровнях TCP/IP для осуществления отказа в обслуживании.
Так как данная программа не является вредоносной в привычном для антивирусных продуктов смысле – не осуществляет закрепления и самораспространения, не пытается скрыть своего присутствия на устройстве, и на текущий момент не используется для управления устройством или похищения информации с него – ни один антивирус не считает этот файл вредоносным и не пытается предотвратить его выполнения. А ведь в отличие от обычного вредоноса, выполнение такой программы приводит к непредумышленному участию в действиях, наказуемых по законодательству РФ, что может быть критичнее, чем компрометация личного устройства или корпоративной сети.
Поэтому было решено проанализировать данный инструмент с целью выявления точного списка его целей, а также возможных индикаторов присутствия на устройстве.
Данный материал будет полезен для специалистов по ИБ/ИТ, а также для всех интересующихся внутренним устройством языка 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 становится понятно, что это совсем не текст:
Получается что программа каким-то образом декодирует данный файл. Значит нужно найти процедуры этого декодирования или расшифрования. Поиск по коду строки “config.json” приводит к нужному методу _possibly_decrypt в файле src/targets.py:
Данный метод сравнивает первые 4 байта файла со списком версий в словаре ENC_KEYS, и если есть совпадение, то расшифровывает оставшиеся данные файла соответствующим ключом из словаря с использованием алгоритма шифрования ChaCha20Poly1305. Сам словарь при этом содержит всего одну версию с ключом:
ENC_KEYS = {b'\xe4\xdc\xf7\x1f': b'fZPK2OTLiNdqVDBxJTSMuph/rfLzpFWHDmHC1/+rR1s='}
И она в точности совпадает с первыми 4-мя байтами файла конфигурации из файла 11.txt. Что ж, нам повезло, ведь это значит что и мы тоже можем повторить то же самое локально: просто копируем данный фрагмент кода и запускаем на своей машине (возможно, потребуется скачать пакет cryptography для python). На выходе получаем что-то интересное:
А именно - список из около четырёхсот 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
Лезем в исходный код распаковщика, и видим:
Константа MAGIC обозначает начало заголовка архива упакованных Python-файлов – “MEI\014\013\012\013\016”. Что ж, оказалось, что не всё так просто, видимо разработчик модифицировал PyInstaller для упаковки mhddos_proxy, а значит придётся лезть в дизассемблер.
Изучая процедуру main, находим процедуру по адресу 0x4024C0, разбирающую заголовок архива, в которой оказывается новое, нестандартное магическое число 0x742F271B6DD36293:
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:
Запускаем пропатченный 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.
Обратите внимание на папку src, вспоминаем код mhddos_proxy прошлых версий, в ней должен находится байткод самого проекта:
Как видим, структура проекта немного усложнилась, и в папке 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т. неразборчивых байт...>'
Не очень похоже на обычный питоновский исходник. По итогу runner.pyc и все файлы каталога src из PYZ-архива невозможно декомпилировать. Виден лишь вызов некой функции pyarmor из библиотеки pytransform.
Cпустя пару минут в гугле по запросу “pyarmor” натыкаемся на коммерческий популярный проект по обфускации Python – http://pyarmor.dashingsoft.com/, https://github.com/dashingsoft/pyarmor.
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__
<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 предоставляют документацию по использованию своего продукта (она изменяется от версии к версии, как и режимы обфускации). По ней можно выделить, что PyArmor осуществляет ряд обратимых и необратимых преобразований над кодом:
rftmode – переименование функций, классов и аргументов. Действительно, названия нужны только людям для понимания исходников, от них можно избавиться и переименовать всё в X1, X2, X3 или как-то иначе.
bccmode – трансляция большинства функций в C и последующая компиляция в машинный код. Как интерпретатор будет их вызывать? Просто управление из интерпретатора будет передаваться в машинный код и обратно. Так же, как он постоянно вызывает функции из различных библиотек системы.
Модульная обфускация – каждый модуль (исходный текст .py) шифруется и распространяется в зашифрованном виде (что можно заметить по неразборчивым байтам, которые мы уже видели). При запуске, разумеется, осуществляется расшифровка и выполнение кода.
Обфускация на уровне объектов – обфускация самого байткода каждой функции и класса. Способ обфускации по очевидным причинам не разглашается.
Обёртка объектов – функции и классы хранятся в зашифрованном виде, расшифровываются на лету и зашифровываются обратно после выполнения.
Защита библиотеки pytransform – проверки целостности кода, JIT-генерация исполняемого кода, антиотладочные механизмы опциональное использование виртуализации кода (использование другой, дополнительной виртуальной машины) Themida для защиты рантайма PyArmor на Windows.
Упаковка с помощью PyInstaller, которую мы разобрали в предыдущей части статьи.
Если суммировать, то всё перечисленное выглядит крайне прискорбно. Код, защищеннный всеми перечисленными механизмами будет довольно сложно проанализировать и практически невозможно восстановить. Есть одна надежда – обфускация это почти всегда компромисс между производительностью и защищенностью, так что не факт, что абсолютно все перечисленные механизмы применены нашем случае.
Поиск способа обхода PyArmor
Первая же ссылка в гугле по запросу “pyarmor unpacker” приведёт вас в репозиторий PyArmor-Unpacker. Это полезное место чтобы начать наше исследование, т.к. в нём перечислены особенности работы PyArmor и там же есть ссылка на топик на форуме tuts4you, где люди делятся способами вскрытия данной нечисти.
Из этих источников можно выделить несколько методов распаковки PyArmor:
Внедрить в исполняющийся процесс специально разработанную библиотеку, для того чтобы сдампить главный исполняемый модуль, расшифрованный в памяти интерпретатора (обход внешней, модульной обфускации). Затем деобфусцировать его по возможности.
То же самое что и в первом методе, но деобфусцировать на лету и дампить уже готовый код.
статически подать интерпретатору Питона обфусцированный модуль, запустить его, и с помощью 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 <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, возвращаем значение, полученное с помощью вызова реальной функции, и всё, наш хук готов! Осталось лишь скомпилировать:
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("./<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”.
То есть даже на вход интерпретатора CPython поступает зашифрованный код? Наверняка он каким-то образом расшифровывается в функции armor_wrap. Но откуда она взялась? Придётся изучить его PyArmor ещё глубже, и этот небольшой манёвр будет стоить нам пары минут.
Внутренности 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__:
Если её декомпилировать, то можно обнаружить, что над байткодом фрейма осуществляются некоторые преобразования, очень похожие на криптографию, затем вызывается некая функция по адресу 9190h, которую я назвал pyarm, а затем, как ни странно, криптографические операции над байткодом повторяются снова. Если предположить, что сначала осуществляется расшифрование байткода, а затем снова его шифрование, то что может потенциально происходить между этими двумя процедурами? То есть зачем его сначала расшифровывают, а затем зашифровывают обратно? Уже догадались?
Лично я не догадался, пока не увидел, что функция pyarm, вызываемая между этими двумя действиями, весит целых 50 (!) КБ. Чтобы вы понимали – 1 машинная инструкция на x86-x64 занимает в среднем 4-5 байт, то есть наша функция выполняет более 10 тысяч операций, при этом её декомпилированный код занимает ~146 тысяч строк. Большую часть этих строк занимают операторы switch-case в паре с goto. К сожалению, графическое представление CFG этой функции просто невозможно сделать информативным в масштабах обычных мониторов:
Без опыта и погружения в CPython нам было бы очень сложно понять, что делает эта функция. Но прочитав тот же самый eval.c из CPython, можно понять (не буду вас томить), что самая большая функция в нем занимает несколько тысяч строк исходного кода, и это _PyEval_EvalFrameDefault(PyThreadState *, PyFrameObject *, int) (код), то есть, реализация самого интерпретатора байткода. Почему 3 тысячи строк превратились в 146 тысяч? Это инлайнинг функций. Вместо того чтобы оставлять в машинном коде вызов “call funcA(x)”, funcA просто встраивается в тело вызывающей функции, таким образом можно увеличить её размеры до невообразимых 50 КБ и сократить время выполнения программы. В libpython.so, разумеется, также присутствует эта функция, но её декомпилированный код занимает в 3 раза меньше, всего ~50 тысяч строк.
В результате нашего исследования мы уже можем заключить, что PyArmor не отдаёт расшифрованный код интерпретатору CPython. Он исполняет этот код самостоятельно, в своей собственной реализации интерпретатора. А это значит что вместо байткода Python там может содержаться что угодно, и разработчики могли изменить и обфусцировать байткод Python каким угодно образом. Но если мы сравним pyarm и _PyEval_EvalFrameDefault из 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 вообще не сможет декомпилировать эту функцию нормально: в данном случае её декомпилятор не может определить границы множества jump-table.
Реализация перехвата байткода и данных в PyArmor
Итак, цель понятна. Есть неэкспортируемая, внутренняя функция библиотеки, и нужно перехватить её аргументы (получаем доступ к PyFrameObject = получаем доступ к байткоду, стеку, аргументам байткода, и т.д.). Как это осуществить из нашей библиотеки, внедряемой в LD_PRELOAD? Самый очевидный и правильный вариант – софтовые брейкпоинты (точки останова). Однако он требует реализации обработчиков в коде. Допустим мы найдем какую-нибудь простенькую реализацию библиотеки-отладчика. Но с софтовыми BP несложно бороться, и PyArmor может легко им противодействовать, поэтому был выбран более “грязный” трюк.
Очевидно, что интерпретатор в pytransform.so будет обращаться к libpython.so через API CPython, которое мы умеем перехватывать. Можем ли мы из вызываемой функции (callee) получить доступ к внутренним данным вызывающей функции (caller)? Легко!
Сначала выберем цель: в самом начале своего выполнения интерпретатор вызывает PyThreadState_Get, получая доступ к ещё одной рантайм-структуре PyThreadState:
Сама структура PyThreadState нас пока не интересует, но функцию мы эту перехватим, определив в нашей библиотеке:
void *PyThreadState_Get(void) {
if (!PyThreadState_Get_real) {
PyThreadState_Get_real = dlsym(-1, "PyThreadState_Get");
}
return PyThreadState_Get_real();
}
Как же осуществить ту самую заветную магию – получить доступ к фрейму интерпретатора из нашего перехватчика? Обратим внимание на сигнатуру pyarm:
Заметим что аргумент PyFrameObject *a1 передаётся в регистре rdi. При этом в машинном коде видим, что в прологе функции rdi тут же сохраняется в регистр r13 перед вызовом PyThreadState_Get:
; __unwind { пролог pyarmor
; .text:0000000000009190
push r15
push r14
push r13
mov r13, rdi
push r12
push rbp
push rbx
sub rsp, 118h
mov [rsp+148h+var_B0], esi
call _PyThreadState_Get
Отлично, мы можем просто вытащить это значение из регистров 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, мы сохраним её адрес в глобальную переменную PYTRANSFORM_ADDRESS, и можем наконец определить наш перехват:
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 *callable, PyObject *result, const char *where) {
if (__builtin_return_address(0)==PYTRANSFORM_ADDRESS + 0x9B3F) { //0x9B3F - адрес инструкции, следующей после
if (where) //вызова _Py_CheckFunctionResult
printf("%s\n", where);
if (tstate) {
PyFrameObject * frame = (*(PyFrameObject**)((void*)tstate + 0x18));
// дампим frame и result
При извлечении информации из PyFrameObject придётся также столкнуться с некоторыми проблемами: frame->f_code->co_consts
(массив констант кода) для любого расшифрованного фрейма будет представлять из себя массив из одного элемента, вроде (2,), (1,)
. Ответ на эту загадку можно также обнаружить в коде pyarm, вот как он обращается к константам:
То есть реальный адрес массива констант вычисляется выражением:
(frame->f_code->f_consts->ob_refcnt – 0x7f38) ^ a2
Где аргумент a2 в pyarm – указатель на значение по адресу 314FE8h. Повторяем это у себя в коде:
PyCodeObject * co = frame->f_code;
PyObject* old_consts = co->co_consts;
PyObject* consts = co->co_consts;
unsigned long key = *(unsigned long *)(PYTRANSFORM_ADDRESS + 0x314FE8);
consts = (consts->ob_refcnt - 0x7F38)^key;
co->consts = consts; // перед возвращением в интерпретатор не забыть вернуть сюда old_consts
Вот и всё, теперь можем спокойно дампить объекты фрейма и байткода с помощью функций, использующих 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.bypasses.stormwall.solutions___modu
co_attrs: argcnt=0, posonlyacnt:0, kwonlyacnt:0, nlocals:0, stacksize:7, flags:1644167232,fl:1
consts:
(0, None, b'BZh91AY&SY\xb6\x83&o\x00\x03\xd6\xcf\x80@\x10\x7f\xf0+\xfd]p?d\x01\x00`{\xb8\x06\x80\x0fo^\xa5U\xdd\x9dU"t\xca\xec\xc2\xd7v\x9e\xb5\xdbN\xcd\xd5\x1d\xd9)...
По сигнатуре бинарных файлов сразу понятно, что это 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',
'o14q3151nq6p45o795o03654656p4ro58o5n4o6961q1nq51o14q0p71569377o977n14r6o6s6
44982744n5365696549o36sn44q7n72or46oq9q9o5n55776q45o691o76o708o8o79o367o74r8
r6p9054n76soo538s775po1o8869o6o2n82oq5491onnr6972p792764p734n4570748q44538o4
o4s69795o58o592o47432535r544s7po667896992o88s715374916o90568noo4n898opn6qoq4
1q44q3151nqq145nr8n5n66595n7249oonro3595qnqq1nq51o1',
'qpr97n44p3s46so811r20299nrrq71q293r0r5r8q741pr32r5n69r9s17q67714',
Они соответствуют переменным ENC_KEYS и SIGN_KEYS. 33ebd69a – Это первые байты конфигурации новых версий, но с ключами явно что-то не так (символы q, p, r, o и т.д. – не входят в шестнадцатеричный алфавит). Вспоминаем про кодировку rot13:
Вот так-то лучше, мы получили ключи шифрования. Но расшифровать конфигурацию не удалось, так как Chacha20Poly1305 подразумевает возможность применения дополнительной аутентификационной информации при шифровании, и мы не можем узнать точную схему без декомпиляции соответствующего кода (а для этого придётся деобфусцировать модифицированную ВМ до конца). Что ж, не очень-то и хотелось.
Решения капчи и ключи шифрования – это интересно, но самое интересное нас ждёт, когда мы найдем в логах строку “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-адресов можно ознакомиться в текстовом файле. mhddos_proxy пытается просканировать их, и в случае нахождения доступных сервисов на каком-либо IP запускает DDoS атаку.
Для дальнейшего анализа можно продолжить восстанавливать функционал инструмента, анализировать таблицу опкодов в PyArmor, попытаться дать инструменту скачать конфигурацию и перехватить её обработку в нашей библиотеке, но так как мы не ставили своей задачей полный анализ инструмента, то на этот раз остановимся в данной точке, а то для одной статьи на хабре и так уже перебор. Если вдруг кто-то захочет продолжить анализ PyArmor, то исходный код перехватчика доступен в репозитории (осторожно, много неприглядного кода на Си!).
Заключение
С помощью некоторых приёмов реверс-инжиниринга и программирования нам удалось извлечь список атакуемых хостов данного инструмента (или большую их часть). Для этого потребовалось:
Расшифровать L7 конфигурацию вшитыми в исходники ключами.
Распаковать файл, упакованный модифицированным PyInstaller.
Обойти защиту PyArmor путём перехвата функций интерпретатора байткода.
В результате этого мы значительно углубили наши познания о Питоне и его интерпретаторе, о методах защиты кода в PyArmor и методах борьбы с ними, а также о некоторых полезных особенностях Linux. Полностью снять обфускацию PyArmor у нас не удалось, так как это требует больших затрат по анализу обфусцированного интерпретатора, однако для получения стоящих результатов это оказалось и не нужно. Тем не менее, как видно из самого исследования, питоновский код действительно возможно защитить с помощью PyArmor на неплохом уровне, не приложив для этого больших усилий.
Пользователям же в очередной раз рекомендуется вовремя обновлять ПО и проверять файлы, получаемые из любых источников в сети Интернет перед их использованием. Да, исходные коды и контейнеры тоже. Да, два этих требования немного противоречат друг другу. Но как видим из данного кейса, даже Virustotal не гарантирует вам, что полученный файл не является вредоносным.
Обнаружить использование инструмента на хосте несложно – так как перед разработчиком стоит задача по распространению этого ПО среди обычных пользователей, сейчас в нём не обнаруживается функционала по сокрытию и закреплению. А так как оно распространяется свободно, возможно детектировать его по доступным хэшам и имени файла mhddos_proxy_.*
. Обнаружение потенциальных ITW модификаций, которые могут не подчиняться этим правилам можно реализовать эвристически, путём поиска в бинарном файле сигнатур PyInstaller совместно с именами файлов проекта (в каталогеsrc/)
, так как PyInstaller должен распаковать весь внутренний архив во временные каталоги ОС перед исполнением кода.
В коде инструмента обнаружены попытки обхода капч и защит от распределённых атак в DDOS Guard, Stormwall, iHead, QRator, Variti, а также капчи реализованной на сайте Госуслуг. Это ещё раз подчеркивает важность постоянного обновления этих механизмов защиты разработчиками и необходимость инвалидации устаревших капч.
Благодаря анализу в инструменте было найдено около 500 URL-ов и 18 тысяч IPv4 адресов как целых подсетей, так и отдельных хостов. Они принадлежат самым разным организациям РФ: от крупных банков и других IT-гигантов, до мелких городских ISP; от ВУЗ-ов, до сайтов различных федеральных служб. Оценку эффективности и целесообразности таких атак для злоумышленников оставим пользователям. Особенно интересно было бы увидеть комментарии тех, кто сталкивался с такими атаками или их последствиями. Проверить наличие интересующих целевых ресурсов данной атаки можно в списке URL-ов и в списке IP-адресов.
Понравился материал? Пишите свои замечания и вопросы в комментариях!
Наши ресурсы:
https://www.usergate.com/ru/security-reports
https://t.me/usergatenews/
mrc@usergate.com