LIMB
Вход Регистрация

тема

Расчехли чужого питона и выверни его.

bezmyatezhnost · · 1 ответов · 378 просмотров

Всех приветствую, название как обычно с подвыподвертом, но если чучуть подумать, всё на поверхности. Питон у нас сегодня будет не змеиный, а тот самый, который аккуратно завёрнут в .exe, лежит на MalwareBazaar. Расчехлять будем именно это добро, причём так, чтоб на выходе у нас на руках лежали и чистый питоновский исходник лоадера, и расшифрованный пейлоад второй стадии, и весь конфиг RAT'а с ip'шниками куда оно стучится. Без иды, без гидры, голыми руками и парой питон-скриптов.

Не удалось прогрузить медиа


Небольшая предыстория, почему вообще за это взялся. Сидел, тыкался на базаре, помидоры искал. Так вот, выкачал три семпла подряд, первый оказался zapret'ом (ну ок, антиблок, не малварь), второй пустым PyInstaller-стабом без полезной нагрузки внутри (бывает и такое, видимо при сборке что-то отвалилось), а вот третий, sha256 e057341a..., и стал нашим сегодняшним пациентом. 7.37 мегабайта, PyInstaller, питон 3.12, дата сборки 2026-02-24. Свежак.

Не удалось прогрузить медиа


Сразу оговорюсь, мы сегодня имеем дело именно с EXE-шником. Никаких исходников нет, ни одного .py файла, только бинарь весом 7.5 метров. И вот тут начинается самое интересное, потому что методов превратить питон-скрипт в экзешник существует вагон и маленькая тележка. Кто-то компилит через Nuitka, и там уже на выходе нативный C-код, и без иды/гидры особо не разгуляешься, реверс становится не сильно проще чем у обычной C++ проги. Кто-то использует py2exe, кто-то cx_Freeze, кто-то изобретает велосипед на cython'е, кто-то поверх обычной сборки накручивает упаковщики типа UPX, Themida, VMProtect, чтоб уж совсем неповадно было. Кто-то прячет полезную нагрузку в зашифрованных .pyc и расшифровывает на лету через marshal.loads, кто-то тупо base64-ит исходник в десять слоёв и гордо называет это обфускацией. У каждого подхода свой геморрой, свои инструменты, своя степень боли. Можно отдельную книгу писать, и я возможно к этому вернусь в следующих статьях.

Не удалось прогрузить медиа


Сегодня же мы возьмём самый народный, самый массовый, самый дефолтный вариант , PyInstaller. Потому что, открою тайну, 90% всего что лежит в виде "питон-малвари, собранной в exe", собрано именно им. Лень-матушка, она и у школьников-вирусников лень-матушка, зачем учить Nuitka и разбираться с её особенностями, когда pyinstaller --onefile --noconsole loader.py и через минуту у тебя на руках готовый дроппер. Что характерно, авторы такого добра даже не пытаются что-то поверх накрутить, ни UPX, ни шифрование секции, ни-че-го. Голый пайинсталлер, как мама родила, бери и потроши. Правда, в нашем случае внутри сидит не один скрипт, а целая матрёшка, и об этом ниже.

Не удалось прогрузить медиа


Что будем делать по шагам, посмотрим как вообще определить что перед нами именно пайинсталлеровский билд а не что-то другое, распакуем его обратно в набор .pyc файлов через pyinstxtractor, разберёмся почему свежевыдранные .pyc не хотят декомпилиться напрямую и как это лечится правильным питоном, прогоним через дизассемблер, поймём что лоадер при запуске гадит в %TEMP% второй стадией, отреверсим алгоритм шифрования, расшифруем эту вторую стадию руками, посмотрим что там внутри (а там, внезапно, .NET), вытащим конфиг и отдадим читателю на руки IP, порты и мьютекс. По пути затронем пару подводных камней, на которые я сам наступил пока ковырялся, ну, чтоб вам потом не пришлось наступать на те же грабли. Погнали.

Не удалось прогрузить медиа



Окей, бинарь у нас на руках, e057...exe весит 7.37 мегабайта, что для лоадера уже подозрительно жирно. Нормальный нативный лоадер, это сто-двести килобайт, ну пятьсот максимум. А семь с половиной метров значит что внутрь экзешника засунули питон-рантайм целиком, со всем зоопарком стандартной библиотеки, и сверху ещё DLL-ки. То есть мы практически на 99% уже понимаем что это пайинсталлер, но пацаны пацанам не верят, по этому давайте проверим научно. Первое что я люблю запускать на любом подозрительном бинаре, Detect It Easy, он же DIE. Если кто не знает, это такая утилитка, опенсорсная, лежит на гитхабе у horsicq, умеет смотреть в exe-шник и говорить это собрано вот этим вот компилятором, упаковано вот этим упаковщиком, версия такая-то. Незаменимая вещь когда тебе кидают непонятный бинарь и надо за пять секунд понять, с чем ты вообще имеешь дело , нативная сишка, .NET, Go, Rust, Nim, Python, AutoIt, упаковано UPX'ом или нет, есть ли антиотладка, и так далее.

Не удалось прогрузить медиа


DIE сразу палит, PyInstaller, и версию питона тоже подсказывает. Если по каким-то причинам DIE у вас под рукой нет, или вы из тех кто принципиально не ставит сторонние тулзы, а живёт одним голым cmd, то можно проверить ручками. Открываем экзешник любым текстовым редактором (или через strings, кому как удобнее) и грепаем по двум характерным маркерам.

Code:
PyInstaller
python312.dll

Если оба маркера есть, перед нами пайинсталлеровский билд на питоне 3.12. Поздравляю, дальше всё уже шаблонно. Кстати, версия питона тут критична, потому что от неё зависит каким декомпилятором/дизассемблером работать, а заодно подо что вам надо ставить интерпретатор, чтоб .pyc вообще распарсились. Запомним эту цифру, 3.12, она нам ещё аукнется. Окей, диагноз поставлен, теперь к лечению. Достаём pyinstxtractor (классический, от extremecoders-re, лежит на гитхабе одним питоновским файлом), и натравливаем на наш бинарь. И тут грабли #1: если у вас в системе питон 3.10 (а у меня именно так), pyinstxtractor распакует CArchive (внешнюю обёртку), но PYZ-архив с зависимостями развернуть не сможет, потому что для рАзМаРшАлИвАнИя (вот это словечко придумал) pyc-объектов нужен интерпретатор той же версии. Лечится через uv, ставим uv (если ещё нет), и одной строчкой подтягиваем нужный питон.

Code:
uv python install 3.12
$env:PATH = "C:\Users\username\.local\bin;$env:PATH"

И уже под этим интерпретатором запускаем экстрактор. Получаем папку e057...exe_extracted с шестидесятью с лишним файлами , DLL-ки, base_library.zip, PYZ-00.pyz_extracted с зависимостями, и среди этого всего скромный loader.pyc на 101 килобайт. Сто килобайт. Для лоадера, который должен быть тонким и блестящим, это много. Запомнили эту цифру.

Не удалось прогрузить медиа


Теперь самое интересное , попытка декомпиляции. И вот тут начинаются те самые грабли #2, о которых я обещал предупредить. Первая мысль, "ну, открою через uncompyle6, это же классика". Запускаем, и получаем в ответ весёлое "too many values to unpack". Окей, не беда, есть decompyle3, тот же проект, но для тройки. Ставим, запускаем, тоже мимо. А дело в том, что начиная с 3.11 питон-байткод официально стал нестабильным, разработчики CPython прямо в changelog'е предупредили, мол, не полагайтесь на стабильность инструкций, мы будем менять их когда захотим. И декомпиляторы старой школы (uncompyle6, decompyle3) на этом массово сломались. Появились новые опкоды (RESUME, PRECALL, KW_NAMES, PUSH_NULL, CACHE), исчезли старые (CALL_FUNCTION), всё перевернули вверх дном ради perf-оптимизаций. Что делать? На сегодняшний день есть два более-менее живых варианта. Первый , pycdc (он же Decompyle++), сишный декомпилятор от zrax, который тащит до 3.12. Минус, его надо собирать из исходников через cmake, готовых бинарей под винду в репе нет, а возиться с компилятором ради одного pyc, то ещё удовольствие. Второй вариант поновее , pylingual, веб-версия живая, но очередь там огромная. По этому я по доброй традиции пошёл третьим путём, не самым красивым, зато рабочим , штатный модуль dis из коробки. Это не декомпилятор, это дизассемблер, он не вернёт нам красивый питоновский код, но покажет байткод в человекочитаемом виде, а из байткода восстановить логику на глаз, дело пятнадцати минут. Особенно когда логика тривиальная.

Code:
import marshal, dis
data = open('loader.pyc','rb').read()
# первые 16 байт это хедер pyc, дальше marshal-объект
co = marshal.loads(data[16:])
dis.dis(co)

Грабли #3 про .pyc файлы. Заголовок у них устроен так: 4 байта magic number (для 3.12 это cb 0d 0d 0a), потом 4 байта флагов, потом 8 байт timestamp/source size, итого 16 байт хедера, и дальше сам marshal-объект кода. Иногда pyinstxtractor выдирает .pyc с битым или нулевым хедером, и старые декомпиляторы на это ругаются. Лечится тривиально, открываем hex-редактором, правим первые 16 байт. У нас magic правильный, можно работать.

Не удалось прогрузить медиа


Запускаем dis.dis и получаем простыню байткода. Смотреть на неё страшно только с непривычки. В шапке нам уже всё рассказали, посмотрите на табличку констант лоадера. Среди обычных строк типа 'wb', '.exe', subprocess'ов и os.path-ов жирной полосой выделяется одна, на сорок с лишним тысяч hex-символов.

Не удалось прогрузить медиа


Значит схема ровно та, о которой я говорил выше. Большая шифрованная константа, локальная функция-декриптор с обфусцированным именем, генерация случайного имени из 12 латинских букв, запись в %TEMP%, запуск через subprocess.Popen([path], shell=False). Классическая двухстадийка. Идём смотреть саму функцию gfEXXUsRbwdCXf, она же декриптор.

Не удалось прогрузить медиа


Восстанавливаю логику по байткоду.
Code:
def gfEXXUsRbwdCXf(hex_str):
    buf = bytearray.fromhex(hex_str)
    for _ in range(3):
        new = bytearray(len(buf))
        for i in range(len(buf)):
            new[i] = (buf[len(buf)-1-i] - 3) % 256
        buf = new
    return bytes((b - 77) % 256 for b in buf)

Три круга "реверснуть массив и вычесть 3 по модулю 256", потом ещё один проход "вычесть 77 по модулю 256". Если посчитать на бумажке: три реверса это один реверс (нечётное число), три вычитания по 3 это вычитание 9, плюс финальные 77, итого простое out = (orig[N-1-i] - 86) mod 256. Считайте, XOR-однобайтник, только сложением и реверсом. Уровень обфускации , школьный, но достаточный, чтоб AV-эвристика не зацепилась за PE-сигнатуру в открытом виде. Запускаем расшифровку, пишем в файл.

Code:
buf = bytearray.fromhex(hexstr)
for _ in range(3):
    new = bytearray(len(buf))
    for i in range(len(buf)):
        new[i] = (buf[len(buf)-1-i] - 3) % 256
    buf = new
out = bytes((b - 77) % 256 for b in buf)
open('govno.bin','wb').write(out)

И что мы видим.

Не удалось прогрузить медиа


MZ. Валидный PE. 48 640 байт. Вот она, вторая стадия. Начинаем потрошить уже PE'шку, питон в этом плане, кстати, тоже идеален , pefile библиотека выдаст всё что надо за десять строк. Загружаем, смотрим хедера, секции, импорты.

Не удалось прогрузить медиа


Полная картина: 32-bit GUI .NET-сборка, единственный импорт mscoree!_CorExeMain, тайм-стамп октябрь 2023 (либо реально, либо стилизация под старый билд, чтоб занижать интерес, AsyncRAT-стабы любят таскать одну и ту же дату). Энтропия .text 5.62 , значит текст не пакован, не зашифрован, его можно читать строками напрямую. Уже неплохо. Дёргаем строки. Тут есть нюанс, в .NET-сборках большая часть осмысленных строк лежит как UTF-16LE, по этому регулярку надо подкручивать. Делаем два прохода, ASCII и UTF-16.

Не удалось прогрузить медиа


А вот и наш клиент. Stub.exe в OriginalFilename, Plugin/savePlugin/sendPlugin , плагинная архитектура, WMI-перечисление антивиря через SecurityCenter2, sandbox-проверка по SbieDll.dll, и в довесок строки про masterKey, MAC и MessagePack ($c1, $c7-$c9 это нативные тэги MessagePack-CSharp). Это AsyncRAT. Ну или его прямой потомок (DcRAT/VenomRAT/одна из тысячи копий, кодовая база там везде одна). Дата компиляции октябрь 2023, классика , староватый стаб, который перепакован свежим питон-лоадером поверх. Дёшево, сердито, для AV-обхода работает. Окей, мало того что мы поняли семейство, надо вытащить конфиг. AsyncRAT хранит настройки прямо в Settings-классе, как набор зашифрованных base64-строк в #US-стриме .NET-сборки. Регуляркой по UTF-16 base64 они вычисляются влёт. Ищем все строки длиной от 40 символов и кратно 4.

Code:
us = re.findall(rb'(?:[A-Za-z0-9+/=]\x00){40,}', d)
strs = sorted({s.decode('utf-16le') for s in us}, key=len)

Среди них одна особая, длиной ровно 44 символа и заканчивается на '='. Это base64 от 32 байт, и это и есть masterKey.

Code:
am1mQmh3bHFxZ2x1WXdxQjZocUlQbm9RQnpSNTJLRzM=
# -> b'jmfBhwlqqgluYwqB6hqIPnoQBzR52KG3'

Все остальные base64-блобы это уже сами зашифрованные параметры конфига. Алгоритм у AsyncRAT публичный, я даже исходники открывать не буду, кратко.

Code:
salt   = 0xBFEB1E56FBCD973BB219022430A57843003D5644D21E62B9D4F180E7E6C33941
derived = PBKDF2(masterKey, salt, dkLen=96, iter=50000, prf=HMAC-SHA1)
encKey  = derived[:32]   # AES-256
authKey = derived[32:96] # 64 байта для HMAC

blob = HMAC_SHA256(authKey, IV || CT) || IV(16) || CT
PT = AES-256-CBC(encKey, IV).decrypt(CT)

Тут была граблина #4, которую я хочу подсветить отдельно, чтоб никто на ней не тонул. В AsyncRAT-форках masterKey передаётся в PBKDF2 двумя разными способами: либо как RAW32 (decode_b64 -> 32 байта), либо как UTF-8 байты от исходной base64-строки (44 байта). У VenomRAT, например, b64-строка. У классического AsyncRAT 0.5.x, RAW32. Я начал с второго варианта, потому что свежее, и получил красивые "valid: 0/11" по всем настройкам. Подумал "не та соль, не та схема, может не AsyncRAT вообще". Снёс HMAC-проверку, сдампил сырой результат CBC-decrypt, там болтаются 16 байт мусора. Ну ок, явно не та схема. Перебрал ещё пять вариантов (sha256(raw), md5+md5, разные salt), мимо. И только когда забил в perebor и просто проперебирал все три варианта masterKey × три значения counter × два хэша (SHA1/SHA256), у меня выскочило два валидных HMAC'а на ключе RAW32 + классическая AsyncRAT-соль + 50000 итераций PBKDF2-SHA1, и расшифрованное короткое поле читалось как 'false'. Слово 'false', это ну очень узнаваемый AsyncRAT-овый bool, тут можно остановиться и наслаждаться. Пишем финальный декриптор.

Code:
import re, base64, hmac, hashlib
from Crypto.Cipher import AES
from Crypto.Hash import SHA1
from Crypto.Protocol.KDF import PBKDF2

SALT = bytes.fromhex('BFEB1E56FBCD973BB219022430A57843'
                     '003D5644D21E62B9D4F180E7E6C33941')

d = open('payload2.bin','rb').read()
us = re.findall(rb'(?:[A-Za-z0-9+/=]\x00){8,}', d)
strs = sorted({s.decode('utf-16le') for s in us}, key=len)

key_b64 = next(s for s in strs if len(s) == 44 and s.endswith('='))
master  = base64.b64decode(key_b64)
derived = PBKDF2(master, SALT, dkLen=96, count=50000,
                 hmac_hash_module=SHA1)
enc_key, auth_key = derived[:32], derived[32:]

def decrypt(b64s):
    b = base64.b64decode(b64s)
    if len(b) < 48 or (len(b)-48) % 16: return None
    mac, iv, ct = b[:32], b[32:48], b[48:]
    if not hmac.compare_digest(
        hmac.new(auth_key, b[32:], hashlib.sha256).digest(), mac):
        return None
    pt = AES.new(enc_key, AES.MODE_CBC, iv).decrypt(ct)
    pad = pt[-1]
    if 1 <= pad <= 16 and pt[-pad:] == bytes([pad])*pad:
        pt = pt[:-pad]
    return pt

for s in strs:
    if s == key_b64: continue
    pt = decrypt(s)
    if pt is None: continue
    try: print(pt.decode('utf-8'))
    except: print(pt[:64].hex(), '...')

Запускаем и получаем всё на блюдечке.

Не удалось прогрузить медиа


Что мы имеем по итогу? AsyncRAT 0.5.8, с C2 на playit.gg-туннеле academic-gospel.gl.at.ply.gg, портами 20964 и 2002 (2002 это исторический дефолт AsyncRAT, его обычно не меняют, потому что лень), мьютексом UzqIc8gD8JWv, без установки в персистенс (Install=false), с выключенными антисэндбоксами (Anti=false), и с самоподписанным сертификатом "AsyncRAT Server" с NotBefore 2026-02-20. То есть стаб скомпилен под этого конкретного оператора, но сам оператор откровенно ленивый, не выкрутил ни одну галочку, не накинул ни одной анти-VM-проверки, и хостится за бесплатным playit.gg-туннелем, потому что не хочет светить домашний айпи. Видно сразу, дешёвая массовая сборка, расчёт на школьников и хабалок, а не на enterprise.

Не удалось прогрузить медиа


Прошли весь путь , от непонятного 7.5-метрового экзешника до полного конфига RAT'а с IP'шниками. По дороге узнали что DIE за пять секунд палит пайинсталлер, что pyinstxtractor под чужой питон не пойдёт и uv тут спасает, что у .pyc файлов есть 16-байтный хедер с магическим числом версии, что начиная с 3.11 байткод сломал к чертям все старые декомпиляторы, и что в крайнем случае всегда можно слезть на dis и читать байткод глазами, не так уж это и страшно. Ну и заодно расковыряли AsyncRAT-овский конфиг на чистом питоне без ilspy, dnSpy и прочего тяжёлого артиллерийского снаряжения, буквально pefile + pycryptodome + регулярка, и ты уже с C2 на руках. Будьте бдительны, но и не параноьте. Всем пока, не болейте!

Спойлер: dobro
в последствии узнал о таком еще сервисе который разбирает pyc to py без очередей и поддерживает все версии

pychaos | Python 3.12

Decompiled cpython bytecode is successful
pychaos.io

оно тут
👍19 🔥3 👏6
Для нуитки тоже существует подобающий экстрактор, обработовши коим работа в IDA сведется к парочке адекватно прописанных сценариек. Но окей.

Сам по себе процесс описан в целом-то дельно, больше ничего и не скажешь.
Асинка сама по себе не самый лучший вариант софта, антипесочница у них легко обходимая, сам по себе билд, куда его не суй, палится уже чисто паттернами.

То, что плэйит, и то, что дефолт порт не изменен, тоже плюсик в копилку, содержащую аргументы, что перед нами школопролив.. но анализ хорош, как и говорил ранее.

Можете проходить на оплату.
👍4

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.