Введение
В начале апреля 2023 года на одном из хостов был обнаружен подозрительный файлmhddos_proxy_linux_arm64 (MD5: 9e39f69350ad6599420bbd66e2715fcb), загружаемый вместе с определенным Docker-контейнером. По открытым источникам стало понятно, что данный файл представляет из себя свободно распространяемый инструмент для осуществления распределённой атаки на отказ в обслуживании (DDoS), направленный против российской ИТ-инфраструктуры.После запуска программа получает все необходимые настройки и автоматически инициирует массированные сетевые подключения к целевым хостам на различных уровнях TCP/IP для осуществления отказа в обслуживании.
Так как данная программа не является вредоносной в привычном для антивирусных продуктов смысле – не осуществляет закрепления и самораспространения, не пытается скрыть своего присутствия на устройстве, и на текущий момент не используется для управления устройством или похищения информации с него – ни один антивирус не считает этот файл вредоносным и не пытается предотвратить его выполнения. А ведь в отличие от обычного вредоноса, выполнение такой программы приводит к непредумышленному участию в действиях, наказуемых по законодательству РФ, что может быть критичнее, чем компрометация личного устройства или корпоративной сети.
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
Получается что программа каким-то образом декодирует данный файл. Значит нужно найти процедуры этого декодирования или расшифрования. Поиск по коду строки “config.json” приводит к нужному методу _possibly_decrypt в файле src/targets.py:
Фрагмент файла src/targets.py
Данный метод сравнивает первые 4 байта файла со списком версий в словаре ENC_KEYS, и если есть совпадение, то расшифровывает оставшиеся данные файла соответствующим ключом из словаря с использованием алгоритма шифрования ChaCha20Poly1305. Сам словарь при этом содержит всего одну версию с ключом:
Код:
ENC_KEYS = {b'\xe4\xdc\xf7\x1f': b'fZPK2OTLiNdqVDBxJTSMuph/rfLzpFWHDmHC1/+rR1s='}
Фрагмент расшифрованного файла с целями для 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
Но тут всего лишь 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 и скопировать туда обфусцированный код. На выходе получится следующее:
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/utils.py также можно обнаружить код для обхода защиты от ботов на Госуслугах (код создания правильной Cookie):
Python:
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")
Скачиваем сборку для 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. Запускаем и получаем следующую ошибку:
Python:
$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
Константа 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 и декомпилированную процедуру разбора заголовка, то можно заметить, что важные для распаковки значения преобразованы XOR-ом с различными константными значениями:
Фрагмент процедуры разбора заголовка
Поправляем pyinstxtractor ещё раз, теперь в методах parseTOC и getCArchiveInfo:
Фрагмент дополненной процедуры parseTOC
Запускаем пропатченный pyinstxtractor ещё раз:
Python:
$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 не распакован. Видимо, нами учтены не все модификации кода PyInstaller.
Распаковка PYZ
К счастью, гугл подсказывает, что мы не первые столкнувшиеся с такой проблемой. Оказывается, что с определённой версии PyInstaller позволяет встроить ключ шифрования для PYZ, он находится в файле pyimod00_crypto_key.pyс. Декомпилируем его с помощью декомпилятора Python – Decompyle++, используем версию для Python3.9, т.к. именно она использована авторами для разработки mhddos_proxy.
Python:
$pycdc pyimod00_crypto_key.pyc
# Source Generated with Decompyle++
# File: pyimod00_crypto_key.pyc (Python 3.9)
key = '7848c0e62fdae63e'
Код:
$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
Обратите внимание на папку src, вспоминаем код
mhddos_proxy прошлых версий, в ней должен находится байткод самого проекта:
Структура каталога /src/
Как видим, структура проекта немного усложнилась, и в папке bypass теперь множество скриптов для обхода различных сервисов защиты от DDoS атак, в том числе – DDOS-Guard, Variti, Qrator, Stormwall.
Вот и всё, наши старания окупились, используем декомпилятор, или же, в крайнем случае, дизассемблер байткода Python, и получаем исходники, в которых сможем обнаружить конфигурацию, да? Пробуем:
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т. неразборчивых байт...>'
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, позволяющей дизассемблировать модули этого байткода:
Python:
$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
Полученный после трансляции байткод интерпретируется (выполняется) на интерпретаторе 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 не защищает от внедрения кода через подгрузку сторонней библиотеки. Мы не будем пользоваться сторонними программами для её внедрения, ведь в Linux есть более удобный механизм внедрения библиотеки через переменную окружения LD_PRELOAD. Достаточно просто указать в этой переменной свою библиотеку перед запуском программы, и ваша библиотека загрузится вместе при запуске. В дальнейшем, когда программа запросит какой-либо функционал из других библиотек (например, функцию memcpy из libc), динамический загрузчик проверит и вашу библиотеку, и если в ней найдется соответствующая функция – то вызовет её, а не функцию из настоящей библиотеки.
Таким образом можно перехватить вызовы к libc или, например, интерпретатору CPython, содержащемуся в libpython. Ведь код, всё-таки, изначально написан на Python, значит он как-то должен обращаться к стандартному интерпретатору? Тогда-то мы и перехватим эти обращения, и, возможно их анализ поможет обойти PyArmor, или забыть о нём вовсе.
Реализация перехвата API CPython
Разработать перехват вызовов и анализ структур неизвестной библиотеки – тоже нетривиальная задача, но CPython – один из самых популярных и успешных проектов, имеет открытый исходный код и лучшую документацию.Вооружившись кодом и документацией, попробуем ответить на простой вопрос – есть ли такая функция, которой на вход подается объект кода для исполнения? Наверняка он уже будет хотя бы расшифрован, тут-то мы его и сдампим!
Поиски приводят к функции PyEval_EvalCode. Вот её сигнатура:
Код:
PyObject* PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);
Код:
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;
}
Код:
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)
...<множество других перехваченных обращений>...
Код:
typedef struct attribute((aligned(4))) code_obj
{
PyObject ob_base;
int co_argcount;
// <...>
PyObject co_code;
// <...>
} PyCodeObject;
Код:
FILE * fp = fopen(((PyBytesObject)co->co_name)->ob_sval, "wb");
PyMarshal_WriteObjectToFile(co, fp, 0);
fclose(fp);
Код:
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
...<мусорный байткод>...
Шестнадцатеричный дамп файла кода
То есть даже на вход интерпретатора 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
Код:
; .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
Объекты 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;
Но зачем PyArmor получает к нему доступ в __armor_wrap__? Ответ ждёт нас дальше в функции по адресу 18AC0h, которая вызывается из __armor_wrap__:
Фрагмент функции по адресу 18AC0h
Если её декомпилировать, то можно обнаружить, что над байткодом фрейма осуществляются некоторые преобразования, очень похожие на криптографию, затем вызывается некая функция по адресу 9190h, которую я назвал pyarm, а затем, как ни странно, криптографические операции над байткодом повторяются снова. Если предположить, что сначала осуществляется расшифрование байткода, а затем снова его шифрование, то что может потенциально происходить между этими двумя процедурами? То есть зачем его сначала расшифровывают, а затем зашифровывают обратно? Уже догадались?
Лично я не догадался, пока не увидел, что функция pyarm, вызываемая между этими двумя действиями, весит целых 50 (!) КБ. Чтобы вы понимали – 1 машинная инструкция на x86-x64 занимает в среднем 4-5 байт, то есть наша функция выполняет более 10 тысяч операций, при этом её декомпилированный код занимает ~146 тысяч строк. Большую часть этих строк занимают операторы switch-case в паре с goto. К сожалению, графическое представление CFG этой функции просто невозможно сделать информативным в масштабах обычных мониторов:
CFG функции pyarm
Без опыта и погружения в 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.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 вообще не сможет декомпилировать эту функцию нормально: в данном случае её декомпилятор не может определить границы множества jump-table.
Реализация перехвата байткода и данных в PyArmor
Итак, цель понятна. Есть неэкспортируемая, внутренняя функция библиотеки, и нужно перехватить её аргументы (получаем доступ к PyFrameObject = получаем доступ к байткоду, стеку, аргументам байткода, и т.д.). Как это осуществить из нашей библиотеки, внедряемой в LD_PRELOAD? Самый очевидный и правильный вариант – софтовые брейкпоинты (точки останова). Однако он требует реализации обработчиков в коде. Допустим мы найдем какую-нибудь простенькую реализацию библиотеки-отладчика. Но с софтовыми BP несложно бороться, и PyArmor может легко им противодействовать, поэтому был выбран более “грязный” трюк.Очевидно, что интерпретатор в pytransform.so будет обращаться к libpython.so через API CPython, которое мы умеем перехватывать. Можем ли мы из вызываемой функции (callee) получить доступ к внутренним данным вызывающей функции (caller)? Легко!
Сначала выберем цель: в самом начале своего выполнения интерпретатор вызывает PyThreadState_Get, получая доступ к ещё одной рантайм-структуре PyThreadState:
Начало функции pyarm
Сама структура PyThreadState нас пока не интересует, но функцию мы эту перехватим, определив в нашей библиотеке:
Python:
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
Python:
PyFrameObject *dst = 0;
__asm__ __volatile__("mov %%rdi, %0" : "=r"(dst));
Python:
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_ADDRESS, и можем наконец определить наш перехват:
Python:
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;
}
Python:
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
frame->f_code->co_consts (массив констант кода) для любого расшифрованного фрейма будет представлять из себя массив из одного элемента, вроде (2,), (1,) . Ответ на эту загадку можно также обнаружить в коде pyarm, вот как он обращается к константам:
Доступ к константам в pyarm
То есть реальный адрес массива констант вычисляется выражением:
Код:
(frame->f_code->f_consts->ob_refcnt – 0x7f38) ^ a2
314FE8h. Повторяем это у себя в коде:
Python:
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
Python:
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);
Python:
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>");
}
}
Python:
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)...
Python:
$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
...
Если ещё немного полистать логи, то можно заметить строки:
Код:
'33ebd69a',
'o14q3151nq6p45o795o03654656p4ro58o5n4o6961q1nq51o14q0p71569377o977n14r6o6s6
44982744n5365696549o36sn44q7n72or46oq9q9o5n55776q45o691o76o708o8o79o367o74r8
r6p9054n76soo538s775po1o8869o6o2n82oq5491onnr6972p792764p734n4570748q44538o4
o4s69795o58o592o47432535r544s7po667896992o88s715374916o90568noo4n898opn6qoq4
1q44q3151nqq145nr8n5n66595n7249oonro3595qnqq1nq51o1',
'qpr97n44p3s46so811r20299nrrq71q293r0r5r8q741pr32r5n69r9s17q67714',
Декодирование ключа шифрования
Вот так-то лучше, мы получили ключи шифрования. Но расшифровать конфигурацию не удалось, так как 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),
Код:
< [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')
В этот большой список диапазонов входят адреса и подсети множества провайдеров, вот лишь некоторые, случайно выбранные из них:
Код:
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
Для дальнейшего анализа можно продолжить восстанавливать функционал инструмента, анализировать таблицу опкодов в PyArmor, попытаться дать инструменту скачать конфигурацию и перехватить её обработку в нашей библиотеке, но так как мы не ставили своей задачей полный анализ инструмента, то на этот раз остановимся в данной точке, а то для одной статьи на хабре и так уже перебор. Если вдруг кто-то захочет продолжить анализ PyArmor, то исходный код перехватчика доступен в репозитории (осторожно, много неприглядного кода на Си!).
Заключение
С помощью некоторых приёмов реверс-инжиниринга и программирования нам удалось извлечь список атакуемых хостов данного инструмента (или большую их часть). Для этого потребовалось:- Расшифровать L7 конфигурацию вшитыми в исходники ключами.
- Распаковать файл, упакованный модифицированным PyInstaller.
- Обойти защиту PyArmor путём перехвата функций интерпретатора байткода.
Пользователям же в очередной раз рекомендуется вовремя обновлять ПО и проверять файлы, получаемые из любых источников в сети Интернет перед их использованием. Да, исходные коды и контейнеры тоже. Да, два этих требования немного противоречат друг другу. Но как видим из данного кейса, даже Virustotal не гарантирует вам, что полученный файл не является вредоносным.
Обнаружить использование инструмента на хосте несложно – так как перед разработчиком стоит задача по распространению этого ПО среди обычных пользователей, сейчас в нём не обнаруживается функционала по сокрытию и закреплению. А так как оно распространяется свободно, возможно детектировать его по доступным хэшам и имени файла mhddos_proxy_.*. Обнаружение потенциальных ITW модификаций, которые могут не подчиняться этим правилам можно реализовать эвристически, путём поиска в бинарном файле сигнатур PyInstaller совместно с именами файлов проекта (в каталогеsrc/), так как PyInstaller должен распаковать весь внутренний архив во временные каталоги ОС перед исполнением кода.
В коде инструмента обнаружены попытки обхода капч и защит от распределённых атак в DDOS Guard, Stormwall, iHead, QRator, Variti, а также капчи реализованной на сайте Госуслуг. Это ещё раз подчеркивает важность постоянного обновления этих механизмов защиты разработчиками и необходимость инвалидации устаревших капч.
Благодаря анализу в инструменте было найдено около 500 URL-ов и 18 тысяч IPv4 адресов как целых подсетей, так и отдельных хостов. Они принадлежат самым разным организациям РФ: от крупных банков и других IT-гигантов, до мелких городских ISP; от ВУЗ-ов, до сайтов различных федеральных служб. Оценку эффективности и целесообразности таких атак для злоумышленников оставим пользователям. Особенно интересно было бы увидеть комментарии тех, кто сталкивался с такими атаками или их последствиями. Проверить наличие интересующих целевых ресурсов данной атаки можно в списке URL-ов и в списке IP-адресов.
автор yamano, usergate.com
источник