seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

自作Javaアプリにサンドボックスで動くアドインの仕組みを作る方法

概要

Javaアプリケーション内から信頼できないかもしれないアドインコードをサンドボックスで実行するための方法」について、簡単なサンプルコードとともに、まとめてみたいと思う。

※ 信頼できないかもしれない、という意味は後述。*1

セキュリティマネージャとポリシーの有効化

SecurityManagerの有効化

まず、Javaアプリケーションは、何も設定していない場合はセキュリティチェックは全く行わないため、どのような操作でも全て許可されている状態と同じとなる。


セキュリティチェックを有効にするには、

    // セキュリティマネージャを有効にする.
    System.setSecurityManager(new SecurityManager());

とすれば良い。


このようにすると、ファイルの作成などの権限チェックすべき各種タイミングでセキュリティチェックが行われるようになる。

※ 設定した瞬間からセキュリティチェックが有効になるため、あらかじめセキュリティ回りの設定を済ませておく必要がある。


特に設定しなければ、デフォルトのポリシーファイルからPermissionの設定を読み取られる。

デフォルトのポリシーには、ほとんど権限が設定されていないため、サンドボックス状態となる。

ただし、今回はアドインをサンドボックスで動かしたいのであってアプリ本体をサンドボックスで動作させたいわけではない。


アプリ本体には明示的に権限を与える必要があり、そのためには明示的に"Policy"を設定する必要がある。

プログラムによるPolicyの設定

権限の集合である"Policy"は、システムプロパティでポリシーを記述したファイルを指定できる他、プログラム的に設定することもできる。


プログラム的に設定する場合、ポリシーは抽象クラスであるjava.security.Policyを派生させる必要がある。

この抽象クラスはすべてのメソッドが実装済みであるため、以下のように単純にインスタンス化できる。

    // セキュリティポリシーを構築する.
    // Abstractクラスだが、すべて実装済みなのでサブクラス化するだけでOK.
    Policy policy = new Policy() {};


このようにインスタンス化した場合、既定の実装では(過去の互換性のため)、ポリシーの初期化時に、そのポリシーのクラスのあるプロテクションドメインは"全権"が付与される。

したがって、何もしなくても、このアプリ本体のプロテクションドメインも全権つきになる。

※ もし、必要なPermissionを選択的にプログラムで制御するには、Policy派生クラスでgetPermissions()やimplies()などをオーバーライドする必要がある。
※ この方法では$JRE/lib/extフォルダ下などにある追加の拡張ライブラリについては権限が付与されないことに注意。*2



ポリシーのインスタンスは、以下のように設定する。
(SecurityManagerを有効にする前に設定しておくこと。)

    // セキュリティポリシーを設定する.
    Policy.setPolicy(policy);

これでセキュリティマネージャを有効にすると、セキュリティマネージャは機能しているが、アプリケーション本体は全権で動作する、という状況になる。

アドインのロードとサンドボックス

アドインのインターフェイス

以下のようなインターフェイスが定義されているものとする。

このインターフェイスはアプリ本体側に格納されている。

package jp.seraphyware.sandbox;

import java.io.PrintWriter;

public interface AddIn {

    void callCreateNewFile();

    void callCreateNewFile2();

    void callback(PrintWriter pw);

    void callExit();
}
アドインの実装クラス

アドインの実装クラスは、アプリケーションとは別のフォルダまたはjarファイルに格納されているものとする。

package jp.seraphyware.sandbox.addin;

import java.io.PrintWriter;

import jp.seraphyware.sandbox.AddIn;
import jp.seraphyware.sandbox.SandboxTest;


public class AddInImpl implements AddIn {

    @Override
    public void callCreateNewFile() {
        SandboxTest.createNewFile("callCreateNewFile");
    }
    
    @Override
    public void callCreateNewFile2() {
        SandboxTest.createNewFile2("callCreateNewFile2");
    }
    
    @Override
    public void callback(PrintWriter pw) {
        pw.println("hello, world!1");
        pw.println("hello, world!2");
        pw.flush();
        pw.close();
    }
    
    @Override
    public void callExit() {
        System.exit(0);
    }
}
アドインを発見できるようにサービスとして定義する

アプリケーション本体側からはアドインのクラス名やパッケージ名を事前に知ることはできない。

そこで、アドイン側に、どこにアドインがあるのかアプリ本体に通知するファイルを置く。

java.util.ServiceLoaderの標準APIで用意されている仕組みを用いると、このあたりは簡単に実装できる。

  1. アドイン側
    1. /META-INF/service/インターフェイスというテキストファイルを作成し、
    2. そのファイルの中にクラスの完全修飾名を記述する。(複数ある場合は複数行とする)
  2. アプリ本体側
    1. java.util.ServiceLoaderにインターフェイス名を指定して実装クラスをロードする

今回は「/META-INF/services/jp.seraphyware.sandbox.AddIn」というファイルを作成し、
以下のような内容とする。

jp.seraphyware.sandbox.addin.AddInImpl
アドインをロードするクラスローダの定義

Javaのセキュリティの仕組みは、コードベースによるもので、そのコードが何処に存在するのかによって権限を判定する。

そのため、すべてのクラスは「どこにあり(CodeSource)」「どんな権限(Permissions)」をもっているかを示すプロテクションドメインを持っている。


クラスは、クラスのロード時にクラスローダからプロテクションドメインを与えられ、一度ロードされたクラスが不変であるようにプロテクションドメインも不変となる。

よって、もし、アドインにアプリ本体とは別のプロテクションドメインを与えたいのであれば、別のクラスローダから読み込ませる必要がある。

    // サンドボックスで動作させるアドイン用クラスを、異なるプロテクションドメインをもつ
    // クラスとしてロードさせるために別のクラスローダでロードする.
    URL url = new File("bin2").toURI().toURL(); // クラスフォルダの指定
    URLClassLoader addInClassLoader = new URLClassLoader(new URL[] {url}) {
        @Override
        protected PermissionCollection getPermissions(CodeSource codesource) {
            PermissionCollection pc = super.getPermissions(codesource);
            // このクラスローダがロードするクラスのProtectionDomainに設定する
            // Permissionを、ここでオーバーライドすることができる.
            // 何も設定しなければ(あるいは何もオーバーライドしなければ)
            // 自分以外へのアクセス権限がなにもない、サンドボックスの状態となる.
            // pc.add(new RuntimePermission("exitVM"));
            // pc.add(new AllPermission());
            return pc;
        }
    };

上記例では、bin2というフォルダ上にアドインのクラスがあるとしてクラスローダを構築している。

※ 実行時にbin2にクラスパスが通っていてはならない。もしbin2にクラスパスが通っていると、アプリ本体のクラスローダで読み込まれることになる。


また、必要であれば、getPermissionsをオーバーライドしてプロテクションドメインに追加するPermissionを設定することもできる。

アドインに、追加で必要なアクセス権を確認するには、システムプロパティとして-Djava.security.debug=accessを指定した状態で実行すると、どのような権限を必要としていたのかコンソール上で確認することができる。


たとえば、以下のような診断メッセージが表示されるので、何の権限が足りていないのか把握できる。

...
access: access allowed ("java.util.PropertyPermission" "line.separator" "read")
access: access denied ("java.lang.RuntimePermission" "exitVM.0")
...

権限を追加で設定しなければ、デフォルトでは、自分自身への読み込みアクセスが許可されただけで他の権限が何もない、サンドボックス状態となる。

アドインのロードと実行

あとは、アプリケーション本体では、上記クラスローダを用いてサービスローダを通じてアドインをインスタンス化すれば良い。

    // アドインのロード
    ServiceLoader<AddIn> addInSrvLoader = ServiceLoader.load(AddIn.class, addInClassLoader);
    for (AddIn addIn : addInSrvLoader) {
    	// ... アドインを呼び出すいろいろな処理 (サンドボックスでの実行となる) ...
	addIn.callCreateNewFile();
	addIn.callCreateNewFile2();
    }

これでアドインのメソッドをアプリ本体から呼び出すと、そのメソッドは自動的にサンドボックス内で動作することになる。

つまり、権限のない場所にあるコードを実行すると、自動的に権限が制限される形となる。

サンドボックス上からのアプリ本体への呼び出し時の権限について

アドインをサンドボックス内で動作させるにしても、アドインからアプリ本体のメソッドを呼び出したり、あるいはコールバックを呼び出す必要がある場合があるだろう。


サンドボックス内のアドインからアプリ本体側のメソッドを呼び出した場合、権限は、あくまでもサンドボックス内での動作となる。


もし、アプリ本体側に権限が必要なメソッドがあり、且つ、それをサンドボックス内から利用可能にしたい場合には、以下のように特権で実行することを明示する。

    /**
     * ファイルへの書き込み.(doPrivileged)<br>
     * @param msg 診断用メッセージ
     */
    public static void createNewFile2(String msg) {
        System.out.println("createNewFile2: " + msg);
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            @Override
            public Void run() {
                try {
		    // ここに権限が必要な処理を記述する.
                    File file = File.createTempFile("dummy", ".txt");
                    file.delete();

                } catch (Exception ex) {
                    ex.printStackTrace(System.out);
                }
                return null;
            }
        });
    }

AccessController.doPrivileged()系メソッド内でコードを呼び出すことで、権限のないコードベースから呼び出されても、
「AccessController.doPrivilegedが記述されているクラスのコードベースに基づいて」セキュリティチェックを行うことになる。(必ずしも正確な表現ではないかも。)

これで、サンドボックス内のアドインであっても、アプリ本体側の管理されたメソッドを通じて、ファイルアクセスなどの有益な副作用を実行できるようになる。

アプリ本体のコード一式
package jp.seraphyware.sandbox;

import java.io.File;
import java.io.PrintWriter;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.AccessController;
import java.security.CodeSource;
import java.security.PermissionCollection;
import java.security.Policy;
import java.security.PrivilegedAction;
import java.security.ProtectionDomain;
import java.util.ServiceLoader;

public class SandboxTest {

    public static void main(String[] args) throws Exception {
        // このクラスのプロテクションドメインを取得し、
        // プロテクションドメインに初期設定されているパーミッションを確認する.
        final ProtectionDomain pd = SandboxTest.class.getProtectionDomain();
        System.out.println("protection domain=" + pd);
        // ↑ AppClassLoaderから読み込まれた場合、exitVM権限が暗黙で許可される.
        
        // セキュリティポリシーを構築する.
        // Abstractクラスだが、すべて実装済みなのでサブクラス化するだけでOK.
        // ※ 既定の実装では、ポリシーの初期化時に、そのポリシーのクラスのある
        // プロテクションドメインは"全権"が付与される.
        // したがって、このクラスのプロテクションドメインも全権つきになる。
        // ※ 独自のPermissionをプログラム的に制御するには
        // 派生クラスでgetPermissions()やimplies()などをオーバーライドする必要がある.
        Policy policy = new Policy() {};

        // セキュリティポリシーを設定する.
        Policy.setPolicy(policy);

        // このプロテクションドメインの付与されたパーミッションを確認する.
        // ※ なお、getPermissionで返されるのは、動的に計算された一時的な
        // パーミッションのコレクションであるため、編集しても無意味である.
        // ※ アクセス権不足などのデバッグは、以下のシステムプロパティを指定すると良い.
        // -Djava.security.debug=access
        System.out.println("perms=" + policy.getPermissions(pd));

        // セキュリティマネージャを有効にする.
        System.setSecurityManager(new SecurityManager());

        // このクラスのProtectionDomainに対してPolicyで許可しているのでokになる.
        createNewFile("mine");

        // このクラスのProtectionDomainに対してPolicyで許可しているのでokになる.
        createNewFile2("mine");
        
        // サンドボックスで動作させるアドイン用クラスを、異なるプロテクションドメインをもつ
        // クラスとしてロードさせるために別のクラスローダでロードする.
        // ※ 実行時、bin2にクラスパスが通っていないこと。bin2にクラスパスが通っていると
        // このクラスローダでは読み込まれなくなるため、プロテクションドメインを設定できない.
        URL url = new File("bin2").toURI().toURL(); // クラスフォルダの指定
        System.out.println("url=" + url);
        URLClassLoader addInClassLoader = new URLClassLoader(new URL[] {url}) {
            @Override
            protected PermissionCollection getPermissions(CodeSource codesource) {
                PermissionCollection pc = super.getPermissions(codesource);
                // このクラスローダがロードするクラスのProtectionDomainに設定する
                // Permissionを、ここでオーバーライドすることができる.
                // 何も設定しなければ(あるいは何もオーバーライドしなければ)
                // 自分以外へのアクセス権限がなにもない、サンドボックスの状態となる.
                // pc.add(new RuntimePermission("exitVM"));
                // pc.add(new AllPermission());
                return pc;
            }
        };

        // アドインのロード
        ServiceLoader<AddIn> addInSrvLoader = ServiceLoader.load(AddIn.class, addInClassLoader);
        for (AddIn addIn : addInSrvLoader) {
            // ロードされたアドインクラスの取得
            System.out.println("AddIn: " + addIn);

            // サンドボックスになっているか、プロテクションドメインの確認.
            Class<?> cls = addIn.getClass();
            ProtectionDomain pd2 = cls.getProtectionDomain();
            System.out.println("pd2=" + pd2);

            PermissionCollection perms2 = policy.getPermissions(pd2);
            System.out.println("perms2=" + perms2);

            // サンドボックス内からcreateNewFileメソッドの呼び出し.
            // (権限がないプロテクションドメインからの実行なのでセキュリティエラーになる.)
            addIn.callCreateNewFile();

            // サンドボックス内からcreateNewFile2メソッドの呼び出し.
            // createNewFile2メソッドはAccessController#doPrivileged()の中で
            // 実行しているため、外のコードから呼び出された場合でもOKとなる.
            addIn.callCreateNewFile2();
            
            // サンドボックス外で作成したファイルに対して
            // サンドボックス内での書き込みは許可される.
            File tmpFile = File.createTempFile("dummy", ".txt");
            try {
                System.out.println("PrintWriter: " + tmpFile);
                try (PrintWriter pw = new PrintWriter(tmpFile)) {
                    addIn.callback(pw);
                }
            } finally {
                tmpFile.delete();
            }

            // サンドボックス内ではexitも許可されていないと実行できない.
            try {
                addIn.callExit();

            } catch (Exception ex) {
                ex.printStackTrace(System.out);
            }
        }

        // すべて完了
        System.out.println("done");
    }

    /**
     * ファイルへの書き込み
     * @param msg 診断用メッセージ
     */
    public static void createNewFile(String msg) {
        System.out.println("createNewFile: " + msg);
        try {
            File file = File.createTempFile("dummy", ".txt");
            file.delete();
        } catch (Exception ex) {
            ex.printStackTrace(System.out);
        }
    }
    
    /**
     * ファイルへの書き込み.(doPrivileged)<br>
     * @param msg 診断用メッセージ
     */
    public static void createNewFile2(String msg) {
        System.out.println("createNewFile2: " + msg);
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            @Override
            public Void run() {
                try {
                    File file = File.createTempFile("dummy", ".txt");
                    file.delete();

                } catch (Exception ex) {
                    ex.printStackTrace(System.out);
                }
                return null;
            }
        });
    }
}

まとめ

以前、同じようにJavaのセキュリティメカニズムについて調べたことがあったが、時間が経過していることもあり、ずいぶん忘れていた。

(JAVAセキュリティメカニズムのメモ - seraphyの日記)


あらためて調べてみて、ようやく自分が何をまとめていたのか、もう一度分かったような感じである。

Javaのセキュリティモデル
  • Javaのセキュリティモデルは「コードベースモデル」であり、どこにコードがあるかによって権限を制御するものである。
  • すべてのクラスはクラスローダにより、プロテクションドメインを付与される。(プロテクションドメインは不変である。)
  • プロテクションドメインは、そのクラスがどこからロードされたかを示す「CodeSource」と、許可される権限「Permission」の集合である。
    • コードソースは、そのコードの場所と、もしコード署名がされていれば、その署名情報を持つ。
  • パーミッションは許可するものを指定する。拒否の指定はない。権限なしの状態から許可するものを付与する形式となる。
  • パーミッションは、Policy派生クラスによってコードベースごとに保持されている。
    • デフォルトでは、コードベースごとのパーミッションを定義するパーミッションファイルからPolicyが作成される。
    • デフォルトのポリシーファイルには有効な権限がほとんど設定されていないのでサンドボックス状態となる。
    • Policyはプログラム的に任意に設定可能である。
Javaのセキュリティメカニズム(要約)
  • Javaランタイムは権限が必要なメソッドの先頭で(セキュリティマネージャが設定されていれば)セキュリティチェックを行う。
  • セキュリティマネージャ(SecurityManager)は、(Java2以降は)単にAccessControllerへの委譲となっている。
    • 現在はSecurityManagerはオーバーライドせずインスタンス化してSystem.setSecurityManager()に渡すだけの定石となっている。
  • AccessControllerは、現在実行中のコールスタックから呼び出し元にさかのぼり各プロテクションドメインに対して権限の有無を問い合わせ、いずれかが不可とするとセキュリティ例外を発生させる。
    • つまり、コールスタック上の、もっとも権限の低いプロテクションドメインの結果が採用される。
    • ある時点でのセキュリティチェックを、その時点ではなく、あとで判定する必要がある場合には、コールスタックの状態をカプセル化したAccessControlContextを用いることができる。
      • 内部的には常にAccessControlContextを作成して、このcheckPermission()で権限チェックを行っている。
    • AccessControllerのdoPrivilegedメソッドでアクセス権限のチェック時に判定するプロテクションドメインを特権としてマークすることができる。
      • マークされたコールスタックが見つかった場合、コールスタックは、それ以上さかのぼらず、doPrivilegedされた時点で作成されたAccessControlContext(もしくはdoPrivilegedに明示的に指定されたAccessControlContext)でプロテクションドメインに対してチェックを行って終了する。
      • doPrivilegedメソッドは、すでに作成済みの任意のAccessControlContextでセキュリティチェックを行うこともできるため、すなわち空のPermissionsをもつ仮想のProtectionDomainを作成し、それをAccessControlContextにしたものをdoPrivilegedに与えてアクションを実施すれば、クラスローダを経由せずともサンドボックス状態での実行となる*3
    • AccessControllerContext#checkPermission()は、対象のプロテクションドメインに対してimplies()を呼び出し権限をチェックする。
  • ProtectionDomain#implies()は、まずイントールされているPolicyに、そのプロテクションドメインに設定されているパーミッションをチェックし、次にプロテクションドメイン自身が保持しているパーミッションをチェックし、双方が不可であれば不可とする。
    • PolicyでPermissionが設定されていなくても、プロテクションドメインに有効なPermissionが設定されていれば許可される。
      • つまりプロテクションドメインに与えられている権限をあとから減ずる方法はない。
    • PolicyのPermissionは実行時に動的に変更しえることも想定されている。(Policy#refreshメソッドがある)
    • Policyはデフォルトではポリシーファイルからポリシーを設定する"sun.security.provider.PolicyFile"の実装を用いる。
    • java.security.Policyの実装を直接使う場合は、そのクラスのあるプロテクションドメインは暗黙で全権(AllPermission)が付与される。
      • Policy#getPermissions()はコピーを返すので、権限を動的に変更したいのであればPolicyクラス内部で行う必要がある。
      • Policy#implies()で権限チェックを行っている。
        • 独自の最低限のPolicyを実装すれのであればgetPermissionsとimpliesをオーバーライドすればよい。
  • アプリをロードするクラスローダであるAppClassLoaderから読み込まれると、必ず"exitVM"のRuntimePermissionが付与される。
余談

最近、Java Appletのセキュリティが非常にうるさくなっていて、どうやら来年(2014年)からはサンドボックスで動くものであってもアプレットには必ずコード署名が必須になる様子である。

SunからOracleへ移行後にセキュリティに致命的な問題がたくさんみつかったのでJava Applet(Java全体?)の信用がガタ落ちというのはわかるが、
サンドボックスにも署名が必要とか、現在でもサンドボックスJava Appletを実行するだけでセキュリティの警告が必ず表示されるとか、
Javaサンドボックスが破たんしていることをOracle自らが宣伝しているようなものである。


それは例えて言えば、JavaScript5を実行するたびにChromeとかのモダンブラウザがセキュリティ警告を出すような状況であり、
「本来は警告などなしで、安全にコードが実行されてしかるべき」である。
Oracleは、どう考えてもセキュリティ対策の方向性を間違えている。

  • 署名したからといってコードが安全になるわけでもない。
  • 署名をつけるなら、わざわざサンドボックスに制限する必要ないから署名付き全権アプレットが増えて、素性のよく知らぬ企業の全権アプレットをほいほい実行せざるを得なくなる状況のほうが恐ろしい。
  • 社内ユーザーは独自PKIをもっていたとしても面倒が増えるだけ。
  • ホビーユーザーは完全に締め出される。(コード署名は企業でなければまず発行してくれないし、そもそも高額だし。)

さらにいえば、アプレットサンドボックスが信用できないのなら、アプリケーションのセキュリティポリシーも信用できないのではないか?という疑念が生ずる。


現時点ではJavaサンドボックスは悪意のあるものを防ぐ効果はないとみて、サンドボックスは不作法なアドインからアプリのリソースを保護する、ぐらいの認識が良いのかもしれない。

クラスローダを経由しないサンドボックスの作り方(おまけ) (2014/05/23追記)

doPrivilegedメソッドは、任意のAccessControlContextでセキュリティチェックを行うことができるため、
すなわち、空のPermissionsをもつ仮想のProtectionDomainを作成し、それをAccessControlContextにしたものをdoPrivilegedに与えてアクションを実施すれば、クラスローダを経由せずともサンドボックス状態での実行となる。

import java.security.AccessControlContext;
import java.security.AccessController;
import java.security.CodeSource;
import java.security.Permissions;
import java.security.Policy;
import java.security.PrivilegedExceptionAction;
import java.security.ProtectionDomain;
import java.security.cert.Certificate;

public class Main {

    public static void main(String... args) throws Exception {
        // 現在のプロテクションドメインを全権として
        // セキュリティマネージャを有効とする.
        Policy policy = new Policy() {};
        Policy.setPolicy(policy);
        System.setSecurityManager(new SecurityManager());
        
        // 空の権限コレクションを作成する = サンドボックス状態
        Permissions perms = new Permissions();

        // サンドボックス状態となるプロテクションドメインを定義する
        ProtectionDomain domain = new ProtectionDomain(
                new CodeSource(null, (Certificate[]) null), perms);

        // サンドボックス状態のプロテクションドメインでの
        // 権限チェックを行うためのアクセスコントロールコンテキストを作成する
        AccessControlContext ctx = new AccessControlContext(
                new ProtectionDomain[] { domain });
        
        // 作成したアクセスコントロールコンテキストを明示して
        // セキュリティチェックを掛ける.
        try {
            AccessController.doPrivileged(
                    new PrivilegedExceptionAction<Void>() {
                @Override
                public Void run() throws Exception {
                    System.exit(1); // exit権限が必要
                    return null;
                }
            }, ctx);
        } catch (SecurityException se) {
            System.out.println("セキュリティ違反: " + se);
        }
    }
}

実行結果は以下のようになる。

セキュリティ違反: java.security.AccessControlException: access denied ("java.lang.RuntimePermission" "exitVM.1")

*1:本当に悪意のあるコードは昨今のJavaだとサンドボックスでも意味ないかもしれない。

*2:2014/5/23追記 たとえばJava8のNashornは lib/ext/nashorn.jar に権限がないとダメ。このような場合にはPolicyクラスをカスタマイズするか、独自のpolicyファイルを定義する必要がある。

*3:2014/5/23追記: http://worldwizards.blogspot.jp/2009/08/java-scripting-api-sandbox.html この記事をみつけるまで、この事実に気が付かなかった!!