Opening a tool for DDoS attacks on Russian IT infrastructure

Розкриваємо засіб для DDoS-атак на російську ІТ-інфраструктуру

Грабовий

Вступ

На початку квітня 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 стає зрозуміло, що це зовсім не текст:

Содержимое файла 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='}

І вона точно збігається з першими чотирма байтами файлу конфігурації з файлу 11.txt. Що ж, нам пощастило, адже це означає, що і ми теж можемо повторити те саме локально: просто копіюємо даний фрагмент коду і запускаємо на своїй машині (можливо, потрібно буде завантажити пакет cryptography для python). На виході отримуємо щось цікаве:

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

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

URL-адреси

Ознайомитися з повним списком і перевірити наявність ним ресурсу, що цікавить, можна файлі .

Але тут лише 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

Константа MAGIC позначає початок заголовка архіву упакованих Python-файлів - " MEI014013012013016 ". Що ж, виявилося, що не все так просто, мабуть розробник модифікував 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 :

Фрагмент доповненої процедури 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 , який знаходиться в файлі pyimod0_ .

$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т. неразборчивых байт...>'

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

Через кілька хвилин у гугле на запит “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:

  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 <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 цим рядком посилання на функцію, яку ми з :

; фрагмент секции данных 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

Без досвіду та занурення в 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.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 -10 хвилин при кожній зміні декомпільованого коду). Тим не менш, це можливо, але у нас зараз простіше завдання – отримати доступ хоча б до розшифрованого коду та даних. До речі, Ghidra взагалі зможе декомпілювати цю функцію нормально: у разі її декомпілятор неспроможна визначити межі безлічі jump-table.

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

Отже, ціль зрозуміла. Є неекспортована, внутрішня функція бібліотеки, і потрібно перехопити її аргументи (отримуємо доступ до PyFrameObject = отримуємо доступ до байткоду, стеку, аргументів байткоду і т.д.). Як це здійснити з нашої бібліотеки, яка впроваджується в LD_PRELOAD? Найочевидніший і правильний варіант - софтові брейкпоінти (точки зупинки). Однак він вимагає реалізації обробників у коді. Припустимо, ми знайдемо якусь простеньку реалізацію бібліотеки-відладчика. Але з софтовими BP нескладно боротися, і PyArmor може легко їм протидіяти, тому був обраний "брудніший" трюк.  

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

Спочатку виберемо мету: на початку свого виконання інтерпретатор викликає PyThreadState_Get, отримуючи доступ до ще однієї рантайм-структури PyThreadState:

Початок функції pyarm
Початок функції pyarm

Сама структура PyThreadState нас поки що не цікавить, але функцію ми цю перехопимо, визначивши в нашій бібліотеці:

void *PyThreadState_Get(void) {
if (!PyThreadState_Get_real) {
    PyThreadState_Get_real = dlsym(-1, "PyThreadState_Get");
  }
 return PyThreadState_Get_real();
}

Як же здійснити ту саму заповітну магію – отримати доступ до кадру інтерпретатора з нашого перехоплювача? Звернімо увагу на сигнатуру pyarm:

Підпис pyarm
Підпис 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, ось як він звертається до константів:

Доступ к константам в 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? Ви дізнаєтесь відповідь, коли промотаєте логи трохи нижче, і побачите:

Справді, 34608128 10 = 02101400 16 = 2.16.20.0, а 34608639 це широкомовна адреса 2.16.21.255 , тобто ці два числа задають підсіти 2.16.20.0 / D8е в 8е . тисяч переважно російських IP-адрес (як підмереж, так і окремих хостів)? Питання риторичне, очевидно, що це можливі цілі DDOS-атаки.

У цей великий список діапазонів входять адреси та підмережі безлічі провайдерів, ось лише деякі, випадково вибрані з них:

домени

З повним списком IP-адрес можна ознайомитись у текстовому файлі . mhddos_proxy намагається просканувати їх, і у разі знаходження доступних сервісів на якомусь IP запускає DDoS атаку.

Для подальшого аналізу можна продовжити відновлювати функціонал інструменту, аналізувати таблицю опкодів у PyArmor, спробувати дати інструменту завантажити конфігурацію та перехопити її обробку в нашій бібліотеці, але оскільки ми не ставили своїм завданням повний аналіз інструменту, то цього разу зупинимося в цій точці, а то для однієї статті на хабрі і так уже перебір. Якщо раптом хтось захоче продовжити аналіз PyArmor, то вихідний код перехоплювача доступний репозиторії (обережно, багато непривабливого коду на Сі!).


Висновок

За допомогою деяких прийомів реверс-інжинірингу та програмування нам вдалося отримати список атакованих хостів даного інструменту (або більшу їх частину). Для цього знадобилося:

  1. Розшифрувати L7 конфігурацію вшитими в вихідні ключі.

  2. Розпакувати файл, запакований модифікованим PyInstaller.

  3. Обійти захист 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-адрес .

Сподобався матеріал? Пишіть свої зауваження та питання у коментарях!

Просмотры:

Коментарі

Популярні публікації