Schéma public et CVE-2018-1058
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.