У Linux-майнера RedTail канал управления закрыт самописным протоколом: X25519, ChaCha20-Poly1305, сессионные ключи, маскировка под DoT. Отладку он не детектит, а просто убивает - сносит соседние процессы в своём пространстве, попутно зачищая конкурентов с машины. Поймать «в лоб» не выходит.
Ключ снять мне не удалось. И, как выяснилось, не зря - нужен он не был. Я взял то, что бот отправляет наверх, на шаг раньше шифрования. Ниже весь путь: где тупик, где разворот, что нашлось внутри.
С чего началось
У меня стоит Cowrie на 22-м и веб-панель, которая притворяется простаивающей нодой валидатора Solana - скучная, но для брутфорса лакомая. Мировой поток логинов идёт мимо круглосуточно, иногда приносит что-то крупное.
В этот раз выпал комплект: bash-загрузчик loader.bin и пять ELF под x86_64, i686, aarch64, arm7 и riscv, все под UPX. Опозналось по строкам: внутреннее имя libredtail, путь сборки /var/build/redtail/scripts/, systemd-юнит your-redtail.service. Внутри - XMRig 6.26.0.
Дальше только изолированный стенд, наружу ни одного пакета. Меня интересует исключительно то, что бот отправляет сам.
Что за зверь
Семейство - RedTail, он же libredtail. Майнер Monero плюс самораспространяющийся SSH-червь. Сборка статическая, stripped, UPX,
не PIE, база 0x400000 - эту деталь запомни, она потом сработала. Примерно 95% кода открытое: BoringSSL, libssh2, hwloc, snappy, libuv, abseil. Своё - форк libevent, протокол dcb и червь.
Маскируется под живые процессы: php-fpm: pool www, nginx: worker process, postgres: user dbname 127.0.0.1 idle, systemd-worker, /bin/-bash. Закрепляется сервисом и строкой в crontab, причём чужой crontab перед этим затирает: crontab -r, потом
@reboot со своей командой.
«Не PIE» здесь принципиально: адреса в файле совпадают с адресами в памяти.
Инфраструктура
| IP | порт | роль | хостинг | ASN |
|---|
| 130.12.180.51 | 43782 | C2, канал dcb | Omegatech/Virtualine | AS202412 |
| 130.12.180.51 | 2137 | майнинг-прокси | Omegatech/Virtualine | AS202412 |
| 31.56.209.165 | 2137 | C2 / прокси | SWISSNET LLC | AS209373 |
| 217.60.103.56 | 80/443 | раздача | SWISSNET LLC | AS209373 |
| 95.215.19.53 | 853 | C2 под DNS-over-TLS | Materialism s.r.l. | AS39287 |
Зацепился взглядом за одно совпадение: управляющий узел и сервер раздачи сидят на одном хостинге, SWISSNET, AS209373. Управление и доставка - одна инфраструктура, значит и оператор, скорее всего, один.
Протокол dcb, и это не TLS
Заголовок кадра ровно 32 байта: magic 0x1B0D6840, версия 5, тег dcb, флаги, длина payload в big-endian, nonce на 12 байт. В перехваченном nonce был нулевой.
На TLS-сканере порт C2 отвечает WRONG_VERSION_NUMBER, потому что это не TLS, а свой бинарный формат. Получается аккуратно: аналитик запускает TLS-сканер, видит мусор и идёт дальше.
Крипто вокруг такой: X25519 для обмена со статическим ключом сервера, ChaCha20-Poly1305 на записи, транспорт - тот самый форк libevent поверх libuv. Метки ключей, которые я вытащил из дампа: server-key, client-key, server-nonce, client-nonce.
Резолвер, который мне доверился
Свои C2 бот резолвит через DNS-over-TLS на 853, ротируя публичные резолверы - снаружи это выглядит как обычный трафик.
Но сертификат TLS он не проверяет. Я поднял свой DoT с самоподписанным сертификатом и получил полный MITM: бот спокойно отдавал имена, которые резолвил. Так собрался весь список доменов, одиннадцать штук, и три из них в публичных отчётах не встречались: proxy.identities.network, proxy.insanitycpp.cx, proxy.insanecppdev.com. Плюс ротация TLD - p.2137gang.{st,pl,net,shop,store} - и парные proxies.* на той же базе. Разделение простое: proxy.* - команды, proxies.* - вход в майнинг-прокси. В отчётах этого деления нет, а оно заметное.
Тупик
Дальше начинается самое неудобное. В бинарнике есть ChaCha20 от CRYPTOGAMS, и я нашёл единую обёртку, через которую проходят все SIMD-варианты:
chacha20_ctr32(out, in, len, key /* rcx */, nonce /* r8 */). Выглядит как подарок - ставим брейкпоинт и читаем ключ из регистра.
Не вышло. Малварь убивает всё в своём PID-пространстве, и отладчик для неё просто ещё один сосед. Перебрал по очереди: gdb напрямую - бот его снимает; unshare с net-ns - harness гибнет вместе с отладчиком, даже dpkg в сборке образа падал; gdb как PID 1 в контейнере - выживает, но бот форкается и перезапускает себя, и ты оказываешься не в том процессе; seccomp с запретом kill - отладчик больше не убивают, зато падает он сам, внутренняя ошибка; eBPF и uprobe - ядро в этом контейнере не даёт то, что нужно.
Отдельно про ключ: он генерируется в рантайме из сильной энтропии и затирается. Перебор по LCG, Mersenne Twister, RC4 со словом redtail - ноль, ровный шум.
Разворот
И тут стоит переформулировать задачу. Мне не нужен ключ. Мне нужен текст, который бот отправляет. А значит, взять его надо там, где запись ещё открыта.
По декомпиляции находится подходящая сигнатура: dcb_send_record(ctx, type, record, len, ...), где record - ещё плейнтекст. Достаточно дампа по rdx длиной rcx. Отладчика у нас нет и не надо: патчим бинарь.
Логика в три хода. Раз бинарь не PIE, адрес в файле равен адресу в памяти. Между функциями в .text есть щели из nop'ов - туда влезает стаб. Пролог целевой функции переписываем на прыжок в стаб, в конце стаба возвращаемся обратно.
Щель нашлась между .fini и .rodata, на 0x7f8300. Стаб открывает файл через sys_openat (257), пишет длину, пишет саму запись, восстанавливает регистры, повторяет перезаписанный пролог и прыгает назад. Точка входа превращается в jmp на семь байт - E9 с относительным смещением и два 90.
Грабли, которые съели полдня: первая версия стаба писала мусор. Причина в одной строке - mov edx, 8 для записи длины затирало rdx, в котором лежал указатель на данные. Пришлось сохранять указатель в r15 заранее. Ошибка на ровном месте, заметная только по факту.
Почему это работает: бот умеет убивать процессы, а патч процессом не является. Это байты в нём самом. Ты не подходишь снаружи с отладчиком, ты уже внутри его кода.
Что оказалось внутри
Прогон дал 25 МБ плейнтекста, 991 сессия. Бот складывал содержимое канала сам.
Конфиг майнера, слово в слово: autosave включён, OpenCL и CUDA выключены, CPU со всеми потоками, huge pages, randomx в авторежиме, и три пула - proxies.identities.network:2137, proxies.insanitycpp.cx:2137, proxies.insanecppdev.com:2137, все с nicehash. Команды распространения такие: crontab -r, затем
@reboot со своей строкой, и зачистка iptables - iptables -F, потом разрешающий INPUT на нужный порт.
Пути, по которым он лезет, - это и есть его карта: /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php (CVE-2017-9841), обход через /cgi-bin/.%2e/... (CVE-2021-41773 и 42013), PHP-CGI с allow_url_include и php://input (CVE-2024-4577), плюс открытый Docker API на 2375 и брутфорс SSH.
С телеметрией оператор тоже аккуратен: модель CPU, cgroups, ProcessName, LinuxCgroup - собирает паспорт хоста, чтобы понимать, куда попал.
Что из этого стоит забрать
Первое и главное: «не могу снять ключ» не значит «не могу прочитать канал». Часто достаточно взять плейнтекст на входе шифрования, на один шаг раньше.
Второе: против малвари, которая убивает отладчики, работает не отладчик, а патч. Процесс можно снять, байты в код не снимешь.
Третье: не PIE плюс nop-щели в .text - готовые условия для стаба. Флаги сборки стоит смотреть раньше, чем разбираться с крипто.
Четвёртое: MITM на доверчивом транспорте бесплатен. DoT без проверки сертификата не безопаснее обычного DNS, просто выглядит приличнее.
И общий вывод, который я вынес для себя: в разборе малвари выигрывает не самый громкий инструмент, а тот, кто раньше переформулировал задачу. Я не ломал их канал. Я дал боту возможность сдать его самому.
Атрибуция не утверждается: RedTail - финансовая группировка, не APT.
Разбираю малварь, которая считает себя умнее аналитика. Пишите, если есть что разобрать вместе. Telegram: @SIMCARDER22
Комментарии
1