Назад к блогу

Как обходят защиту от суперпользователя в managed PostgreSQL

Как обходят защиту от суперпользователя в managed PostgreSQL

Managed-провайдеры защищают PostgreSQL самописными security-расширениями, которые перехватывают запросы и выдают им привилегии суперпользователя на время выполнения. Разбор показывает, что между проверкой объекта и его разрешением в ядре остаётся окно, позволяющее подменить цель — и тогда код исполняется уже с правами администратора. Это ставит под вопрос всю модель угроз, на которой держится безопасность managed-баз.

Managed-провайдеры строят защиту на security-расширении. Логика угрозы такая: клиент не должен получить права суперпользователя, а если и получит их через баг расширения или zero-day ядра — расширение всё равно должно его остановить. Разбор показывает, где эта логика ломается: расширение проверяет запрос по именам, а ядро потом само разрешает эти имена заново, уже под суперпользователем. Между проверкой и выполнением остаётся окно, в которое можно подсунуть другой объект.

Что именно проверяет расширение при старте backend

Когда стартует новый бэкенд, security-расширение перехватывает запросы клиента и решает, разрешено ли пользователю их выполнять. Для разрешённой операции оно временно переключает current_user на суперпользовательскую роль провайдера, даёт PostgreSQL выполнить запрос клиента с повышенными правами и возвращает исходную идентичность. Так обычный пользователь получает отдельные возможности без реального статуса суперпользователя.

При этом расширения блокируют ряд функций и возможностей Postgres даже для суперпользователей. Поэтому получение rolsuper через баг расширения или zero-day само по себе ничего не даёт, пока не удастся обойти hardening-расширение.

Конкретный пример из исходников supautils: при попытке создать FDW расширение переключает пользователя на суперпользователя, передаёт исходный оператор ядру на выполнение с повышенными привилегиями, а затем понижает привилегии обратно.

Как устроен обход: переключение идентичности и момент его срабатывания

Расширение сначала проверяет, может ли клиент выполнить операцию, затем переключает current_user на суперпользовательскую роль провайдера, чтобы PostgreSQL выполнил оператор с повышенными привилегиями, и после этого восстанавливает исходную личность.

Ключевая деталь — в какой момент это происходит относительно разбора запроса. На примере валидатора: клиент передаёт имя валидатора, Supautils меняет current_user на свою административную роль, делегирует исходный оператор с этим именем, PostgreSQL разрешает имя в OID и вызывает функцию от имени заимствованного суперпользователя, после чего Supautils восстанавливает личность клиента. Переключение происходит до разрешения имени в OID и до вызова функции — то есть до выполнения, а не после парсинга.

Побочный эффект виден по схеме поиска: до повышения current_user=customer и "$user" указывает на схему customer, а во время повышения current_user=postgres и "$user" указывает на схему postgres.

На чём держится модель угроз провайдеров

Провайдеры исходят из того, что расширение безопасности — последний барьер: даже получение rolsuper через баг расширения или zero-day ядра не считается успехом, пока не обойдено само расширение. Расширения при старте backend создают контролируемое окно, где операции выполняются с rolsuper, — это требует сложной логики и делает их привлекательной целью.

При этом делается несколько предположений:

  • ядро Postgres само по себе безопасно, а возможности выполнения кода через LANGUAGE internal устранены;
  • расширение надёжно убирает у суперпользователя чтение и запись файлов.

Но расширения не передают OID обратно в ядро — они лишь делегируют запрос на повторный разбор ядром. Отсюда предположение, что ядро разрешит функцию по тому же имени в search_path. Если атакующий может создать схему postgres и функцию с нужным именем, ядро вызовет её уже с правами суперпользователя. Дополнительно предполагается, что валидаторы FDW не проверяют, какие OID безопасно возвращать ядру для выполнения от имени суперпользователя.

Анатомия запроса: по какому узлу расширение распознаёт опасную операцию

Опасную операцию расширение распознаёт по узлу дерева разбора T_CreateFdwStmt — команда CREATE FOREIGN DATA WRAPPER определяется именно по этому типу узла. Внутри ветки проверяются не поля узла, а текущий контекст выполнения: если роль уже суперпользователь или не является привилегированной ролью, обработка пропускается. Иначе выполняется переключение на суперпользователя, и исходная команда передаётся PostgreSQL через utility hook. Поля самого узла CreateFdwStmt в этой ветке не читаются.

Структура CreateFdwStmt содержит имя обёртки, func_options с вложенными списками handler и validator, а также options. Имена функций до резолва в OID хранятся именно в func_options: каждый элемент — пара из имени схемы и имени функции. На этом этапе PostgreSQL ещё не преобразовал имена в OID; позже handler_oid и validator_oid получаются через resolve_function.

Почему момент вмешательства критичен

Расширение вмешивается до преобразования имён функций в OID — на этом этапе имена ещё не стали идентификаторами. Это критично, потому что расширение не передаёт OID ядру, а позволяет ядру заново обработать запрос при делегировании. После смены идентичности на суперпользователя ядро само разрешает имя валидатора в OID и вызывает его.

Поскольку проверка безопасности выполняется над именами до их разрешения, а повторное разрешение происходит уже под суперпользователем, можно подсунуть функцию с тем же именем, но другим OID. Это позволяет выполнить вредоносный код в окне повышенных привилегий — достаточно создать валидатор, который его запускает.

Case study #1: CREATE FOREIGN DATA WRAPPER и эскалация

Пошаговая цепочка:

  1. Клиент поставляет имя валидатора в операторе CREATE FOREIGN DATA WRAPPER.
  2. Расширение в хуке для T_CreateFdwStmt проверяет, что текущая роль не суперпользователь и привилегированная, затем переключает текущего пользователя на свою административную роль.
  3. Расширение делегирует исходный оператор ядру PostgreSQL.
  4. Ядро разрешает переданное имя в OID.
  5. Ядро вызывает эту функцию от имени заимствованного суперпользователя.
  6. Расширение восстанавливает исходную роль клиента.

Эскалация происходит на шаге 5 — в момент вызова валидатора под суперпользователем, потому что расширение не проверяет, какие OID безопасно передавать ядру для выполнения с повышенными привилегиями.

Вредоносный валидатор может создать роль-суперпользователя и выдать её текущему пользователю сессии:

EXECUTE 'CREATE ROLE callback_super NOLOGIN SUPERUSER';
EXECUTE format('GRANT callback_super TO %I', session_user);

Достаточно создать внешний источник данных с этим валидатором — например, CREATE FOREIGN DATA WRAPPER callback_fdw NO HANDLER VALIDATOR public.evil_validator OPTIONS (invoke 'now').

В момент срабатывания валидатора контекст меняется: до повышения current_user=customer и "$user"=customer schema, во время — current_user=postgres и "$user"=postgres schema. Именно поэтому команды внутри валидатора исполняются с правами суперпользователя, и клиент видит смену текущего пользователя на postgres. Валидируемая функция отличается от функции, вызываемой в повышенном контексте.

Case study #2: повторяющиеся ошибки и фиксы Supabase

Вторая уязвимость — в порядке проверок. Перед повышением привилегий код разрешает OID обработчика и валидатора, но затем проверяет лишь то, что они принадлежат одному расширению и что расширение обработчика входит в список разрешённых. Проверка совпадения владельца обработчика и валидатора подмену не предотвращает: злоумышленник создаёт собственный валидатор с вредоносными запросами и привязывает его к разрешённому обработчику через FDW. После этого привилегии повышаются до суперпользователя, а исходная инструкция передаётся в PostgreSQL, где исполняется от имени суперпользователя.

Supabase выпустила исправления для 4 различных критических находок, о которых сообщил автор. Общая причина, по которой расширение и ядро вынуждены делать одну и ту же проверку дважды, — та же: расширения не передают OID, а делегируют выполнение ядру, которое заново разрешает функции. Это создаёт возможность подмены функции с тем же именем, но другим OID, особенно в окне с повышенными привилегиями. Запросы должны быть проверены расширениями, а затем выполнены PostgreSQL с повышенными привилегиями суперпользователя — и если проверку удаётся обойти, вредоносный запрос исполняется в этом окне.

Подмена через схему "$user"

При переключении current_user с customer на postgres меняется и схема, подставляемая вместо "$user": до повышения это схема customer, во время — схема postgres. Атакующий создаёт в схеме postgres функцию, которая при вызове создаёт суперпользовательскую роль и выдаёт её текущему session_user. Затем создаётся FDW с неквалифицированным именем валидатора postgres_fdw_validator. При повышении прав поиск функции идёт по search_path, где "$user" уже указывает на схему postgres, поэтому вызывается подложенная функция, а не оригинальная. Валидируемая функция отличается от функции, вызываемой в повышенном контексте, — это и приводит к эскалации.

Case study #3: от SQL-инъекции до выполнения кода в ОС

Полная цепочка начинается с байтов, контролируемых атакующим, которые лежат в large object. Байты выгружаются на файловую систему сервера через lo_export(), в результате создаётся файл /tmp/attacker-module.so. Затем файл подключается как разделяемая библиотека через CREATE FUNCTION ... LANGUAGE C, что загружает нативный код внутрь процесса PostgreSQL. Функция public.mdi_shell с телом в виде пути /tmp/vendor-rce.so и имени символа ctf_exec_out при вызове выполняет команду в ОС и возвращает результат.

Обход чёрного списка через pg_catalog.pg_proc

PostgreSQL хранит имя реализации внутренней функции в pg_catalog.pg_proc.prosrc, и суперпользователь может это поле обновлять. Сначала создаётся функция-обёртка с LANGUAGE internal — её реализация задаётся не телом на SQL, а именем внутренней функции. Затем UPDATE pg_proc подменяет внутреннюю реализацию обёртки:

UPDATE pg_catalog.pg_proc SET prosrc = 'be_lo_export'
  WHERE oid = 'public.mdisec_lo_export(oid,text)'::regprocedure;

Теперь вызов обёртки выполняет be_lo_export — так обходится чёрный список, если защитное расширение не блокирует LANGUAGE internal.

Компиляция и загрузка модуля

Модуль собирают офлайн, используя заголовки разработки той же мажорной версии PostgreSQL, что и цель. Компиляция — gcc с флагами -shared -fPIC -O2 -Wall -Wextra и путём к серверным заголовкам из pg_config --includedir-server, результат — ctf_exec.so. Бинарные данные кодируют в Base64 и загружают в переменную psql. Затем через pg_catalog.lo_from_bytea(0, pg_catalog.decode(:'payload', 'base64')) создаётся большой объект и возвращается его OID как payload_oid. После этого создаётся функция LANGUAGE C, использующая загруженный модуль.

Роль LANGUAGE internal и LANGUAGE C

LANGUAGE internal и LANGUAGE C играют разные роли: internal используется для создания функции-обёртки, а C — для загрузки и выполнения записанного файла с нативным кодом. Переименование internal-алиаса обходит блокировку lo_export: создаётся функция с LANGUAGE internal и телом 'int4pl', затем через UPDATE pg_catalog.pg_proc поле prosrc заменяется на 'be_lo_export', что даёт доступ к запрещённой функции. Дальше LANGUAGE C загружает записанный файл и выполняет нативный код. Это работает, потому что security-расширения блокируют LANGUAGE C и lo_export, но не блокируют LANGUAGE internal.

Почему провайдеры были уверены в безопасности

Провайдеры считали, что устранили возможности чтения и записи файлов у суперпользователя через security-расширение, тем самым исключив выполнение кода через LANGUAGE internal. Но системный риск возникает из-за дублирования проверок: расширение и ядро должны делать одно и то же дважды, а расширения не передают OID, что позволяет ядру переразрешить функцию с тем же именем, но другим OID. При делегировании запроса ядру функцию можно подменить через search_path, создав схему postgres, — у default user есть CREATE на уровне базы, а роли и схемы — разные объекты, поэтому создание схемы с именем postgres не требует контроля над ролью postgres. В результате отсутствует проверка того, какие OID безопасно передавать обратно ядру для выполнения от имени суперпользователя, что и позволяет выполнить вредоносные запросы в окне повышенных привилегий.

Что говорит документация Postgres

Документация накладывает ограничения на расширения с флагом superuser: по умолчанию только суперпользователи могут создавать такое расширение или обновлять его до новой версии. Если параметр trusted установлен в true, установка разрешается любому пользователю с привилегией CREATE на текущей базе, но скрипт выполняется от имени bootstrap-суперпользователя. Разница в том, что флаг superuser определяет, кому вообще разрешена установка расширения, а trusted расширяет круг устанавливающих, но не понижает права, от имени которых исполняется скрипт установки: он всё равно выполняется как bootstrap-суперпользователь, а не как вызвавший его пользователь.

Эти ограничения не закрывают описанные векторы. Даже при superuser=true злоумышленник может создать троянские объекты, которые скомпрометируют выполнение скрипта и позволят получить суперпользовательские привилегии. Для trusted-расширения установочная схема выбирается устанавливающим пользователем, который может намеренно использовать небезопасную схему. SQL- и PL-функции расширений уязвимы к атакам на основе search_path, так как разбор происходит во время выполнения, а не создания; даже схема-квалифицированные ссылки на собственные объекты не полностью безопасны — вызов myschema.myfunc(42) может быть перехвачен враждебной функцией myschema.myfunc(integer). Скрипт установки или обновления должен защищаться от search-path-атак, иначе компрометация возможна немедленно или позже. Расширения, поставляемые с PostgreSQL, считаются безопасными против атак во время установки, кроме некоторых, зависящих от других расширений, которые следует устанавливать в безопасные схемы или в те же схемы, что и зависимости.

Что из этого следует на практике

  • Проверка по именам до резолва в OID — корень проблемы. Расширение проверяет оператор, пока имена функций ещё не стали идентификаторами, а ядро разрешает их заново уже под суперпользователем. Любой объект, который может перехватить имя в этот момент, исполняется с повышенными привилегиями.
  • Окно повышенных привилегий — цель атаки. Всё сводится к тому, чтобы заставить ядро выполнить вредоносный код между switch_to_superuser() и восстановлением роли. Валидатор FDW — удобный вход, потому что его имя поставляет клиент.
  • Схема postgres достижима. У default user есть CREATE на уровне базы, а схема и роль — разные объекты, поэтому подмена через search_path и "$user" не требует контроля над ролью postgres.
  • Блокировка LANGUAGE C и lo_export недостаточна. Если LANGUAGE internal не заблокирован, UPDATE pg_proc.prosrc превращает безобидную обёртку в запрещённую функцию, а дальше цепочка lo_export → .so → LANGUAGE C даёт выполнение кода в ОС.
  • Проверка владельца не спасает. require_same_owner не мешает подменить валидатор на свой и привязать его к разрешённому обработчику.
  • Дублирование проверок — системный риск. Пока расширение и ядро проверяют одно и то же по отдельности и не передают OID между собой, между двумя проверками всегда остаётся зазор.

Источники

Похожее