
Snake Keylogger — один из тех .NET-образцов, что на первый взгляд кажутся простыми, но на деле используют нетривиальный способ упаковки полезной нагрузки. В этом материале я пошагово разберу процесс распаковки, покажу, как извлекаются скрытые PE-файлы, и объясню, что делает зловред после их загрузки в память.
ВАЖНО!
Все манипуляции над семплом будут проводиться в изолированной среде с отключенным интернетом. Никаких экспериментов на рабочей машине!
Поехали!
Перед нами исполняемый файл ml.exe который весит всего 600Кб. Отправим его в утилиту Detect It Easy (DIE) чтобы собрать больше информации.

DIE выдал нам интересные подробности. Во первых: вероятно код нашего семпла обфусцирован (проверим это позже). Во вторых: DIE определила, что секция.text запакована, а это важная информация, т.к. в секции .text хранится сам код программы (на самом деле это не всегда так), а запакованный код изучать не рационально. Проверить факт упаковки можно с помощью расчета энтропии файла.
Энтропия файла — это мера хаотичности/непредсказуемости данных в файле. Чем выше энтропия, тем сложнее предсказать последовательность байтов. Высокая энтропия (>7) тревожный признак: код может быть зашифрован, упакован или обфусцирован.

Так же один из способов определить, запакована ли программа — проверить таблицу импорта DLL‑функций. Как правило в запакованных программах таблица импорта очень маленькая, порядка 2–3 библиотек. Однако в нашем случае данный способ не подойдет, так как семпл написан на.NET, а как известно, почти все.NET‑приложения имеют минимальную таблицу импорта, где часто фигурирует только mscoree.dll (иногда еще kernel32.dll для базовых WinAPI‑вызовов).

Почему так?
Дело в том, что .NET использует собственный слой абстракции для работы с Windows. Вместо прямого вызова функций из системных DLL (например, user32.dll), .NET-приложения обращаются к встроенным библиотекам .NET, которые содержат большинство стандартных функций. Если же необходимых функций не хватает, используется механизм P/Invoke (Platform Invocation Services), позволяющий управляемому коду (написанному на C#, VB.NET и др.) вызывать функции из нативных DLL.
Итак, мы собрали минимальную информацию о файле и теперь можем приступать к изучению кода программы. Как уже упоминалось, программы на.NET обычно достаточно хорошо поддаются декомпиляции. В сети доступно множество декомпиляторов, но я выбрал dnSpy, который хорошо себя зарекомендовал. Загружаем наш образец в dnSpy и получаем следующий результат:

ml.exe
Перед вами наглядный пример обфусцированного кода. Красиво, правда? (сарказм) Разработчики ВПО, да и обычного ПО часто используют обфускаторы для усложнения анализа своего кода. Особенно часто это можно заметить в программах, написанных на.NET, так как декомпилировать код до почти исходного очень просто, а защитить свой код от анализа хочется. И как же нам такое анализировать? На помощь нам приходят деобфускаторы.
Деобфускаторы — это класс программ, предназначенных для обратной трансформации обфусцированного кода (или данных) в читаемый и анализируемый вид.
Существует масса различных решений. Я решил выбрать de4dot (ссылка в конце статьи). Отправляем наш семпл в деобфускатор и получаем следующее:

Другое дело. Теперь можно спокойно изучать данный код. Первый вызов включает визуальные стили Windows (темы оформления). Второй вызов указывает, использовать ли для текста в элементах управления старую или новую систему рендеринга текста. А вот третий метод уже интереснее. Здесь создается объект класса SuperAdventure
и передаётся в метод Application.Run()
для отображения. Давайте изучим этот класс.

Класс большой (более 1000 строк), поэтому я выделю только интересный для нас фрагмент. this.InitializeComponent()
— инициализация формы, в которой можно найти такой фрагмент:

Сначала вызывается метод smethod_62
в который передается некий ресурс componentResourceManager и его имя «CV». Возвращаемое значение приводится к Bitmap. Далее создается пустой список байтов с именем list. После вызывается метод ArtifactScanning
в который передаются bitmap, list и некое число 68 608.

Бинго! Здесь происходит следующее:
1) Передаются некие данные с именем CV. Это картинка.
2) Каждый пиксель данного изображения обрабатывается и полученные байты заносятся в список treasureData (list) пока размер списка не достигнет 68 608 байт (то самое число, которое передавалось в ArtifactScanning
)

В результате мы имеем список list в котором 68 608 байт. Что же это за список?

Первые 2 элемента списка это байты 4D 5A. Поняли, да?) В этом списке содержится распакованный исполняемый файл. Чтобы в этом убедиться посмотрим как это выглядит в памяти.

Видим всем знакомый DOS‑header и имена секций. Отсюда я делаю вывод что изначальный семпл ml.exe это дроппер, который распаковывает полезную нагрузку и запускает её. Собственно потом в классе SuperAdventure
происходит вызов метода из распакованного исполняемого файла.

SuperAdventure.smethod_1(methodInfo, null, "Invoke", new object[] { 0, array }, null, null);
где methodInfo — информация о вызываемом метода. Имя метода — Justy, array — передаваемые в метод аргументы строки: «64 444A56», «727 163», «SuperAdventure».
Как работает сам механизм вызова метода:
1) Вызывается smethod_1
, которая является простой обёрткой и передаёт вызов в LateBinding.LateCall
.
2) LateCall вызывает InternalLateCall
, где происходит основная логика выбора способа вызова.
3) В InternalLateCall
происходит рефлексивный вызов с помощью InvokeMember
— вызывается метод «Invoke» у объекта MethodInfo.
4) InvokeMember вызывает MethodInfo.Invoke
, то есть реально вызывается метод Justy с переданными аргументами.
Схематично это выглядит так:

Получается своего рода матрёшка, цель которой — затруднить анализ вызова метода Justy из распакованной полезной нагрузки. Теперь давайте сделаем дамп чтобы изучить этот метод в распакованном файле. Для этого я написал небольшой код на python:
import ctypes
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
pid = 1234 # PID процесса
base_address = 0x040A946C # адрес первого байта (4D)
size = 0x0005B000 # размер файла/массива байт
OpenProcess = ctypes.windll.kernel32.OpenProcess
ReadProcessMemory = ctypes.windll.kernel32.ReadProcessMemory
CloseHandle = ctypes.windll.kernel32.CloseHandle
process_handle = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not process_handle:
raise OSError(f"[-] Не удалось открыть процесс с PID {pid}.")
buffer = ctypes.create_string_buffer(size)
bytes_read = ctypes.c_size_t()
success = ReadProcessMemory(
process_handle,
ctypes.c_void_p(base_address),
buffer,
size,
ctypes.byref(bytes_read)
)
if not success:
CloseHandle(process_handle)
raise OSError("[-] ReadProcessMemory не удалось выполнить.")
with open("payload_dump", "wb") as f:
f.write(buffer.raw)
CloseHandle(process_handle)
print(f"[+] Успешно дампнуто {bytes_read.value} байт в файл")
После запуска получаем дамп payload_dump. Открываем его так же в dnspy и видим следующее:

TL.dll
DnSpy определила его как библиотеку TL.dll, причем обфусцированную. Отправляем её в de4dot и получаем более читаемый код. Напомню что в качестве аргументов в метод Justy передаются 3 строки: «64 444A56», «727 163» и «SuperAdventure».

Итак, метод Justy:

Видим много вызовов различных методов, но как оказалось большинство из них либо пустые либо не имеют смысла. Например smethod_9
это просто 2 цикла for, а smethod_10
вообще пустой.


Чтобы лишние методы нам не мешали я просто удалил их. Получилось следующее:

Сразу скажу что все smethod_* это всего лишь заглушки, которые вызывают другие методы. Например smethod_1 вызывает метод CausalitySource
. Далее я сразу буду писать название конечного вызываемого метода. Первые две строки делают одно и то же: вызывают метод CausalitySource который получает на вход набор байт, преобразует его в строку и возвращает её.

Далее в методе Justy идет большая строка с вложенными вызовами. Изучаем каждый метод справа налево (именно в таком порядке они вызываются).

Этот метод принимает два параметра: строку, полученную при первом вызове метода CausalitySource
, и строку, переданную в качестве одного из аргументов при вызове метода Justy
Сначала Assembly.GetEntryAssembly()
возвращает входную сборку проекта — то есть файл, с которого началось выполнение программы. В нашем случае это ml.exe
. Далее создаётся объект ResourceManager
, который обеспечивает доступ к ресурсам этой сборки. Первый аргумент — это имя ресурса (состоящее из пространства имён и имени класса), второй — имя сборки.
Затем вызывается метод GetObject(x10)
, который извлекает объект из ресурсов по имени, хранящемуся в переменной x10
. Результат приводится к типу Bitmap
, так как извлечённый ресурс представляет собой изображение.

Теперь перейдем к следующему методу smethod_4(Bitmap bitmap_0)

Сначала выполняется RestoreOriginalBitmap(bitmap_0, 150, 150)
— метод, который берет полученный нами на прошлом этапе ресурс, обрезает его по длине и ширине на 150 пикселей и возвращает полученное изображение. Это говорит нам о том, что нижние 150 пикселей были добавлены для маскировки или как шум/паддинг.

Полученное изображение подается на вход методу smethod_13(Bitmap bitmap_0)
, который проходит по каждому пикселю, преобразует его в 4 байта ARGB(int32) и складывается в один большой массив byte[]. Далее берутся первые 4 байта данного массива и конвертируются в int. Полученное число — размер полезной нагрузки и начиная с 5 байта копируется N байт массива. Это и есть полезная нагрузка. Очень похоже на то, что было при извлечении TL.dll.

На очереди метод smethod_5(byte[] byte_0, string string_0)
, он же SearchResult(byte_0, string_0)
:

Общая логика: дешифровка массива байтов BinaryCompatibility
с использованием двух ключей:
1) Статический ключ — переменная num
, которая равна последнему байту исходного массива, XOR с числом 112. Этот ключ не меняется в процессе дешифровки.
2) Динамический ключ — переменная num5
. В коде видно, что строка Opcode
преобразуется в массив байтов bytes
. Это сделано для того, чтобы на каждой итерации дешифровки использовать байт из массива по индексу num
. Изначально num
равен 0, поэтому при первой итерации берётся первый байт массива. С каждой итерацией num
увеличивается на 1, а как только достигает длины массива, сбрасывается обратно в 0.
Цикл расшифровки:
1) Берется зашифрованный байт и записывается в num4
2) Берется ключ из массива bytes и записывается в num5
3) Берется ключ num
4) Делается XOR всех трех переменных — num4 XOR num XOR num5
5) Результат записывается в массив array
6) Делается проверка переменной num3
: Если она равна длине строки Opcode, то num3
равна 0, а иначе увеличивается на 1.
Конечный результат — массив array
с расшифрованными байтами. Если посмотрим память то увидим до боле знакомую картину:

Получается что метод Justy
в распакованной библиотеке TL.dll также выполняет распаковку исполняемого файла из изображения dDJV
, которая хранится в ресурсах изначального исполняемого файла ml.exe.
Теперь переходим к вызову smethod_12
.

Данный метод получает на вход расшифрованный массив и вызывает Assembly.Load
, которая загружает байты из этого массива в память, без сохранения на диск. Сделано это для того чтобы избежать обнаружения антивирусом с помощью файлового анализа. Возвращается объект, который представляет из себя новую сборку.

И последний метод — smethod_11

Данный метод:
1) Принимает на вход объект сборки
2) Из этой сборки получает все типы и возвращает их в массиве Type
3) Берет 21й тип из массива Type и получает все методы этого типа
4) Вызывает 30й метод
На деле вызывается метод ec9CTS9EMlrfJEdODQ
из пространства имен diS2MKGVhxCkt6IgIf
с аргументом K0qFnlEV
в Montero.dll. Собственно работа TL.dll окончена и можно перейти к изучению Montero.dll

Montero.dll
Опять обфускация, опять кидаем в de4dot и получаем читаемый файл:

Вызывается метод smethod_0
, который извлекает данные из собственной сборки. Строка K0qFnlEV
это имя ресурса в Montero.dll. В этот раз это не картинка, а простой набор байт)

Если кратко, то создаётся ResourceManager, привязанный к ресурсу K0qFnlEV
. Далее получает объект, кастует результат к byte[]
и возвращает его. И вот здесь проблема потому что если смотреть деобфусцированный код, то далее ничего не происходит, хотя в изначальном Montero.dll есть еще код. Видимо деобфускатор не очень хорошо справился. Подозреваю что использовался какой то кастомный обфускатор. После получения массива байт byte[]
выполняется следующий блок кода:

Т.к. кода осталось мало я решил вручную переписать данный метод. В итоге получилось следующее:
public static byte[] Decrypt(byte[] encryptedData, string key)
{
int index = 0;
while (index <= encryptedData.Length)
{
int currentIndex = index % encryptedData.Length;
int nextIndex = (index + 1) % encryptedData.Length;
int keyIndex = index % keyBytes.Length;
encryptedData[currentIndex] = (byte)(((encryptedData[currentIndex] ^ keyBytes[keyIndex]) - encryptedData[nextIndex] + 256) % 256);
index++;
}
// Удаляется последний байт
Array.Resize(ref encryptedData, encryptedData.Length - 1);
return encryptedData;
}
Алгоритм следующий:
1) Строка key преобразуется в массив байтов (ASCII)
2) Определяются 3 индекса для цикличного обхода массива
3) Берется байт и массива и XORится с байтом ключа
4) Вычитается следующий байт в массиве
5) Прибавляется 256 и берётся % 256 — чтобы результат точно остался в пределах 0–255
6) Полученный байт перезаписывается обратно в массив
Если посмотрим на полученный массив байт то увидим следующее:

Думаю пояснения здесь не нужны) Просто делаем дамп как в прошлые разы и закидываем в DnSpy.

Вот оно! Наконец мы смогли добраться до полезной нагрузки. Причем никакой обфускации здесь нет. Давайте же изучим ее.
CloudAgent.exe

Метод isUserExpired
Метод выполняет проверку на истечение срока действия, как часть системы лицензирования или временного доступа. Сначала извлекается дата из строки UltraSpeed.ExpireTimeDate
в формате yyyy‑MM‑dd. В программе зашита дата 2025–01–10. Данная дата сравнивается с текущей (DateTime.Now) и если текущая дата больше заданной, то программа завершает работу с помощьюApplication.Exit()

Методы DisableWD
, Taskmgr_Disabler
, CMD_Disabler
, Registeries_Disabler
объявлены в программе, однако не содержат никакого кода. Почему так? Вероятнее всего данное ВПО распространяется по принципу MaaS (Malware‑as‑a-Service). т. е. разработчик ВПО предлагает за дополнительную плату добавить некоторые модули которые улучшают функционал малвари. Например в этом случае за доп. плату разработчик мог добавить функцию отключения Windows Defender (DisableWD), которую он скорее всего специально вырезал, но оставил вызов. Теперь понятно для чего нужен метод isUserExpired
.
Метод Start
В данном методе реализованы вызовы функций извлечения конфиденциальных данных из браузеров. Методы реализованы в классах COVIDPickers
и MozilSpeed
.

Методы Chrome_Speed, Torch_Speed, CocCoc_Speed, QQ_Speed, xVast_Speed, QIPSurf_Speed, Chromium_Speed, Blisk_Speed, Brave_Speed, Nichrome_Speed, Nichrome_Speed, Kometa_Speed, Superbird_Speed, Comodo_Speed, Cent_Speed, Chedot_Speed, Ghost_Speed, Iron_Speed, UC_Speed, BlackHawk_Speed, Citrio_Speed, Uran_Speed, Falkon_Speed, Sputnik_Speed, CoolNovo_Speed, Chrome_Canary_Speed, Sleipnir_Speed, Kinzaa_Speed, Amigo_Speed, Epic_Speed, e360_English_Speed, e360_China_Speed, Vivaldi_Speed, Xpom_Speed, orbitum_Speed, Iridium_Speed, SevinStar_Speed, работают по одному принципу, поэтому далее будет описана работа только метода Chrome_Speed
.
Метод Chrome_Speed
1) Определяется путь к базе данных логинов Chrome
2) Проверяется существование файла логинов Chrome. Если файл найден — начинается обработка
3) Создаётся обработчик SQLite‑базы (Login Data) и читается таблица logins
4) Перебираются все строки в таблице логинов (logins). Для каждой строки получаются URL, имя пользователя и пароль в зашифрованном виде
5) Определяется формат шифрования пароля. Если password_value
в формате V10 (то есть Chrome 80+), то получается master key из профиля пользователя и производится расшифровка пароля с помощью ключа в методе DecryptWithKey
(алгоритм описан ниже).
6) Если имя пользователя и пароль не пустые, то формируется строка с URL, логином, паролем и добавляется в UltraSpeed.PasswordVault
.

Метод DecryptWithKey
1) Копирует 12 байт из зашифрованных данных (скорее всего, это инициализационный вектор (IV)). Извлекает зашифрованные данные и аутентификационный тег (16 байт).
2) Используется алгоритм AES‑GCM для дешифровки данных с ключом (MasterKey), IV и аутентификационным тегом.
3) Возвращает расшифрованную строку, если всё прошло успешно, или null в случае ошибки.

Метод Microsoft_Speed
Метод работает так же, но в нем присутствуют некоторые особенности:
1) Реализован более сложный механизм обработки ошибок, включающий использование меток, что может указывать на попытку скрыть или минимизировать исключения для лучшего скрытия деятельности вируса.
2) Используется множество меток IL, которые затрудняют анализ кода с помощью перенаправления потока выполнения программы

Метод Outlook_Speed
1) Инициализируется процедура извлечения учётных данных из Outlook в методе GetOutlookPasswords
.
2) Внутри метода происходит поиск путей в реестре Windows, где Outlook может хранить данные учётных записей.
3) Анализируются обнаруженные ключи реестра, содержащие: email пользователя, учетные данные (логины и пароли) для протоколов IMAP, POP3, SMTP, HTTP
4) Для каждого найденного ключа проверяется, содержится ли в нём хотя бы один пароль
5) Пароль извлекается из ключа реестра в зашифрованном виде
6) Производится расшифровка пароля с помощью decryptOutlookPassword()
7) Если после расшифровки получен валидный email + пароль, они сохраняются во внутреннее хранилище (UltraSpeed.PasswordVault), в таком формате:
============X============
URL:
Username:
Password:
Application: Outlook
=========================

После того как отработали все методы и вся информация собрана вызывается метод SpeedOffPWExport
, который позволяет экспортировать собранные данные с использованием различных протоколов в зависимости от конфигурации. Всего реализовано 3 способа передачи украденных данных: FTP, SMTP и Telegram.
Все 3 метода используют переменную UltraSpeed.TheInfo
, которая включает информацию о компьютере, такую как его имя, дата, IP‑адрес и страна. Все реализованы в методе SpeedOffPWExport
.
FTP‑экспорт
Данный способ работает при #FTPEnabled
.
1) Формируется имя файла, включающее UltraSpeed.FTP_Domain
, имя компьютера (MyProject.Computer.Name), строку P и UltraSpeed.Encoder
, с расширением.txt.
2) Создается FTP‑запрос с методом STOR, который используется для загрузки файлов на сервер.
3) Устанавливаются учетные данные для авторизации на FTP‑сервере, используя UltraSpeed.FTP_Username
и UltraSpeed.FTP_Password
4) Формируется содержимое файла, включающее UltraSpeed.TheInfo
и UltraSpeed.PasswordVault
5) Получается поток запроса FTP, в который записываются подготовленные данные.
6) Данные отправляются на сервер, а поток закрывается.

SMTP‑экспорт
Данный способ активируется при #SMTPEnabled
В зависимости от значения UltraSpeed.Text_VenQJDFjPqkSr
(#AttachmentForced) используется либо отправка без вложения либо отправка с вложением.
Отправка без вложения:
1) Создается объект MailMessage
2) Устанавливаются отправитель UltraSpeed.Host_Sender
и получатель UltraSpeed.Host_Receiver
В данном случае Host_Sender — rock@supamemo.sbs
, Host_Receiver — rocee@supamemo.sbs
.
3) Формируется тема письма, содержащая имя пользователя и IP‑адрес системы
4) В тело письма добавляются UltraSpeed.TheInfo
(информация о системе) и UltraSpeed.PasswordVault
(содержит собранные пароли)
5) Создается SMTP‑клиент с сервером UltraSpeed.Host_Server
(mail.supamemo.sbs)
6) Устанавливается SSL, если UltraSpeed.SslSlate
равно «True»
7) Определяется порт сервера UltraSpeed.Host_Port
8) Передаются учетные данные UltraSpeed.Host_Sender
и UltraSpeed.Host_Password
9) Устанавливается протокол безопасности SecurityProtocol = 3072
10) Вызывается метод UltraSpeed.CertificateValidation()
, проверяющий сертификаты.
11) Отправляется письмо через smtpClient.Send(mailMessage)

Отправка с вложением
1) Создается объект MailMessage
2) Устанавливаются отправитель UltraSpeed.Host_Sender
и получатель UltraSpeed.Host_Receiver
. В данном случае Host_Sender — rock@supamemo.sbs
, Host_Receiver — rocee@supamemo.sbs
3) Формируется тема письма, содержащая имя пользователя и IP‑адрес системы
4) В тело письма добавляются UltraSpeed.TheInfo
(информация о системе) и UltraSpeed.PasswordVault
(содержит собранные пароли)
5) Данные кодируются в Unicode и Default.
6) Создается поток MemoryStream
, содержащий текстовые данные.
7) Формируется вложение UserData.txt
с MIME‑типом «text/plain».
8) Добавляется вложение в письмо.
9) Создается SMTP‑клиент, аналогично отправке без вложения.
10) Настраиваются параметры безопасности, авторизация и проверка сертификатов.
11) Отправляется письмо с вложением.

Telegram — экспорт
Данный способ активируется при #TGEnabled
1) Формируется сообщение, которое включает UltraSpeed.TheInfo
и UltraSpeed.PasswordVault
2) Отключается Expect100Continue
для ServicePointManager
3) Устанавливается протокол безопасности SecurityProtocol = 3072
4) Формируется URL для отправки файла через API Telegram. Для этого используется UltraSpeed.TG_Access
(токен бота) и UltraSpeed.TG_Profileid
(ID чата)
5) В caption передается имя пользователя, IP‑адрес и текст «Passwords».
6) Вызывается метод UltraSpeed.TGMultipart
, который загружает файл «Userdata.txt» с типом application/x‑ms‑dos‑executable на Telegram‑сервер.

Также в данном файле есть много методов с такими названиями как SpeedClipboard
, SpeedScreenshot
, SpeedKeylog
и т. д. Однако все эти методы пустые — вирусописатель намеренно их вырезал (вспоминаем про MaaS).
Итог
Семпл оказался весьма интересным с исследовательской точки зрения — в нём реализована защита от анализа в виде многослойной упаковки. Основной сложностью стало извлечение полезной нагрузки из дроппера, но благодаря пошаговому разбору это удалось реализовать.
Надеюсь, эта статья окажется полезной для тех, кто изучает вредоносное ПО, написанное на C#. Ниже я также привожу артефакты, которые могут пригодиться для идентификации этой разновидности ВПО — IoC (Indicators of Compromise) и общую схему распаковки.
IoC

Форма хранения полученных данных:
============X============
URL:
Username:
Password:
Application: Outlook
=========================
Строки: rock@supamemo.sbs
, W0kz);5}7i_aesKD
, mail.supamemo.sbs
, rocee@supamemo.sbs
, #SMTPEnabled
, #FTPEnabled
, #TGEnabled
, TL.dll
, Montero.dll
Схема распаковки
