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(月) は笠原さんです。よろしく〜

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でユーザ定義関数のコストを指定する手段があるかどうか不明のため。

OSS開発勉強会-09

今日の勉強会で説明した内容のメモ

GitHubなんかを使って、何か別のOSSプロジェクト(gitで管理されている)の
派生バージョンを作り、かつ、オリジナルの更新に追従する方法。

オリジナルのGitリポジトリのURLを git://github.com/kaigai/my_oss.git とし、
このリポジトリに対するコミット権は無いものとする。

自分の派生バージョンのGitリポジトリのURLを ssh://git@github.com/kaigai/modified.git とする。
自分はこのリポジトリの所有者であるのでコミット権があるものとする。

% git clone ssh://git@github.com/kaigai/modified.git
% git remote add upstream git://github.com/kaigai/my_oss.git
  • 手順3:オリジナルの master をトラッキングするブランチの作成
% git checkout --track upstream/master -b develop

ここでは develop というブランチを作成し、同時に、このブランチが upstream リポジトリの master ブランチをトラッキング(追跡)するように設定している。

これにより、オリジナル側で新たなパッチが commit された場合には、git pull でオリジナルの修正をマージすることができるようになる。

  • 手順4:オリジナル側の更新をマージ
% git pull [--rebase]
  • 手順5:origin のリモートリポジトリに登録
% git push origin develop

この操作により、developブランチに加えた全ての修正がoriginのリモートブランチに加えられる事になり、他の利用者にも visible となる。

gitの場合、ローカル環境で commit しても、pushしない限りその修正はローカルのコピー(git cloneした)に留まることに留意。

この辺の git の機能を使えば、非常に楽に派生バージョンを並行に開発することができる。
SE-PostgreSQLのように、マージまでに非常に長い時間がかかって
いる場合でも、本家の追従を容易に行うことができる。

OSS開発勉強会-08 (前半)

ひそやか〜に続けている社内勉強会のメモ。
(特に今回は資料もなかったので)

libcurl について

このライブラリを使うと、HTTP(s)ほかプロトコルでURLにアクセスする
プログラムを、非常に簡単に書く事ができる。

ソースコードはこちらのリポジトリ参照
http://github.com/shiumachi/curl_tut

ポイントとなるコードは以下の通り

まず、ライブラリ自体を初期化する。
この関数はThread-Safeではないので、Apacheのモジュールから使うのであれば、
child_initフックなどの初期化フェーズ(まだスレッドが作られていない)で
呼ぶ必要があるだろう。

curl_global_init(CURL_GLOBAL_SSL);

そして、これと対になるのが

curl_global_cleanup();

とりあえず、グローバルな初期化と終了処理はこの2つ。

そして、個々のURLに対するロジックの核は以下のブロック

  FILE *fp = stdout;
  char *url = "http://www.example.com/";
  CURL *curl;
    :
  curl = curl_easy_init();

  curlinfo = curl_version_info(CURLVERSION_NOW);

  curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
  curl_easy_setopt(curl, CURLOPT_URL, url);

  res = curl_easy_perform(curl);

  curl_easy_cleanup(curl);

やる事は、curl_easy_init()で作成したCURLオブジェクトに、
色々オプションを指定して、最後にcurl_easy_perform()を実行する。
そうすると、この関数が指定したオプションに従って処理をしてくれる。

この場合、CURLOPT_URLで指定したURLからGETを試みて、取得したデータを
CURLOPT_WRITEDATAで指定したストリームに書き込む。

POSTメソッドの場合も非常に単純。libcurl-tutorial(3)を参照すると、
以下のような記述がある。

We provide a pointer to the data and tell libcurl to post it all to the remote site:

    char *data="name=daniel&project=curl";
    curl_easy_setopt(easyhandle, CURLOPT_POSTFIELDS, data);
    curl_easy_setopt(easyhandle, CURLOPT_URL, "http://posthere.com/");

    curl_easy_perform(easyhandle); /* post away! */

Simple enough, huh? Since you set the POST options with the CURLOPT_POSTFIELDS,
this automatically switches the handle to use POST in the upcoming request.

確かにこれは simple enough である。

さくらのVPSにFedoraをインスコ

話題のさくらVPS、早速申し込んでOSを入れ替えてみた。

私の好みにより、Fedora Linuxとする。

ただ、実際のところ、非常に簡単にインストールが終わり、
ちょっと拍子抜けしたくらい。

まず、標準のCentOS5.4環境にログインし、Fedoraのインストールイメージで
ブートするように grub の設定を書き換える。

[root@www5066u ~]# mount -o ro,loop Fedora-13-x86_64-netinst.iso /mnt/
[root@www5066u ~]# cp /mnt/images/pxeboot/vmlinuz \
                      /mnt/images/pxeboot/initrd.img /boot

ネットワークブート用の vmlinuz と initrd.img を /boot にコピー。
続いて、/boot/grun/grub.conf を以下のように書き換える。

# grub.conf generated by anaconda
#
# Note that you do not have to rerun grub after making changes to this file
# NOTICE:  You have a /boot partition.  This means that
#          all kernel and initrd paths are relative to /boot/, eg.
#          root (hd0,0)
#          kernel /vmlinuz-version ro root=/dev/vda2
#          initrd /initrd-version.img
#boot=/dev/vda
default=0
timeout=5
#splashimage=(hd0,0)/grub/splash.xpm.gz
serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1
terminal --timeout=10 serial console
hiddenmenu
title Fedora 13 installation
        kernel /vmlinuz ro console=tty0 console=ttyS0,115200n8r
        initrd /initrd.img
title CentOS (2.6.18-194.8.1.el5)
        root (hd0,0)
        kernel /vmlinuz-2.6.18-194.8.1.el5 ro root=LABEL=/ console=tty0 console=ttyS0,115200n8r
        initrd /initrd-2.6.18-194.8.1.el5.img

そして再起動。ああ、その前にネットワーク設定を書き留めておこう。


ネットワークブート用のカーネルで起動すると、まずKeyBoardとNICの
設定を聞かれる。ここはIPv4で先ほど書きとめておいたネットワーク
設定を記入する。

続いて、インストール元を聞かれるので『URL』を選択し、適当な
Fedoraのミラーを指定する。今回は理研FTPサーバを使った。

Welcome to Fedora for x86_64                                                    

┌───────────┤ URL Setup  ├────────────┐
│                                                              │
│          Please enter the URL containing the Fedora          │
│          installation image on your server.                  │
│                                                              │
│ http://ftp.riken.jp/Linux/fedora/releases/13/Fedora/x86_64/os│
│                                                              │
│ [ ] Enable HTTP proxy                                        │
│                                                              │
│ Proxy URL        ___________________________________         │
│ Username         _______________                             │
│                                                              │
│ Password         _______________                             │
│                                                              │
│            ┌──┐                   ┌───┐             │
│            │ OK │                   │ Back │             │
│            └──┘                   └───┘             │
│                                                              │
│                                                              │
└───────────────────────────────┘

その後、install.img をロードして anaconda が起動するが、以降の作業は
VNCサーバ経由で行った方が吉。
テキストモードだと指定できないオプションもあるので。
(パーティション構成のCustom Layoutなど)

VNCで繋いでからの作業は特に特筆する事は無し。
普段のインストール時と同じようにして、最後に "reboot" ボタンを
クリックして完了。

あまりに簡単すぎて拍子抜けした。

[root@www5066u ~]# uname -r
2.6.33.3-85.fc13.x86_64
[root@www5066u ~]# df
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/sda2             19894140   3315412  15568144  18% /
tmpfs                   252784         0    252784   0% /dev/shm
/dev/sda1               101086     62494     33373  66% /boot
[root@www5066u ~]# free
             total       used       free     shared    buffers     cached
Mem:        505568     159116     346452          0      10944      65856
-/+ buffers/cache:      82316     423252
Swap:       655352          0     655352
[root@www5066u ~]# sestatus
SELinux status:                 enabled
SELinuxfs mount:                /selinux
Current mode:                   enforcing
Mode from config file:          enforcing
Policy version:                 24
Policy from config file:        targeted

swap 2GBはちょっと多すぎる気がしたので640MBに減らした。

もちろん、SELinuxは有効にしている。

RBACによるDB管理者の設定

やっとこさdbadm.ppポリシーがFedoraでも有効化されたので、RBACを使った
DB管理者の設定方法について記す事にする。

ポリシーは selinux-policy-3.9.1 以降を用意してほしい。
現時点ではRawhide(開発者版)向けなので、Fedora15での機能になるハズ。

方針は以下の通り。

  • ユーザ dba をDB管理者として定義する
  • ログイン時のロール:ドメインは staff_r:staff_t とする。
  • sudoを使って root に昇格する際に、dbadm_r:dbadm_t に変更。

まず、UNIXユーザ dba を定義する。

[root@masu ~]# useradd dba -g users
[root@masu ~]# passwd dba
Changing password for user dba.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.

次に、対応する SELinux ユーザ dba_u を作成する。
dba_uは、UNIXユーザ dba と、staff_r および dbadm_r ロールを紐付ける目的で使用する。

[root@masu ~]# semanage user -a -P user -r s0 \
                        -R "system_r staff_r dbadm_r" dba_u
[root@masu ~]# semanage login -a -r s0 -s dba_u dba
[root@masu ~]# cp /etc/selinux/targeted/contexts/users/staff_u \
                  /etc/selinux/targeted/contexts/users/dba_u

最初の構文では、新たにSELinuxユーザ dba_u を定義する。
このSELinuxユーザには system_r と staff_r と dbadm_r ロールが
紐付けられている。
(system_rはサーバプロセスの起動のため)
また、ホームディレクトリのラベリングに使用する prefix として
user を指定(-P user)しているが、現状、これ以外の選択肢はない。

次の構文では、UNIXユーザ dba に対してログイン時に紐付けるSELinux
ユーザを指定している。ここで、先ほど定義した dba_u を指定する。

さらに、ログイン直後は staff_r:staff_t のロール/ドメインのペアを
関連付けるため、staff用の設定ファイルをコピーして使用する。
ログインしたらいきなり dbadm_r:dbadm_t というのは指定できない為念
(Dan Walshが「俺はそーゆーポリシーはヤダ」と突っぱねたので…。)

では、dbaでログインしてみよう。どうなるか。

[kaigai@masu ~]$ ssh dba@localhost
dba@localhost's password:
Last login: Thu Sep  9 22:33:52 2010 from localhost
[dba@masu ~]$ id -Z
dba_u:staff_r:staff_t:s0

確かに設定した通り、dba_u:staff_r:staff_t:s0 としてログインできている。

では最後に、ここから dbadm_r:dbadm_t に遷移するための sudo の設定を
行う事にしよう。

[root@masu ~]# echo "dba ALL=(ALL) ROLE=dbadm_r TYPE=dbadm_t NOPASSWD:/sbin/service" \
                    >> /etc/sudoers

この設定のポイントは、ROLE=dbadm_r と TYPE=dbadm_t である。
FedoraのsudoコマンドにはSELinux対応のパッチが含まれており、
sudoで権限を切り替える際に、ロール/ドメインも併せて指定する事ができる。
(ここ、テストに出ますよ!)

すると、どうなるか。

[dba@masu ~]$ sudo service sepostgresql start
Starting sepostgresql service:                             [  OK  ]
[dba@masu ~]$ ps -eZ | grep postgres
dba_u:system_r:postgresql_t:s0  13423 ?        00:00:01 sepostgres
dba_u:system_r:postgresql_t:s0  13425 ?        00:00:00 sepostgres
dba_u:system_r:postgresql_t:s0  13426 ?        00:00:00 sepostgres
dba_u:system_r:postgresql_t:s0  13427 ?        00:00:00 sepostgres
dba_u:system_r:postgresql_t:s0  13428 ?        00:00:00 sepostgres
dba_u:system_r:postgresql_t:s0  13429 ?        00:00:00 sepostgres
[dba@masu ~]$

こんな感じで、サーバプロセスの起動/停止が行えるようになる。

その他、sudo経由での/bin/bashを許可すれば、DB設定ファイルの編集ができたりするが、
それ以外のファイルには一切触れないという面白い事になったりする。

ユーザのホームディレクトリを読み込めず、さすがに辛いという場合は、
dbadm_read_user_files (読込のみ) か dbadm_manage_user_files (読み書き) の
booleanを設定して少しポリシーを緩めると良いだろう。

[root@masu ~]# setsebool -P dbadm_read_user_files 1

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の行レベル制御にも同じ方法を使うように提案されているものの、
こんなボロボロじゃ使い物にならないという事だ。早く直さねば。(´ー`;)