Managed-провайдеры строят защиту на security-расширениирасширение, которое перехватывает запросы клиента и решает, разрешено ли их выполнять. Логика угрозы такая: клиент не должен получить права суперпользователя, а если и получит их через баг расширения или zero-day ядра — расширение всё равно должно его остановить. Разбор показывает, где эта логика ломается: расширение проверяет запрос по именам, а ядро потом само разрешает эти имена заново, уже под суперпользователем. Между проверкой и выполнением остаётся окно, в которое можно подсунуть другой объект.
Что именно проверяет расширение при старте backend
Когда стартует новый бэкендпроцесс, обслуживающий одно клиентское соединение, security-расширение перехватывает запросы клиента и решает, разрешено ли пользователю их выполнять. Для разрешённой операции оно временно переключает current_user на суперпользовательскую роль провайдера, даёт PostgreSQL выполнить запрос клиента с повышенными правами и возвращает исходную идентичность. Так обычный пользователь получает отдельные возможности без реального статуса суперпользователя.
При этом расширения блокируют ряд функций и возможностей Postgres даже для суперпользователей. Поэтому получение rolsuper через баг расширения или zero-day само по себе ничего не даёт, пока не удастся обойти hardening-расширение.
Конкретный пример из исходников supautils: при попытке создать FDWобёртку внешних данных — объект, через который Postgres обращается к внешним источникам расширение переключает пользователя на суперпользователя, передаёт исходный оператор ядру на выполнение с повышенными привилегиями, а затем понижает привилегии обратно.
Как устроен обход: переключение идентичности и момент его срабатывания
Расширение сначала проверяет, может ли клиент выполнить операцию, затем переключает current_user на суперпользовательскую роль провайдера, чтобы PostgreSQL выполнил оператор с повышенными привилегиями, и после этого восстанавливает исходную личность.
Ключевая деталь — в какой момент это происходит относительно разбора запроса. На примере валидатора: клиент передаёт имя валидатора, Supautils меняет current_user на свою административную роль, делегирует исходный оператор с этим именем, PostgreSQL разрешает имя в OIDчисловой идентификатор объекта в каталоге Postgres и вызывает функцию от имени заимствованного суперпользователя, после чего Supautils восстанавливает личность клиента. Переключение происходит до разрешения имени в OID и до вызова функции — то есть до выполнения, а не после парсинга.
Побочный эффект виден по схеме поиска: до повышения current_user=customer и "$user" указывает на схему customer, а во время повышения current_user=postgres и "$user" указывает на схему postgres.
На чём держится модель угроз провайдеров
Провайдеры исходят из того, что расширение безопасности — последний барьер: даже получение rolsuper через баг расширения или zero-day ядра не считается успехом, пока не обойдено само расширение. Расширения при старте backend создают контролируемое окно, где операции выполняются с rolsuper, — это требует сложной логики и делает их привлекательной целью.
При этом делается несколько предположений:
- ядро Postgres само по себе безопасно, а возможности выполнения кода через LANGUAGE internalспособ объявить функцию, реализация которой задаётся именем уже встроенной в сервер внутренней функции, а не телом на SQL или загружаемой библиотекой; в отличие от LANGUAGE C, который подключает внешний .so-файл, здесь код берётся из самого сервера, поэтому подсунутый .so не нужен устранены;
- расширение надёжно убирает у суперпользователя чтение и запись файлов.
Но расширения не передают 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 и эскалация
Пошаговая цепочка:
- Клиент поставляет имя валидатора в операторе
CREATE FOREIGN DATA WRAPPER. - Расширение в хуке для
T_CreateFdwStmtпроверяет, что текущая роль не суперпользователь и привилегированная, затем переключает текущего пользователя на свою административную роль. - Расширение делегирует исходный оператор ядру PostgreSQL.
- Ядро разрешает переданное имя в OID.
- Ядро вызывает эту функцию от имени заимствованного суперпользователя.
- Расширение восстанавливает исходную роль клиента.
Эскалация происходит на шаге 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поле системного каталога pg_proc, в котором записано имя внутренней функции, реализующей данную функцию; подмена этого имени меняет то, какая внутренняя функция вызывается при обращении к обёртке, и суперпользователь может это поле обновлять. Сначала создаётся функция-обёртка с 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 между собой, между двумя проверками всегда остаётся зазор.