Leaky Views と Security Barrier : PostgreSQL Advent Calendar #4

このエントリはPostgreSQL Advent Calendarに参加しています。12/4(日)担当也。ヨーロッパ中部時間ではまだ12/3(土)ですが。



RDBMSで行レベルのアクセス制御を実現する方法として、利用者に対して直接のアクセス権を付与せずに、特定のビューを通してだけアクセスを許可するのはしばしば使われるテクニックです。
ですが、場合によっては不可視な行の中身を参照できてしまうというのは、あまり広く認知されている訳ではないようです。
ので、問題のポイントと、現在開発中の PostgreSQL v9.2 に提案しているアイデアをご紹介します。

ユーザ定義関数のCOST値による問題

ここでは、以下の表を例に考えてみます。

customerテーブル

列名 制約
cid int primary key
cname text not null
cmail text
cpasswd text

customerテーブルには全顧客の情報が格納されているため、利用者は自分自身の情報しか見る事ができないよう設定しましょう。
(便宜上 PostgreSQL ユーザ名が cname に対応するものとします)

postgres=# CREATE VIEW my_account AS SELECT * FROM customer
                  WHERE cname = getpgusername();
CREATE VIEW
postgres=# GRANT SELECT ON my_account TO public;
GRANT

本来、このテーブルには 3ユーザ分の情報が格納されているのですが、

postgres=# SELECT * FROM customer;
 cid | cname |       cmail       | cpasswd
-----+-------+-------------------+----------
 101 | alice | alice@example.com | abcdef
 102 | bob   | bob@example.com   | xyz123
 103 | eve   | eve@example.com   | deadbeaf
(3 rows)

確かに、自分自身の情報しか参照できないように見えます。

postgres=# SET SESSION AUTHORIZATION alice;
SET
postgres=> SELECT * FROM customer;
ERROR:  permission denied for relation customer
postgres=> SELECT * FROM my_account;
 cid | cname |       cmail       | cpasswd
-----+-------+-------------------+---------
 101 | alice | alice@example.com | abcdef
(1 row)

しかし、利用者がSQL関数を定義できる場合、面白い事が起こります。
publicスキーマはデフォルトでCREATE権限を全体に与えている事に注意!)

postgres=> CREATE FUNCTION f_leak(text) RETURNS bool LANGUAGE plpgsql
           COST 0.00000001
           AS 'BEGIN RAISE NOTICE ''f_leak => %'', $1; RETURN true; END';
CREATE FUNCTION

postgres=> SELECT * FROM my_account WHERE f_leak(cmail);
NOTICE:  f_leak => alice@example.com
NOTICE:  f_leak => bob@example.com
NOTICE:  f_leak => eve@example.com
 cid | cname |       cmail       | cpasswd
-----+-------+-------------------+---------
 101 | alice | alice@example.com | abcdef
(1 row)

おっと、何か見えてはならないモノが見えたようです。
どういう事なのでしょうか、ちょっと EXPLAIN で調べてみましょう。

postgres=> EXPLAIN SELECT * FROM my_account WHERE f_leak(cmail);
                           QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on customer  (cost=0.00..20.85 rows=1 width=100)
   Filter: (f_leak(cmail) AND (cname = (getpgusername())::text))
(2 rows)

この実行計画はVIEWの本体である customer テーブルをスキャンしていますが、利用者が付与した f_leak() とVIEWの条件を順にチェックしています。
問題は、副作用を持つ f_leak() の実行コストが非常に小さな値に設定されているため、オプティマイザは cname = getpgusername() より先にf_leak()を実行して不必要な条件の判断を省略した方が得策であると判断して、関数の実行順序を並べ替えている事です。その結果、不可視であるべき行の内容が引数としてf_leak()に渡され、それが利用者に漏えいしている訳です。

JOINと条件句の分配に伴う問題

同様に、VIEWによる行レベルアクセス制御を破るシナリオはもう一つ知られています。
先ほどの customer テーブルに加えて、もう一つテーブルを追加して考察を進めてみましょう。

creditテーブル

列名 制約
cid int references customer(cid)
number text
expired date

この credit テーブルは顧客のクレジットカード番号を保持しています。先ほどの my_account ビューと同様に、自分自身のレコードだけを参照できるようなVIEWを定義してみましょう。

postgres=# SELECT * FROM customer;
 cid | cname |       cmail       | cpasswd
-----+-------+-------------------+----------
 101 | alice | alice@example.com | abcdef
 102 | bob   | bob@example.com   | xyz123
 103 | eve   | eve@example.com   | deadbeaf
(3 rows)

postgres=# SELECT * FROM credit;
 cid |       number        |  expired
-----+---------------------+------------
 101 | 1111-2222-3333-4444 | 2014-02-28
 102 | 5555-6666-7777-8888 | 2013-10-30
 102 | 1234-5678-1234-5678 | 2015-06-30
 103 | 0987-6543-2109-8765 | 2014-08-31
(4 rows)

postgres=# CREATE VIEW my_credit AS SELECT cname, cmail, credit.*
           FROM customer NATURAL JOIN credit WHERE cname = getpgusername();
CREATE VIEW
postgres=# GRANT SELECT ON my_credit TO public;
GRANT

おや、やっぱり何かおかしいようです。

postgres=# SET SESSION AUTHORIZATION alice;
SET
postgres=> SELECT * FROM my_credit;
 cname |       cmail       | cid |       number        |  expired
-------+-------------------+-----+---------------------+------------
 alice | alice@example.com | 101 | 1111-2222-3333-4444 | 2014-02-28
(1 row)

postgres=> SELECT * FROM my_credit WHERE f_leak(number);
NOTICE:  f_leak => 1111-2222-3333-4444
NOTICE:  f_leak => 5555-6666-7777-8888
NOTICE:  f_leak => 1234-5678-1234-5678
NOTICE:  f_leak => 0987-6543-2109-8765
 cname |       cmail       | cid |       number        |  expired
-------+-------------------+-----+---------------------+------------
 alice | alice@example.com | 101 | 1111-2222-3333-4444 | 2014-02-28
(1 row)

もう一度 EXPLAIN で実行計画を眺めてみましょう。

postgres=> EXPLAIN SELECT * FROM my_credit WHERE f_leak(number);
                              QUERY PLAN
----------------------------------------------------------------------
 Hash Join  (cost=20.89..43.96 rows=2 width=104)
   Hash Cond: (credit.cid = customer.cid)
   ->  Seq Scan on credit  (cost=0.00..21.60 rows=387 width=40)
         Filter: f_leak(number)
   ->  Hash  (cost=20.85..20.85 rows=3 width=68)
         ->  Seq Scan on customer  (cost=0.00..20.85 rows=3 width=68)
               Filter: (cname = (getpgusername())::text)
(7 rows)

困ったことに、『creditテーブルをf_leak()条件でスキャンした結果』と『customerテーブルをcname = getpgusername()条件でスキャンした結果』がJOINされています。
オプティマイザはJOINすべき行を最小化するよう条件句を分配するのですが、f_leak()関数は credit テーブルの number 列のみ、cname = getpgusername() 条件は customer テーブルの cname 列のみに依存しています。そのため、JOINの完了を待つ事なく個々のテーブルをスキャンする時点で条件句を実行した方が、JOINすべき行数を減らす事ができます。
その結果、副作用を持つf_leak()がcreditテーブルのスキャン計画に push-down され、最初の例と同様に、不可視であるべき行の内容がf_leak()に渡され、それが利用者に漏えいしてしまっています。

この2つの問題は共に、オプティマイザがVIEWの境界を越えて関数の実行順序を入れ替えている事が原因です。これは性能観点からは優れた実装ですが、セキュリティを目的としたVIEW定義という観点では問題です。
一方で、VIEW内部で使われている関数を全て評価してから、その外部から与えられた関数を評価するという実装は、安全ですが、性能上無視できない性能劣化をもたらします。例えば、1万行 x 1万行のテーブルをJOINする場合、外部から与えられた関数をテーブルスキャンの時点で評価する事で片方の行数を1万行から100行に絞り込めるとしたら、9900万行分のJOIN処理を省略する事ができます。

次に、PostgreSQL v9.2に向けて提案されている Leaky View 問題への対策を紹介しましょう。

VIEW の security_barrier 属性と最適化の抑制

ここからは、私の提案している「Fix Leaky View Problemパッチ」の解説です。
前節で考察したように、VIEWを行レベルアクセス制御の目的で利用する場合には、パフォーマンスとセキュリティのトレードオフが存在します。安全側に倒せば許容できない程の性能劣化を招く可能性があり、一方、性能最適であれば情報漏えいの危険があります。

Fix Leaky Views Problem パッチは、CREATE VIEW構文でWITH(...)句を用いてオプション値を指定することを許容します。構文は以下の通りです。

CREATE VIEW view_name [WITH (options[,...])] AS select_statement;
options:
  security_barrier[= true|false]

security_barrier オプションは、VIEWが行レベルアクセス制御を目的として定義されていることを示す属性です。これを指定することで、一部のクエリ最適化を抑制する事が可能になります。
この設計に至るまでには長い議論があったのですが、結局、パフォーマンスとセキュリティのどちらが重要であるのかを判断できるのはVIEWを定義する人のみである、というシンプルな結論にたどり着いたのでした。
VIEWにsecurity_barrier属性が付与されている時、VIEWの内側で使用されている全ての関数・条件句は、VIEWの外側から与えられた関数・条件句よりも先に実行される事が保証されます。

では、実際に試してみましょう。以下で定義する my_account_secure と my_credit_secure は、先ほどの2つの例で使用したVIEWにsecurity_barrier属性を付加したものです。

postgres=# CREATE VIEW my_credit_secure WITH (security_barrier) AS
           SELECT cname, cmail, credit.* FROM customer NATURAL JOIN credit
           WHERE cname = getpgusername();
CREATE VIEW
postgres=# GRANT SELECT ON my_account_secure TO public;
GRANT
postgres=# CREATE VIEW my_account_secure WITH (security_barrier) AS
           SELECT * FROM customer WHERE cname = getpgusername();
CREATE VIEW
postgres=# GRANT SELECT ON my_credit_secure TO public;
GRANT

動作結果は以下のようになりました。"f_leak => ..." と表示されている内容は、クエリによって本来参照可能なデータの範囲内に収まっている事が分かります。

postgres=# SET SESSION AUTHORIZATION alice;
SET
postgres=> SELECT * FROM my_account_secure WHERE f_leak(cmail);
NOTICE:  f_leak => alice@example.com
 cid | cname |       cmail       | cpasswd
-----+-------+-------------------+---------
 101 | alice | alice@example.com | abcdef
(1 row)

postgres=> SELECT * FROM my_credit_secure WHERE f_leak(number);
NOTICE:  f_leak => 1111-2222-3333-4444
 cname |       cmail       | cid |       number        |  expired
-------+-------------------+-----+---------------------+------------
 alice | alice@example.com | 101 | 1111-2222-3333-4444 | 2014-02-28
(1 row)

では、VIEWにsecurity_barrier属性を付加することで、クエリ実行計画にどのように変化しているのでしょうか?先ほどと同じように、EXPLAIN構文で調べてみましょう。

postgres=> EXPLAIN SELECT * FROM my_account_secure WHERE f_leak(cmail);
                               QUERY PLAN
-------------------------------------------------------------------------
 Subquery Scan on my_account_secure  (cost=0.00..20.88 rows=1 width=100)
   Filter: f_leak(my_account_secure.cmail)
   ->  Seq Scan on customer  (cost=0.00..20.85 rows=3 width=100)
         Filter: (cname = (getpgusername())::text)
(4 rows)

f_leak()関数の評価は cname = getpgusername() 条件で customer テーブルをスキャンした後に行われる事が分かります。オプティマイザは security_viwe 属性を持ったVIEWの内側に条件句を push-down しなくなりました。

もう一つの例も同様です。

postgres=> EXPLAIN SELECT * FROM my_credit_secure WHERE f_leak(cmail);
                                 QUERY PLAN
----------------------------------------------------------------------------
 Subquery Scan on my_credit_secure  (cost=20.89..46.96 rows=2 width=104)
   Filter: f_leak(my_credit_secure.cmail)
   ->  Hash Join  (cost=20.89..46.90 rows=6 width=104)
         Hash Cond: (credit.cid = customer.cid)
         ->  Seq Scan on credit  (cost=0.00..21.60 rows=1160 width=40)
         ->  Hash  (cost=20.85..20.85 rows=3 width=68)
               ->  Seq Scan on customer  (cost=0.00..20.85 rows=3 width=68)
                     Filter: (cname = (getpgusername())::text)
(8 rows)

パッチ自体の動作原理は極めて単純です。

PostgreSQLは、一旦、VIEWに対するクエリを内部的にサブクエリに書き換えます。その後、オプティマイザがクエリ実行計画を作成する際に、"シンプル"なサブクエリ(OFFSET/LIMIT句を含まない…など)であれば、性能最適の観点からサブクエリをJOINを用いてフラット化(Pull-Up)します。
その後で、条件句はオプティマイザによって性能上最適な位置に振り分けられるため、VIEWの内側/外側といった区別はもはや意味を持たなくなります。

VIEWのsecurity_barrier属性は、この際の条件に作用します。RangeTblEntry構造体のsecurity_barrierは、関連するサブクエリがVIEWに由来し、かつ、VIEWのsecurity_barrier属性がtrueである場合にセットされます。
以下の処理では、security_barrier属性が false だとpull_up_simple_subquery()は呼ばれないため、サブクエリのフラット化は抑制されます。

--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -543,6 +543,7 @@ pull_up_subqueries(PlannerInfo *root, Node *jtnode,
         */
        if (rte->rtekind == RTE_SUBQUERY &&
            is_simple_subquery(rte->subquery) &&
+           !rte->security_barrier &&
            (containing_appendrel == NULL ||
             is_safe_append_member(rte->subquery)))
            return pull_up_simple_subquery(root, jtnode, rte,

さらにもう一ヶ所。条件句に与える引数が特定のサブクエリにだけ依存している場合、オプティマイザはこの条件句の実行をサブクエリ処理の中に移動(Push-Down)しようとしますが、同様にサブクエリが security_view 属性つきのVIEWに由来する時は、これをスキップします。

@@ -763,6 +769,7 @@ set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
      Node       *clause = (Node *) rinfo->clause;

      if (!rinfo->pseudoconstant &&
+         !rte->security_barrier &&
          qual_is_pushdown_safe(subquery, rti, clause, differentTypes))
      {
          /* Push it down */

この2ヶ所の処理を追加することによって、これまで見たような、VIEWを行レベルアクセス制御の目的に使用する場合の問題を回避する事ができます。

FUNCTION の leakproof 属性

Leaky View問題はVIEWのsecurity_barrier属性によって解決する事ができるのですが、これは一部のクエリ最適化を無効化するために、場合によっては、そのためのコストが看過できないほど大きい事もあります。

例えば、アプリケーションの設計上、以下のようなVIEWを定義し、VIEWの外側から条件句(主キーによる絞込みなど)を与えて使いたいというケースを考えてみましょう。

CREATE VIEW valid_credit WITH (security_barrier) AS
    SELECT * FROM credit WHERE card_is_valid(number, expired);

SELECT * FROM valid_credit WHERE cid = <customer-id>

この場合、card_is_valid関数と、VIEWの外部から与えた cid = <customer-id> を用いて credit テーブルをスキャンした結果が利用者には返されます。ですが、VIEWにはsecurity_barrier属性が設定されているため、常にcard_is_valid関数が先に実行されます。
この制限は cid 列にインデックスが設定されていても同様です。したがってインデックス・スキャンが選択されるべき状況でも全件スキャンが選択されてしまいます。ああ困った、困った。

Fix Leaky View ProblemパッチはPart-1とPart-2から構成されており、Part-1は前述の security_barrier 属性の実装を、Part-2ではその例外を設定する機能を実装しています。

Part-2によって、先ほどのオプティマイザへの変更は一部修正されます。

--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -769,7 +769,8 @@ set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
        Node       *clause = (Node *) rinfo->clause;

        if (!rinfo->pseudoconstant &&
-           !rte->security_barrier &&
+           (!rte->security_barrier ||
+            !contain_leakable_functions(clause)) &&
            qual_is_pushdown_safe(subquery, rti, clause, differentTypes))
        {
            /* Push it down */

サブクエリがsecurity_barrier属性付きのVIEWに由来するとき、このif文は条件句のPush-Downを抑止しますが、Part-2パッチは条件句(clause)が leakable-functions (つまり情報を漏えいする可能性のある関数)を含んでいなければ、サブクエリへの条件句のPush-Downを許可するように修正します。

では、関数が情報を漏えいする可能性の有無をどのように設定するか。
それには、CREATE FUNCTION構文に新たに追加されるLEAKPROOF属性を使用します。

例えば、以下のように使用します。LEAKPROOFを指定することで、この関数に情報漏えいの恐れがないという事を明示的に指定できますが、これは同時に、潜在的に不可視の行の内容を参照することを可能にするため、関数のLEAKPROOF属性をセットするには特権ユーザの権限が必要です。
SE-PostgreSQLでも db_procedure:{install}権限をチェックする予定です)

CREATE FUNCTION is_positive(int) RETURNS bool LANGUAGE plpgsql
    LEAKPROOF
    AS 'BEGIN RETURN $1 > 0; END';

一部のビルトイン関数の中でも、明らかに情報漏えいのリスクがない関数については、デフォルトでLEAKPROOF属性がセットされています。
(全部で2400個程あるため、網羅的なチェックはこれからですが…。)

例えば、32bit Integer同士の大小比較を行う int4gt 関数は、以下のように実装されています。

Datum
int4gt(PG_FUNCTION_ARGS)
{
    int32       arg1 = PG_GETARG_INT32(0);
    int32       arg2 = PG_GETARG_INT32(1);

    PG_RETURN_BOOL(arg1 > arg2);
}

この実装に情報漏えいの危険はありませんので、DB初期化時にLEAKPROOF属性はセットされています。
その他にも、現在のパッチでは各種ビルトインタイプの等価・大小比較演算子の実装として利用されている関数にLEAKPROOF属性がついています。実際に試してみましょう。

postgres=# SET SESSION AUTHORIZATION bob;
SET
postgres=> SELECT * FROM my_credit;
 cname |      cmail      | cid |       number        |  expired
-------+-----------------+-----+---------------------+------------
 bob   | bob@example.com | 102 | 5555-6666-7777-8888 | 2013-10-30
 bob   | bob@example.com | 102 | 1234-5678-1234-5678 | 2015-06-30
(2 rows)

ユーザ bob は2枚のクレジットカードを持っています。リッチメンですね。

では、2つの条件句を付加してみます。一つは先ほどのf_leak()関数、もう一つは expired < '2014-01-01' という Date 型の大小比較演算です。

postgres=> SELECT * FROM my_credit_secure WHERE f_leak(number) AND expired < '2014-01-01';
NOTICE:  f_leak => 5555-6666-7777-8888
 cname |      cmail      | cid |       number        |  expired
-------+-----------------+-----+---------------------+------------
 bob   | bob@example.com | 102 | 5555-6666-7777-8888 | 2013-10-30
(1 row)

NOTICEメッセージが一行だけ表示されているという事は、大小比較演算はf_leak()関数よりも先に実行されたようです。EXPLAINで実行計画を見てみましょう。

postgres=> EXPLAIN SELECT * FROM my_credit_secure WHERE f_leak(number) AND expired < '2014-01-01';
                                QUERY PLAN
---------------------------------------------------------------------------
 Subquery Scan on my_credit_secure  (cost=1.06..27.06 rows=1 width=104)
   Filter: f_leak(my_credit_secure.number)
   ->  Hash Join  (cost=1.06..27.04 rows=2 width=104)
         Hash Cond: (credit.cid = customer.cid)
         ->  Seq Scan on credit  (cost=0.00..24.50 rows=387 width=40)
               Filter: (expired < '2014-01-01'::date)
         ->  Hash  (cost=1.05..1.05 rows=1 width=68)
               ->  Seq Scan on customer  (cost=0.00..1.05 rows=1 width=68)
                     Filter: (cname = (getpgusername())::text)
(9 rows)

見ての通り、expired < '2014-01-01' 条件句が credit テーブルのスキャンに結びついているのと比較して、f_leak()関数はmy_credit_secureビューの内側にPush-Downされていません。これが LEAKPROOF 属性の有無による違いです。もし credit テーブルにインデックスが設定されていれば、Push-Downされた条件句により、全件スキャンの代わりにインデックス・スキャンが選択されるかもしれません。

まとめ

確かこの問題は、かれこれ2年以上議論を続けてきた息の長い問題です。

2009年9月4日のセキュアOS塾SE-PostgreSQL vs Oracle Label Security』の資料の中で言及があります。(p.34)
http://sepgsql.googlecode.com/files/090904-jsosjk04-sepgsql-vs-ols.pdf

開発コミュニティとしての方向性は、概ね上で紹介した形で収束しつつありますが、まだ v9.2 の新機能として紹介できるかどうか、は分からない状況です。が、SE-PostgreSQLの行レベルアクセス制御機能を実現するためにもマージしておきたい機能ですので、なんとかcommitできるよう頑張りたいところです。

最後に『じゃあ、既存のシステムではどうやって対策したら良いのよ?』という質問に対して一つTIPSを紹介しておきたいと思います。

Q. PostgreSQL v9.1以前のバージョンでLeaky View問題を防ぐにはどうしたらよいか?
A. クエリに OFFSET 0 を付ける

オプティマイザがサブクエリをフラット化、または、条件句をPush-Downする時、サブクエリにOFFSET/LIMIT句が含まれている場合はそれを断念する、という事を思い出してください。
OFFSET 0は結果セットの先頭から値を読むという意味ですので、本来は何の意味もありません。ですが、ここまで説明した条件句の実行順序に起因する問題を防ぐには簡便な方法です。
ただし、関数のLEAKPROOF属性に相当する機能はありませんので、その点でトレードオフは必要になります。



PostgreSQL Advent Calendar向けに記事を書くにあたり、MySQL、MS SQL ServerOracle Databaseなど他のRDBMSの挙動はどうなっているのか調べたかったのですが、時間がありませんでした。特に Oracle は勝手にWHERE句に条件をくっつけるVirtual Private Databaseという機能を持っていますので気になります。

これらは、追って調査したいと思います。きっと。いつの日か。アディオス、アミーゴ。


さて、翌 12/5(月) は笠原さんです。よろしく〜