Введение
По разным источникам, в 2023 году было зафиксировано несколько миллионов DDoS-атак на российские информационные системы. И судя по статистике, их количество только растёт. В связи с этим, а также по многочисленным просьбам коллег мы решили углубить наше исследование и более подробно разобрать средства используемые для этих атак и продемонстрировать возможные подходы для их анализа.
Для того чтобы полностью понимать весь контекст статьи, рекомендуется ознакомиться с первой частью.
В отличие от прошлой части, в данной работе описано исследование не одного, а трёх различных инструментов и инфраструктуры используемой для DDoS-атак. Их список можно найти на сайте разработчиков:
Это уже небезызвестный для наших читателей mhddos proxy, а также инструменты db1000n, и distress.
Статья будет полезна для специалистов ИБ, а также для всех остальных читателей, интересующихся обратной разработкой и сетевой безопасностью.
Не рекомендуется повторять действия, описанные в статье на системах с доступом в Интернет. Если же он необходим для тех или иных действий, то предварительно нужно убедиться, что Вы полностью контролируете исходящий трафик ваших устройств, а также можете в любой момент отключить их от сети.
db1000n
db1000n (аббревиатура от “death by 1000 needles”) – инструмент для DDoS-атак с открытым исходным кодом, написанный на языке Golang. Ссылка на проект: https://github.com/arriven/db1000n. Там же можно скачать все всевозможные релизы проекта под различные архитектуры. На заметку производителям антивирусного ПО: релизы исследуемых инструментов выходят довольно часто, иногда по нескольку раз в месяц, так что не забывайте обновлять свои сигнатуры.
Скачиваем, запускаем инструкцию программы:
В инструкции сразу же написано, откуда берётся конфигурация:
1) https://raw.githubusercontent[.]com/db1000n-coordinators/LoadTestConfig/main/config.v0.7.json
2) https://github[.]com/crayfish-kissable-marrow/crayfish/raw/master/20.json
3) https://github[.]com/snoring-huddling-charred/snoring/raw/master/config.v0.7.json
Если вы попытаетесь её скачать, то обнаружите, что она зашифрована, что не удивительно.
Откроем файл в дизассемблере, и обнаружим следующее:
17 тысяч функций, при этом 0 импортов. Это значит, что исполняемый файл содержит всю стандартную библиотеку, и не пользуется системными динамически подключаемыми библиотеками. В обычных условиях анализ такого файла был бы затруднён, но в программе не вырезаны символы (названия функций и переменных), так что несложно определить, где происходит расшифровка конфигурации. По понятным ключевым словам, находим процедуру с незатейливым названием “github_com_Arriven_db1000n_src_job_config_fetchAndDecrypt”:
Вот как она выглядит в исходном коде: (https://github[.]com/arriven/db1000n/blob/d4f0eb7cd0804d217aa98943503afb272be30061/src/job/config/config.go#L81).
Эта процедура, в свою очередь, вызывает процедуру utils.Decrypt, определённую в https://github[.]com/arriven/db1000n/blob/main/src/utils/crypto.go. Там же можно найти ключи, используемые для расшифрования конфигурации:
Можно разобраться с алгоритмом и расшифровать конфигурацию самостоятельно, но мы просто запустим отладку поставим точку останова после вызова функции Decrypt (не забудьте передать путь к скачанной конфигурации в аргументе -с):
Запускаем программу под отладчиком gdb, устанавливаем точку останова, и дампим расшифрованную конфигурацию:
Таким образом удалось извлечь полный список 630 целей, атакуемый согласно данной конфигурации. Так как каждый сервер может атаковаться несколькими разными способами, то изначальных записей в конфигурации было 1612, вот некоторые из них:
217.70.17.99, 217.70.22.185, 84.23.33.140, 217.70.22.212, 84.23.34.52, 217.70.22.195, 217.70.22.211, 84.23.36.46, 217.70.21.198, 217.70.28.61, 84.23.34.21, 217.70.24.190, 84.23.44.28, 217.70.31.115, 217.70.17.190, 84.23.37.98, 217.70.27.197, 84.23.33.220, 217.70.17.130, 217.70.17.87, 84.23.38.166, 217.70.22.58, 84.23.39.148, 217.70.24.29, 217.70.24.183, 217.70.22.55, 217.70.17.251, 84.23.38.174, 217.70.22.103, 217.70.28.190, 217.70.29.117
В конфигурации также описаны способ осуществления атаки для каждой цели:
Как правило, это описание HTTP-POST-пакета, если сервис представляет собой HTTP(S) сервер, либо генерация случайной последовательности байт, для всех остальных протоколов.
Также конфигурация содержит одну специально зашифрованную активность, которую можно расшифровать, повторив те же самые действия при отладке. В результате получим следующий YAML-файл:
type: lock
args:
key: test
job:
type: discard-error
args:
job:
type: loop
args:
job:
type: sequence
args:
jobs:
- type: set-value
name: user-id
args:
value: '{{ if ne (.Value (ctx_key "global")).UserID "" }}{{ (.Value (ctx_key "global")).UserID }}{{ else }}777222111{{ end }}'
<…>
args:
job:
type: sequence
args:
jobs:
- type: set-value
name: source
args:
value: '{{(.Value (ctx_key "global")).Source}}'
- type: set-value
name: metrics-body
args:
value: '{"version":"{{.Value (ctx_key "version") }}","os":"{{.Value (ctx_key "goos") }}","traffic":{{usub64 (.Value (ctx_key "data.bytes-after")) (.Value (ctx_key "data.bytes-before"))}}, "user_id":"{{(.Value (ctx_key "data.user-id"))}}", "client_id":"{{(.Value (ctx_key "global")).ClientID}}", "packet_id":"{{random_uuid}}", {{if ne (.Value (ctx_key "data.source")) "{{(.Value (ctx_key \"global\")).Source}}"}}"source":"{{(.Value (ctx_key "data.source"))}}", {{end}}"duration":1}'
- type: log
args:
text: '{{(.Value (ctx_key "data.metrics-body"))}}'
- type: http-request
name: new-relic
args:
client:
proxy: {}
request:
method: POST
path: https://log-api.eu.newrelic.com/log/v1?Api-Key=eu01xx8e66c781d235a228e7d21e166aFFFFNRAL
headers:
Content-Type: application/json
body: '[{{(.Value (ctx_key "data.metrics-body"))}}]'
- type: log
args:
text: '{{ .Value (ctx_key "data.new-relic") }}'
- type: check
args:
value: '{{ ne (.Value (ctx_key "data.user-id")) "0" }}'
- type: http-request
name: metrics-request
args:
client:
proxy: {}
request:
method: POST
path: https://api.all-service.in.ua/api/db1000n/set-statistics
headers:
Content-Type: application/json
body: '{{(.Value (ctx_key "data.metrics-body"))}}'
- type: log
args:
text: '{{ .Value (ctx_key "data.metrics-request") }}'
Эта активность служит для сбора статистики DDoS атакующими на серверах https://api.all-service.in.ua/api/db1000n/set-statistics и https://log-api.eu.newrelic.com . Также статистика используется для накопления виртуальных очков, которые разработчики когда-то в будущем обещают обменивать на некие призы (finally – геймификация добралась и до DDoS-а).
Мы рассмотрели конфигурацию, полученную по первой ссылке. По второй ссылке скачивается идентичная конфигурация, а вот разобрав третью конфигурацию, получим ещё один список из 651 целей (атакуемых по 1436 сервисам), вот некоторые из них:
94.41.117.113, 46.191.142.72, 46.191.236.73, 95.105.90.146, 46.191.142.42, 77.79.161.58, 46.191.142.134, 79.140.16.202, 92.50.178.132, 92.50.178.126, 46.191.140.142, 46.191.142.144, 46.191.142.226, 79.140.16.169, 89.189.129.66, 46.191.234.94, 46.191.236.74, 46.191.140.103, 77.79.187.170, 46.191.237.241, 46.191.141.83, 77.79.161.57, 77.79.161.18, 46.191.237.221, 79.140.28.2, 46.191.234.51, 46.191.234.33, 92.50.191.55, 77.79.185.94
Списки целей обновляются каждый день, так что данная информация может не носить долговременный характер. Но проанализировав список, можно заключить, что на момент исследования под прицелом разработчиков находится в том числе большой пул адресов провайдера Ufanet (видимо ввиду того, что исследование проводилось в период крупных паводков в том числе на юге Урала).
Учитывая, что конфигурации различных инструментов частично пересекаются по спискам целей, инструмент db1000n может быть эффективно использован для автоматической расшифровки таких списков. Или же, конфигурация может быть расшифрована следующим небольшим скриптом на языке Python, использующим библиотеку pyrage:
import sys
from pyrage import passphrase
key = '/45pB920B6DFNwCB/n4rYUio3AVMawrdtrFnjTSIzL4='
data = open(sys.argv[1],'rb').read()
print(passphrase.decrypt(data, key))
Distress
Следующая вредоносная утилита для автоматического запуска DDoS – это написанная на языке Rust программа distress (https://github.com/Yneth/distress-releases). По лекалам разработчиков mhddos_proxy исходного кода в репозитории нет, что довольно удручает. Скачиваем версию для нашей платформы distress_i686-unknown-linux-musl (их там с десяток под все популярные архитектуры) и загружаем в дизассемблер, обнаруживаем там такую картину:
21 тысяча функций, ни одна из которых не имеет известного названия, и, конечно же, полное отсутствие каких-либо динамических импортов. Помните суффикс -musl в названии файла? Это открытая облегченная реализация стандартной библиотеки libc (https://musl.libc.org/), предназначенная для статического встраивания в программу. Ну, если нам недоступны символы, может попробуем найти нужные функции через строки? Запустим программу на виртуальной машине без доступа в Интернет:
$./distress_x86_64-unknown-linux-musl -v
2024-04-25 09:08:22 WARN - failed to load latest version, reason: TimedOutFetchingVersion. continuing to work...
2024-04-25 09:08:24 INFO - main| starting distress:0.7.20 for any questions or support refer to: https://t.me/distress_support
2024-04-25 09:08:24 INFO - main| initializing context...
2024-04-25 09:10:20 WARN - failed to retrieve default interface Generic("Local IP address not found")
2024-04-25 09:10:20 INFO - context| using interface []
2024-04-25 09:12:38 ERROR - failed to load targets
2024-04-25 09:13:22 WARN - failed to load proxies
2024-04-25 09:13:22 INFO - main| starting...
2024-04-25 09:13:22 ERROR - core| failed due to internal error, error_id:e9354d3c-dfc0-41c7-8c7c-22783553950c error:no targets...
2024-04-25 09:13:22 INFO - core| attempting to restart in 30sec...
2024-04-25 09:13:52 INFO - core| refreshing app...
Программа ругнулась, что не может загрузить список целей и прокси серверов, отлично, попытаемся найти эти “failed to load…” строки:
И… Пусто? Неожиданно, казалось, что программа не использует никакой защиты. Кстати, даже над этим выводом строк пришлось немного поработать. Стандартные строки в Rust имеют формат отличный от классических нуль-терминированных строк в Си, они задаются указателем и размером. При этом хранятся они, как правило, последовательно, и вместо нормального списка строк в поиске у вас в дизассемблере будет каша из одной большущей строки. Разработчики HexRays исправили эту проблему в плагине https://hex-rays.com/blog/rust-analysis-plugin-tech-preview/, так что пришлось им воспользоваться.
Итак, строки куда-то потерялись. Причём не все, а вполне определённые, написанные, вероятно, самим программистом, а не заимствованные из сторонних библиотек.
Но блуждая в потёмках дизассемблированного кода на глаза попадаются множество участков следующего вида:
Все такие участки имеют схожий паттерн: загрузка байтов из какого-то адреса в программе, XOR с заранее определёнными байтами, и сборка получившейся последовательности на стек. Похоже на расшифрование строк! Проверим вручную – и вправду, в данном случае получится строка RUST_BACKTRACE. Но как найти все такие участки в функции? Искать по ассемблерному коду слишком сложно, XOR может применяться и вне этого паттерна. Что ж, люди уже озаботились этой проблемой, и сделали удобные плагины, например https://github.com/patois/HexraysToolbox. HXTB позволяет осуществлять поиск по микрокоду IDA Pro – более высокоуровневому представлению, чем ассемблер, используемому во время декомпиляции. Не будем вдаваться в детали его установки и настройки, это можно узнать из документации. Перейдём к результатам: с помощью следующего небольшого скрипта можно найти все выражения, соответствующие нашему паттерну в текущей функции:
from idaapi import *
from hxtb import find_expr
query = lambda cf, i: i.op is idaapi.cot_xor and i.x.op is idaapi.cot_ptr and i.y.op is idaapi.cot_num
r = find_expr(here(), query)
for e in r:
print(e)
В выделенном участке устанавливается выражение поиска, которое можно описать следующим образом: “найти операции XOR, у которых первый операнд – указатель на данные, а второй операнд – число”. Применяем скрипт в текущей функции, и вуаля – получаем список таких участков в декомпилированном коде:
Дальше возникает большой вопрос: а что, собственно, делать с этими участками кода? Вероятно, лучший вариант – это найти все такие декомпилированные участки, и каким-то образом интерпретировать их через инструментарий IDC или idapython и автоматически получить все строки. Но лично нам неизвестен подобный функционал, поэтому сделаем по-другому. Есть небольшой плагин-эмулятор для IDA Pro (https://github.com/alexhude/uEmu), разработанный на базе Unicorn Engine. Его графическая часть с окошками у меня не завелась из-за проблем совместимости версий QT/Python и IDA Pro, но этого нам и не надо. Мы добавили в эмулятор функцию set_xor_comm, которая вызывается при эмуляции каждой инструкции, и если предыдущая инструкция – XOR, то оставляет в листинге комментарий с результатом XOR-шифрования.
Запустим на показанном ранее участке кода, и вот результат:
Решение очень неказистое, т.к. нужно самостоятельно вызывать эмулятор в нужных местах, и тем не менее, в комбинации с предыдущим скриптом оно позволило найти заветную процедуру по адресу 0x038B080:
Но большого прогресса это нам не дало – да, мы знаем, что строка используется в этой процедуре, но её цикломатическая сложность слишком велика:
В декомпилированном коде из этого получается около 4000 строк кода. Да, это не интерпретатор PyArmor на 100+ тысяч строк кода, но здесь нет никакого референса в виде исходного кода, который бы помог понять хоть что-то. И сразу видно, что всё что можно умный компилятор Rust вкомпилировал в эту функцию. И таких функций в программе множество, что сильно затрудняет анализ.
Ещё одна фишка Rust – асинхронный код (https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html). Это как многопоточность, но только в одном потоке, без особых накладных расходов в рантайме. У данной концепции много названий и реализаций (нити, корутины, горутины, и т.д.). Идея проста: программист определяет “длинные” и требующие множество системных вызовов функции как “асинхронные”, и во время выполнения программы поток управления переключается между этими функциями, создавая иллюзию параллелизма (и ускоряя программу за счёт того, что код может не ждать результат IO, а совершить другую работу).
На деле же компилятор Rust превращает каждую такую функцию в конечный автомат с несколькими состояниями, который может быть схематично представлен в псевдокоде следующим образом:
void async_func(future *fut, args…) {
switch (fut->state) {
case START:
init(args);
fut->state = WORKING;
return;
case WORKING:
result = do_work(args);
if result {fut->state = COMPLETED};
return result;
case COMPLETED:
error();
default:
error();
}
}
Нужно ли упоминать, что такая система так же увеличивает объём кода и тоже затрудняет его анализ? К сожалению, пока нет проекта модуля/плагина, который бы обнаруживал асинхронный код и улучшал его читаемость при декомпиляции – нам кажется, что это отличная идея для небольшого хобби-проекта.
И тем не менее, дальнейший статический анализ “влоб” не дал результатов. Вот бы достать где-нибудь отладочные символы, чтобы отделить ту сотню функций написанных самими разработчиками от 20000 библиотечных…
На самом деле, теоретически такое возможно. Для этого была опробована замечательная идея, реализованная в данном проекте: https://github.com/R33v0LT/rlibs2sigs. Она базируется на мощной инфраструктуре общедоступных модулей Rust (crates.io), менеджера сборки cargo, и сайта с документацией к модулям docs.rs, выгодно отличающих Rust от многих других языков программирования. Идея состоит в том, что в собранных программах Rust остаются строки с версиями вкомпилированных библиотек из менеджера пакетов cargo, например: “/cargo/registry/src/index.crates.io-6f17d22bba15001f/aho-corasick-1.1.3/src/packed/pattern.rs”. Можно собрать такие строки из программы, добавить в новый Rust-проект все библиотеки соответствующих версий, затем с помощью удобного сайта https://docs.rs/ автоматически выкачать все примеры использования функций библиотек, и скомпилировать всё это в статический модуль с отладочными символами. Затем скормить этот модуль стандартным инструментам IDA Pro/Rizin/Ghidra, создать сигнатуры каждой функции, и подгрузить их в дизассемблер, определив весь библиотечный код. К счастью, помимо идеи коллеги разработали скрипты для автоматизации всего процесса, вплоть до компиляции статического модуля и создания сигнатур, так что за подробностями отсылаю вас к их статье.
К сожалению, на практике этот подход работает не так идеально, особенно в нашем случае, когда в программе используются сотни различных библиотек. Причина проста: в компилятор Rust постоянно вносятся изменения, а также он имеет множество опций кодогенерации, которые могут значительно изменять результирующий машинный код, в связи с чем pattern-matching алгоритмы с использованием сигнатур оказываются бесполезны. Перепробовав большое множество различных опций компиляции, со скрипом удалось определить всего около 3т. библиотечных функций, и те в основном из стандартных библиотек UNIX и Rust:
Но и на том спасибо, это уже что-то. Далее был опробован динамический подход – почему бы не поставить точку останова на интересуемую функцию и не подсмотреть, каков контекст? Запускаем в gdb, пробуем. Нет результата. Строка failed to load… выводится, а остановки не происходит. Причина оказалась проста – разработчикам оказалось мало многопоточности, асинхронности программы, они сделали её ещё и многопроцессной. На каждый чих вроде загрузки обновления или конфигурации программа запускает новый экземпляр себя с аргументом --child. Повезло, что мы и сами можем запустить программу с таким аргументом:
$ gdb -args ./distress_x86_64-unknown-linux-musl -v --child
…
Reading symbols from ./distress_x86_64-unknown-linux-musl_mod...
(No debugging symbols found in ./distress_x86_64-unknown-linux-musl)
(gdb) starti
Starting program: ./distress_x86_64-unknown-linux-musl --child
Program stopped.
0x00007ffff6dbdb9a in ?? ()
(gdb) i proc ma
process 1135
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x7ffff6c4e000 0x7ffff7c0e000 0xfc0000 0x0 ./distress_x86_64-unknown-linux-musl
…
(gdb) hbreak *(0x7ffff6c4e000 + 0x38B080)
Hardware assisted breakpoint 1 at 0x7ffff6fd9080
(gdb) c
Continuing.
2024-04-25 12:48:41 INFO - main| starting distress:0.7.20 for any questions or support refer to: https://t.me/distress_support
2024-04-25 12:48:41 INFO - main| initializing context...
2024-04-25 12:48:50 INFO - context| using interface ["eth0"]
Thread 1 "distress_x86_64" hit Breakpoint 1, 0x00007ffff6fd9080 in ?? ()
Отлично, теперь точка останова отрабатывает. Однако мы не разреверсили машинный код, и в контексте особо нечего искать, ведь мы не дали утилите доступа в интернет. Придётся это исправлять. При этом, не хотелось бы, чтобы вредонос отстукивал куда не надо и начинал свою DDoS. Как это осуществить это на своей линуксовой машинке, если исследуемая программа, вроде как, не особо настроена на то, чтобы слушать наши переменные окружения вроде HTTP_PROXY? Правильно, нам нужно создать прозрачный прокси, и в этом нам поможет iptables! С помощью его правил будем перенаправлять весь исходящий трафик на другой порт, где он будет прослушиваться с помощью прокси-сервера mitmproxy. Полную инструкцию по настройке такой конфигурации можно найти на https://docs.mitmproxy.org/stable/howto-transparent/. Только при запуске прокси не забудьте нажать на “i” и ввести ~q чтобы перехватывать и останавливать все запросы. Настроив наш настольный сетевой экран, пытаемся осторожно запустить программу, с готовностью в любой момент отключить хост от сети. Помним, что это методика не для исследования вредоносов, протечь хост может в любом случае, например через ICMP, да и вредонос теоретически может закрепиться и подождать отключения FW (у нас, правда, не тот случай), поэтому внимательно наблюдаем за объёмами исходящего трафика и готовимся отключить и откатить всё при возникновении каких-либо аномалий. После запуска получаем следующее предупреждение от утилиты и сразу же отключаем её, снося все процессы, которые она создала:
./distress_x86_64-unknown-linux-musl --verbose
2024-04-25 13:20:26 WARN - failed to load latest version,
reason: FailedToFetchNewVersion(Client(Error(Tls(Ssl(Error { code: ErrorCode(1),
cause: Some(Ssl(ErrorStack([Error { code: 167772294, library: "SSL routines",
function: "tls_post_process_server_certificate", reason: "certificate verify failed",
file: "ssl/statem/statem_clnt.c", line: 2091 }]))) },
X509VerifyResult { code: 19, error: "self-signed certificate in certificate chain" }))))). continuing to work..
Программа ругается на самоподписанный сертификат mitmproxy. Видим также соответствующее ему предупреждение в mitmproxy:
Warn: 172.21.177.101:34894: Client Handshake failed.
The client may not trust the proxy's certificate for raw.githubusercontent.com.
Ну что же, логично. Инфраструктура разработчиков по получению конфигурации и обновлению, как всегда, работает через Github, и использует открытую криптографию. Было бы страшно, если бы мы всего лишь запустив прокси могли бы взломать TLS-шифрование, на котором держится вся безопасность Интернета и просмотреть их пакеты. Но для того, чтобы взломать TLS нужен ещё один небольшой шажок!
Пытливые читатели уже заметили в предупреждениях программы довольно обширный вывод информации о местоположении ошибки, даже с номером строчки кода в неком “statem_clnt.c”, значит можно попытаться обойти проверку подлинности сертификата. По строке "tls_post_process_server_certificate" находим ту самую единственную функцию, использующую её:
Пусть вас не смущает, что в псевдокоде есть все имена и структуры – мы добавили их сами, и вы можете сделать так же, прочитав исходник statem_clnt.c, находящийся по адресу https://github.com/openssl/openssl/blob/5d218b0e447da20d44d75ab8105ee1d742ca8d09/ssl/statem/statem_clnt.c#L2091
Подобрать в этот код самый правильный и неинвазивный патч несложно, просто изменим функцию ssl_verify_cert_chain:
Теперь она вместо проверки цепочки сертификатов по всей иерархии PKI будет просто изменять verify_mode в 0 (не проверять цепочку доверия) а verify_result в 1 (проверка валидности сертификатов прошла успешно). О – оптимизация, теперь наша программа будет работать быстрее, и, в качестве приятного бонуса, позволит просматривать все свои TLS-пакеты.
Правда, ничего особенного в трафике найти так не удалось:
Программа загружает зашифрованную конфигурацию из своего репозитория, узнаёт свой IP-адрес через WHOIS-сервисы, затем загружает список целей из уже знакомых репозиториев. В дальнейшем подключаться и статистические запросы, которые были и в db1000n (POST-запросы по адресу https://log-api.eu.newrelic[.]com/log/v1?Api-Key=eu01xxdd3755319ebf893a8415d55d4a44a3NRAL). Если у вас есть возможность быстро и безболезненно заблокировать данный сервис локально, то лучше бы ею воспользоваться, чтобы даже если подобный вредонос залетит в вашу сеть, то от него не было никакой обратной связи.
Если дать программе поработать ещё немного, она выдаст свой список целей и попытается начать DDoS:
2024-04-23 10:11:54 INFO - main| starting distress:0.7.20 for any questions or support refer to: https://t.me/distress_support
2024-04-23 10:11:54 INFO - main| initializing context...
2024-04-23 10:12:11 INFO - context| using interface ["eth0"]
2024-04-23 10:14:04 INFO - main| starting...
2024-04-23 10:14:04 INFO - attacking tcp_flood:79.140.17.85
2024-04-23 10:14:04 INFO - attacking tcp_flood:145.255.18.248
2024-04-23 10:14:04 INFO - attacking tcp_flood:145.255.19.212
2024-04-23 10:14:04 INFO - attacking tcp_flood:95.105.118.200
2024-04-23 10:14:04 INFO - attacking tcp_flood:145.255.20.226
2024-04-23 10:14:04 INFO - attacking http_flood:79.140.23.4
2024-04-23 10:14:04 INFO <…>
Список целей мы проанализируем, а продолжать работу утилите, разумеется, не дадим.
Cначала показалось, что целей атаки было более двух тысяч, однако потом выяснилось, что одна и та же цель атакуется различными способами, поэтому итоговый список состоял из 835 адресов, вот некоторые из них:
79.140.16.154, 136.169.240.21, 95.105.118.232, 79.140.16.45, 95.105.118.250, 95.105.118.162, 79.140.16.17, 79.140.16.34, 79.140.21.64, 79.140.17.58, 94.41.188.144, 145.255.19.221, 145.255.23.176, 84.39.250.119, 95.105.118.160, 145.255.23.238, 145.255.18.133, 84.39.248.190, 79.140.17.184, 145.255.20.140, 79.140.20.69, 79.140.23.121, 95.105.118.96, 79.140.24.8, 95.105.118.18, 79.140.23.70
Но более интересна другая информация: после подгрузки целей утилита рапортует о том, что загрузила и настроила 200 прокси-серверов, чтобы не использовать IP-адрес хоста (также разработчики рекомендуют дополнительно использовать различные ВПН-сервисы). Но адреса прокси-серверов она уже не выводит в консоль, так что их придётся искать самостоятельно.
Для этого в коде была обнаружена другая процедура, которая использует зашифрованную строку “failed to load proxies or tor, using only my ip”:
В результате анализа выяснилось, что эта процедура конфигурирует цели атаки, прокси-серверы, и осуществляет выбор режима атаки в соответствии с конфигурацией. Вот доступные режимы:
Как видно из кода, утилита реализует самые разные способы атаки, в том числе DoS по протоколу ICMP. Однако при запуске icmp_flood и udp_flood по умолчанию отключаются, так как этот трафик не проксируется и выдаёт IP-адрес источника. Путём отладки также были найдены фрагменты конфигурации, описывающие шаблоны атаки, например:
По этим шаблонам, а также множеству нестандартных TCP-портов в конфигурации понятно, что разработчики активно исследуют как веб-сервисы ИТ-инфраструктуры, так и иные сетевые сервисы предприятий.
И, что ещё более важно, нам всё же удалось найти участок кода, отвечающий за подгрузку прокси-серверов:
Так сдампим его сразу же через IDA Pro:
Для этого при отладке понадобилось просто остановиться перед загрузкой прокси, узнать адрес массива, и запустить небольшой скрипт:
from idaapi import *
def get_rs_string(adr):
size = get_qword(adr+16)
if size ==0 or size == 0x8000000000000000:
return ''
str_adr = get_qword(adr+8)
return get_bytes(str_adr, size,0).decode('utf-8')
START = 0x00007FFFF47FDEC0
STRUC_SZ = 0x50
STRING_SIZE = 0x18
ea = START
i = 0
while i<200:
print(f'{{"address": "{get_rs_string(ea)}", "login": "{get_rs_string(ea+STRING_SIZE)}", "password": "{get_rs_string(ea+STRING_SIZE*2)}"}},')
ea += STRUC_SZ
i += 1
В результате получаем все явки и пароли двухсот прокси-серверов в удобном JSON-формате. Пароли от проксей оказались из списка топ-10 самых популярных, предположительно разработчики намеренно ищут подобные прокси-серверы, а не арендуют их. В этих прокси аутентификация происходит по обычной схеме (HTTP-заголовок Proxy-Authentication с Basic Authorization). Входящие соединения из адресов данного перечня точно подлежат блокированию, так как атаки могут производится непосредственно через них.
Помимо этого списка прокси из конфигурации, в самой программе обнаружен набор из 200 тор-прокси с их аутентификационной информацией, вот некоторые из них:
DA45B31898EDEBC5F6DB39F5BC0DB5FC693FCEFE etPW8yyK/OpOSPxBPZRnpNWd3t7tn926TwbN8Al3O2c 185.220.101.65:9100
322967A70161145738F6CEB4F5165FDADF9EB27C NNf9bN7uu6Dv8XLXWErl87IiakSe8wOf2JTi9eqajvY 104.244.73.136:9001
B13BF9FB86663B2D587611B5F3369873919AC54F TlDILF1MeboyyS7JwsCI1v4VKU/fqY6IXShnbG/7dls 185.244.194.156:4711
E2E1AAFB65DB0B82F868071FF173DDA28407FDDB jTpJOGcxmY90SVfvpN+rABEbsoFXa+oZx0ZVdGFvCc8 89.147.111.106:443
9785BD5AC04E6112071EA9172591275465FD758D o91FvBmAT/YWXI18b8ZOI2nEKJrN+A/iKhT77BiZKyE 185.220.101.64:9100
Аутентификационная информация в дальнейшем преобразуется в программе, но так как нас не интересует использование прокси-серверов, мы не стали с этим разбираться.
Можно заключить, что несмотря на то, что разработчиком не предпринято особых усилий по защите своего кода (по сравнению с разработчиками mhddos-proxy), использование Rust и некоторых библиотек вроде https://crates.io/crates/obfstr и https://crates.io/crates/subtle позволило существенно осложнить статический анализ программы.
MHDDOS proxy
Мы уже исследовали данный инструмент (можете почитать об этом в https://habr.com/ru/companies/usergate/articles/743080/), но от коллег начали поступать вопросы по проблемам на некоторых этапах его воспроизведения, так что теперь мы решили повторить и углубить анализ на последних версиях mhddos-proxy (v99). Данная часть статьи не будет повторять всех теоретических аспектов прошлой статьи, и предназначена в основном для тех, кто уже знаком с ними или читал нашу предыдущую статью по теме.
Напомним, что анализ актуальных версий утилиты состоял из двух этапов: статическая распаковка и расшифровка PyInstaller, которая позволила выявить обфусцированный с помощью Pyarmor код, и второй этап – динамический перехват кода программы во время его исполнения с помощью предзагрузки своей библиотеки через LD_PRELOAD.
С первым этапом нет проблем и на последних версиях mhddos_proxy, но он и не даёт интересных результатов. А вот во втором этапе возникли сложности: программа тихо падает, если запускать её с библиотекой-перехватчиком. Источник проблемы обнаружился быстро: оказывается разработчики ознакомились с нашей статьёй, и внедрили защиту от LD_PRELOAD (кстати, как вы можете наблюдать, у нас появилась возможность расшифровки и дизассемблирования байткода, защищенного PyArmor, но это тема заслуживает отдельной статьи):
Можно совсем не разбираться в байткоде Python, но здесь видны строки '4c445f5052454c4f4144' и from_hex. Если перевести эти строки самостоятельно из шестнадцатеричного представления в ASCII – получится “LD_PRELOAD”. Далее происходит проверка этой переменной окружения, и если она имеет какое-либо значение, то программа тихо завершается (sys.exit). Ну, тогда и мы пойдём дальше, ведь есть ещё один способ внедрить библиотеку в автозагрузку – через файл /etc/ld.so.preload. Достаточно создать его, указать в нём путь к .so библиотеке, и она будет загружена в каждое приложение, которое будет запущено впоследствии. Не очень удобно, но зато работает:
$ ./mhddos_proxy_linux
INIT LIB injected in mhddos_proxy_linux
INIT LIB injected in mhddos_proxy_linux
/tmp/_MEIUII0C3/libpython3.9.so.1.0 loaded
/tmp/_MEIUII0C3/lib-dynload/_struct.cpython-39-x86_64-linux-gnu.so loaded
…
Для дальнейшего анализа код нашего перехватчика пришлось дорабатывать, и на этом этапе возникли некоторые архитектурные проблемы: код начал разрастаться, и уследить за корректностью кода на Си стало всё сложнее. Также потребовалась реализация некоторых простейших алгоритмов. Из-за низкоуровневости Си, и отсутствия удобных контейнерных библиотек вроде STL этот процесс осложняется, в связи с чем было принято решение переделать программу под язык Rust. Мы не фанаты переписывания всего и вся на %language name%, но Rust хорош для консольных утилит и системного ПО, а его стандартная библиотека вместе с огромным количеством полезных библиотек от сообщества помогли сократить объём кода и облегчить внесение новых фич в наш небольшой проект. Не будем вдаваться во все подробности реализации, лишь перечислим основные зависимости, которые были использованы нами для облегчения разработки:
libc - биндинги к libc,
lazy_static - для определения глобальных состояний),
ld_preload_helpers - значительно упрощает определение хуков ld_preload, кода стало намного меньше чем в Си,
-
python3-sys - биндинги к CPython, чтобы не определять все типы самостоятельно.
Ещё одна причина использования Rust – низкая производительность написанных “на коленке” алгоритмов для фильтрации и поиска в Си. Но проблема оказалась более фундаментальной, и переход на Rust тут не спасает до конца: если перехватывать и записывать в консоль/на диск всё подряд, то производительность программы падает слишком низко, и программа фактически перестаёт быть рабочей. А чтобы перехватывать только то, что нужно – необходимо множество повторяющихся эскпериментов и перепроверок сотен тысяч строк лога и “ручная” настройка логирования.
В связи с этим было предпринято много попыток оптимизации вывода, вот одна из них:
fn filter_output(string: &str) -> String {
static NOT_INTERESTING_OUTPUT: [&str; 5] = [
"b'AWAVAUATUSH",
"bytearray(b'AWAVAUATUSH",
"b'\\x7fELF\\x02\\x01\\x",
"bytearray(b'\\x7fELF\\x02\\x01\\",
"b'PYARMOR",
];
if string.len() < 0x1000 {
string.to_string()
} else {
match NOT_INTERESTING_OUTPUT
.iter()
.find(|&x| string.starts_with(x))
{
Some(&pattern) => pattern.to_owned() + "...",
None => string.to_string() //"<stripped>".to_owned() + &string[..0x1000] + "</stripped>",
}
}
}
Дело в том, что в программе часто передаются определенные двоичные последовательности, не представляющие собой интереса для анализа. С помощью этой функции мы фильтруем их и не перегружаем вывод.
Примерно такой же принцип пришлось применить при логировании функций из стандартных модулей Python – они вызываются настолько огромное количество раз, что пришлось и их внести в чёрный список:
static NOT_INTERESTING: [&str; 20] = [
"pathlib",
"psutil",
"ipaddress.py",
"colorama",
"sre_parse",
"sre_compile",
"typing",
"ler",
"importlib",
"structures",
"ions_abc",
"_________",
"codecs.py",
"PyInstaller",
"enum.py",
"zipimport",
"requests/structures.p",
"faker/",
"_collections_abc.py_items",
"urllib3/util/timeout.py",
];
Следующая проблема, с которой пришлось столкнуться – это то, что без интернета анализ mhddos_proxy стал ещё менее интересен и больше не выдаёт дефолтную конфигурацию, как и все остальные, рассмотренные нами инструменты. В случае distress мы полагались на mitmproxy, но здесь решили фильтровать соединения тут же, в нашей библиотеке, с помощью перехвата функций connect и sendto:
const ALLOWED_IP: [&str; 14] = [
"0.0.0.0:443",
"172.21.176.1:53",
"0.0.0.0:80",
"5.188.158.161:80", /*"185.199.108.133:443",*/
/*"185.199.110.133:443",*/ "140.82.121.4:443",
"34.117.118.44:80",
"23.88.33.229:443",
"172.67.74.152:80",
"208.95.112.1:80",
"104.16.184.241:80",
"140.82.121.3:443",
"49.12.234.183:80",
"185.199.111.133:443",
"185.199.109.133:443",
];
unsafe fn connect/connect_real(sockfd:c_int, addr: * const sockaddr, addrlen: socklen_t) -> c_int {
let ipstr = sockaddr_to_ipport(addr);
let ipstr = ipstr.as_str();
print!("trying to connect to {}", ipstr);
if ALLOWED_IP.contains(&ipstr) {
print!(" - allowed address");
if CONNECT_ALLOWED {
connect(sockfd, addr, addrlen)
}
else {
println!(", but network is not allowed yet.");
-1
}
}
else {
println!(" - not allowed");
-1
}
}
}
Этим кодом задаётся возможность подключения лишь к IP-адресам из белого списка, в котором состоят в основном WHOIS-сервисы, к которым программа обращается для получения собственного IP, а также сервера github.com, откуда программа получает свою конфигурацию. Также, помимо фильтрации IP используется глобальный переключатель CONNECT_ALLOWED. Это сделано для того, чтобы разрешить сетевые подключения только после попытки ПО обновиться, чтобы оно не скачивало обновление, а сразу перешло к загрузке конфигурации:
let name = get_frame_name(frame);
if name.starts_with("src.misc.self_update_check_for_updates") {
println!("update appeared - disabling network");
disable_network();
}
else if name.starts_with("src.misc.system_fix_ulimits") {
println!("config appeared - enabling network");
enable_network();
}
Таким образом удалось сдампить список из более чем тысячи различных целей, вот лишь некоторые из них:
176.57.78.126 176.57.77.236 176.57.72.178 176.57.79.9 176.57.79.68 176.57.79.226 176.57.77.51 176.57.77.12 176.57.73.199 176.57.77.100 176.57.78.142 176.57.79.70 176.57.79.203 176.57.79.175 176.57.77.33 176.57.77.39 176.57.77.156 176.57.78.202 176.57.79.36 176.57.77.137 176.57.75.77 176.57.78.46 176.57.75.3 185.8.127.173 176.57.79.74 176.57.78.32 176.57.79.254 176.57.72.137 176.57.78.100 176.57.79.50 185.8.127.235 176.57.77.239 176.57.77.82 176.57.78.192 176.57.78.188 176.57.73.130 176.57.72.188 176.57.77.101 176.57.78.162 176.57.73.106 176.57.77.128 176.57.77.14 176.57.77.124 176.57.72.181 176.57.77.152 176.57.74.179 176.57.78.25 176.57.77.159 176.57.77.59 176.57.79.136 176.57.79.22 176.57.72.134 176.57.75.35
Особенно много в этом списке оказалось адресов провайдеров Esknet и Telincom.
Как и в прошлых случаях, каждая цель может атаковаться различными способами, заданными в конфигурации. В основном Это TCP или UDP-флуд, но есть и HTTP-запросы:
Также, помимо отдельных хостов в конфигурации присутствует DDOS по подсетям:
"networks": ["185.8.124.0/22", "176.57.72.0/21"],"networks": ["178.219.36.0/22", "178.219.40.0/22", "178.219.44.0/22", "178.219.44.0/24"]
Список целей — это хорошо, но как защититься от таких атак? Дальнейший анализ логов позволил извлечь более интересную информация: помимо списка целей удалось добиться расшифровки прокси-серверов, используемых разработчиками для масштабирования атаки и сокрытия своих IP-адресов. Входящие подключения из данного списка также небходимо блокировать. Вот некоторые из списка этих 200 прокси-серверов:
http://196.250.239.229:8787 http://117.86.15.183:8089 http://218.86.21.113:8089 http://123456:123456@89.46.4.138:44444 socks4://45.184.183.231:4145 http://50.235.247.114:8085 http://27.158.204.3:8089 http://180.120.215.40:8089 http://119.47.90.77:8080 http://38.52.221.46:999 socks4://45.169.217.62:4153 http://45.236.104.29:999 http://36.6.145.40:8089 http://111.225.153.32:8089 http://183.165.250.240:8089
Помимо HTTP-прокси, в списке также обнаружены 32 SSH-прокси, вот некоторые из них:
ssh://*@115.133.40.25:22 ssh://*@115.200.189.223:22 ssh://*@117.213.254.219:22 ssh://*@117.196.161.76:22
Учетные данные от этих прокси-серверов также оказались достаточно незамысловатыми (примерно из топ-10 популярных логинов-паролей), вряд ли данные сервера принадлежат разработчикам, скорее всего они находятся ими автоматизировано и могут быть заменены в любой момент.
Заключение
Мы проанализировали три основных инструмента, используемых для DDoS-атак на российскую ИТ-инфраструктуру. В результате анализа можно заключить, что:
для атак используются различные инструменты и технологии, объединённые одной платформой распространения конфигурации и сбора статистики;
списки целей постоянно обновляются в зависимости и от текущей повестки;
разработчиками инструментов ведётся работа по сканированию и анализу сервисов, уязвимых к DDoS-атакам;
для эффективной защиты от DDOS-атак необходим глубокий анализ инструментов атаки, так как их конфигурация и список прокси-серверов может постоянно изменяться.
Также мы представили некоторые базовые приёмы по анализу и отладке программ без исходного кода, которые могут помочь в исследовании других вредоносов.
Для получения полного списка прокси-серверов можно обратиться в mrc@usergate.com.
NickDoom
Есть одно интересное решение против ддоса, которое по очевидной причине никогда не применят — завернуть всё в I²P через аппаратные I²P-маршрутизаторы, сопоставляющие имя девайса и его I²P-ключ. Даже узнав, что терминал в банке обращается к серверу «Вася», понять, что «Вася» — это I²P «ФЫЛУАЦ;ЗКШЕЗфыВАВЫЛАЫЖЩУЛК%"З;%ШЕШВАЫЖДЛАЖЦФЫЛДК3кваы-c-хвостиком» — можно только вскрыв сам маршрутизатор. А их один раз настроили и забыли, ну версию иногда подновить, но таблица соответствия-то не меняется при этом. То есть эти данные нигде даже в кармане админа не ездят.
Получается, что никаким анализом трафа как на стороне терминала (ну мало ли, хакнули терминал), так и на стороне провайдера (опять же, какой-нибудь D-Link поломали) невозможно понять, куда в недрах I²P надо слать свой флуд, чтобы положить каналы банка. I²P-ключ знают только чёрт и маршрутизатор, а какой IP ему соответствует — ну, тут вся архитектура I²P заточена под то, чтобы его не узнал даже чёрт.
Отличное радикальное решение, которое никогда не будет использовано по очевидным причинам, пхех.
yamano Автор
Интересная мысль, но мне кажется, что такая реализация не отменяет 1) "физическую" ограниченность полосы пропускания, 2) ограниченность аппаратных мощностей атакуемых серверов)