Java Security Manager でセキュアなサービスを構築しよう

こんにちは、アプリケーション基盤チームの青木(@a_o_k_i_n_g)です。

今回は Java アプリケーションをセキュアに運用する仕組みである Java Security Manager について紹介しようと思います。この仕組みは Linux の強制アクセス制御機構(SELinux や AppArmor) の Java 版に相当するもので、プログラムの挙動を制限することができます。弊社が提供するクラウドサービス cybozu.com でも有効化されています。

セキュアなサービスを提供する上では良い仕組みだと思うのですが、検索したところ Java Security Manager に関する記事があまり多くなかったため、我々が得た知見をここに記します。

Java Security Manager とは

Java Security Manager (以下 JSM) とは、Java コードを安全に実行する仕組みの一つです。Java コード上で危険な操作を行う際は、事前に権限を許可しておかないかぎり実行に失敗し、例外をスローするというものです。危険な操作とは、例えばファイルの読み書きやネットワークへの接続、外部コマンドの実行、それからクラスローダーの取得やシステムプロパティの読み書きなどを指します。

初期状態では JSM は無効になっており、Java コードはどんな処理でも行えます。自由ではありますが、一方でこれは危険な状態でもあります。サードパーティのライブラリが勝手にファイルを読み書きしてしまったり、またはリモートコード実行という脆弱性が見つかった場合どんな操作でもされたりということが起こりえます。JSM で適切なポリシーを構築すればこれらの被害を抑えることが出来るでしょう。

ポリシーの作り方

許可する権限をまとめたものをポリシーと呼びます。JSM を有効に機能させられるかどうかはこのポリシーを正しく構築できるかどうかにかかってるといっても過言ではありません。

どのような処理がどのような権限の許可を必要とするのか、こちらの仕様書に一覧があるのでわかりやすいです。
Java Development Kit (JDK)でのアクセス権

既存のアプリケーションでどのような権限を要求されているかについて知りたい時は、まず最初に全権限を許可するポリシーファイルを作ります。

grant {
  permission java.security.AllPermission;
};

上記を all.policy のようなファイル名で保存し、対象の Java プログラムの起動オプションに -Djava.security.manager -Djava.security.policy=all.policy -Djava.security.debug=access を指定して実行すると、下記のようなログが標準エラー出力に大量に書き出されます。これらのログは権限が必要になった時点でログに出力されるので、アプリケーションのすべての権限を集めたい場合は全機能を触る必要があります。こうして得たログを元にポリシーを構築しましょう。

access: access allowed ("java.lang.reflect.ReflectPermission" "suppressAccessChecks")
access: access allowed ("java.util.PropertyPermission" "java.security.egd" "read")
access: access allowed ("java.security.SecurityPermission" "getProperty.securerandom.source")
access: access allowed ("java.io.FilePermission" "/dev/random" "read")

ポリシーの記述方法

ポリシーを記述する方法はファイルに記述する方式と Java コードで記述する方式のふたつがあります。ファイルに記述する方式は公式ドキュメントでも解説されていますが、後述する性能問題やシンボリックリンクに関する問題があり、あまりお勧めしません。よってここではファイルに記述する方式の解説は省略し Java コードで記述する方式について解説します。

ポリシーをファイルに記述する方式に比べて、Java コードで記述する利点はいくつかあります。

  • パフォーマンスチューニングする余地がある
  • 複雑な権限評価を実装できる
  • ログ出力等も自由に可能

最小の記述で動かす例を示します。まず、下記のように java.lang.SecurityManagerjava.security.Policy を継承したクラスをそれぞれ作ります。SecurityManager を継承したほうは現在は空ですが後述する性能問題でオーバーライドするのでここで定義しておきます。

public class CybozuSecurityManager extends SecurityManager {
}

public class CybozuPolicy extends Policy {
    private final PermissionCollection permissions;

    public CybozuPolicy(PermissionCollection permissions) {
        this.permissions = permissions;
    }

    @Override
    public boolean implies(ProtectionDomain domain, Permission permission) {
        return permissions.implies(permission); // 権限評価部分
    }
}

実際に権限評価を行う部分は CybozuPolicy#implies メソッドです。今回のコード例では domain オブジェクトを無視していますが、domain オブジェクトを利用すればライブラリごとに評価する権限を切り替えるようなことができます。

上記のクラスを用いて、アプリケーションが起動した直後の処理に下記のようなコードを入れて JSM を有効化します。許可する権限は FilePermission クラス等、Permission クラスの子クラスのインスタンスで表現します。それらを先ほど作った CybozuPolicy クラスに渡し、Policy.setPolicy(Policy policy) で指定すれば権限が反映されます。

public static void main(String[] args) throws Exception {
    // ここで許可する権限を記述。
    Permissions permissions = new Permissions();
    permissions.add(new FilePermission("/tmp/-", "read,write"));  // /tmp/下のファイルを読み書きを許可
    permissions.add(new ReflectPermission("suppressAccessChecks")); // リフレクションを許可
    permissions.add(new PropertyPermission("line.separator", "read")); // line.separator プロパティを読み込みを許可
    permissions.add(new RuntimePermission("getenv.PATH")); // 環境変数 PATH の読み込みを許可

    Policy.setPolicy(new CybozuPolicy(permissions)); // ポリシーの反映
    System.setSecurityManager(new CybozuSecurityManager()); // SecurityManager をセット(JSM 有効化)

    ...
}

以上がほぼ最小の Java コードによる JSM を有効化する方法です。上記例で JSM を有効化した場合、例えば /proc 下のファイルを読む、というようなことはできません。リモートコード実行の脆弱性があったとして、System.setSecurityManager(new EvilSecurityManager()); のようなコードが実行されてしまうのではないか?という懸念があるかと思いますが、一度セキュリティマネージャーを設定した後再設定するには専用の権限を許可する必要があり、通常は行えません。

今後 SecurityManager を実装する方は、分散検索サーバーで有名な Elasticsearch のコードが参考になるかも知れません。

性能について

JSM を有効にする場合、既存の処理に加えてコードの各所で権限チェックが行われるので性能劣化を引き起こします。弊社のとある Web アプリケーションでは、特に性能について考慮せずナイーブにポリシーを構築した場合、応答時間が 3 倍程度まで伸びてしまいました。

これを改善するため、評価される権限のうちの一部頻繁に評価される権限について短絡評価を行うことにしました。短絡評価を行うには先ほどの CybozuSecurityManager クラスで checkPermission(Permission perm) メソッドをオーバーライドし、その中にロジックを記述します。

コード例を示します。こちらはファイルを読み書きする際の権限が要求された際に /tmp/ から始まるパスなら実行を許可するという例です。

public class CybozuSecurityManager extends SecurityManager {

    @Override
    public void checkPermission(Permission perm) {
        String name = perm.getName();
        String actions = perm.getActions();

        // ここで短絡評価を行い、実行可能な操作なら super.checkPermission(perm) を呼び出す前に return する
        if (perm instanceof FilePermission) {
            if (name.startsWith("/tmp/")) {
                return;
            }
        }

        ...
        super.checkPermission(perm);
    }
}

主に遅くなるのは SecurityManager#checkPermission の処理のようでした。上記のような短絡評価を複数取り入れた結果、性能劣化は 3% 程度に抑えられました。潤沢なリソースがある本番環境ではほぼユーザーエクスペリエンスに影響がない範疇に収められたかと思います。ただし、公式ドキュメントでも指摘されているように、SecurityManager クラスをサブクラス化する際は権限の評価漏れ等が起きないよう最新の注意を払ってください。

JSM をうまく扱うポイント

シンボリックリンクについて

ファイルやディレクトリを読み書きする権限を与えても、そのパスがシンボリックリンクだと正常に許可されないケースがあります。

具体的には、プログラム実行中にシンボリックリンクの指し先が変わるケースです。例えばシンボリックリンクのあるファイル /path/to/symlink への読み込みを許可するアプリケーションがあるとします。そのシンボリックリンクの指し先がアプリケーション実行中に変わると、同じ /path/to/symlink の読み込みをしているにも関わらず権限は許可されません(内部でパス解決のキャッシュが効いているのか、指し先が変わっても最大 30 秒程度は許可されます)。

この現象は前述した短絡評価と同じようなコードを挿入することで解決できます。

外部コマンドの実行について

アプリケーション内から外部コマンドを実行する系がある場合、そのコマンドのパスは絶対パスで記述しましょう。というのも、JSM の実装の都合上、絶対パスでないコマンドを実行する時は 全ファイル の実行権限を許可する必要があるからです。

コマンド実行時に呼び出される java.lang.SecurityManager の一部を転載します(整形済)。

public void checkExec(String cmd) {
    File f = new File(cmd);
    if (f.isAbsolute()) {
        checkPermission(new FilePermission(cmd, SecurityConstants.FILE_EXECUTE_ACTION));
    } else {
        checkPermission(new FilePermission("<<ALL FILES>>", SecurityConstants.FILE_EXECUTE_ACTION));
    }
}

ここからわかるように絶対パスでない場合は常に <<ALL FILES>>、すなわち全ファイルの実行権限を要求されます。checkExec メソッドをオーバーライドして回避する方法もありますが、あまりハックらしきことはせず郷に従うほうが得策ではないかと思います。

ForkJoinPool について

通常、JSM を有効にするとどのスレッドでも同じポリシーが適用されるのですが、デフォルトの ForkJoinPool で生成されたスレッドは例外です。

SecurityManagerが存在し、ファクトリが指定されていない場合、デフォルト・プールではファクトリが提供する有効なPermissionsを持たないスレッドが使用されます。 https://docs.oracle.com/javase/jp/8/docs/api/java/util/concurrent/ForkJoinPool.html

ForkJoinPool のスレッドを使う時とは、ForkJoinPool.commonPool() を用いてタスクを処理したり、Stream API で parallelStream() を用いて並列にリスト処理を行う時などです。

これを解決するには下記のように自前で ForkJoinWorkerThreadFactoryForkJoinWorkerThread を定義する必要があります。

public class CybozuForkJoinThreadFactory implements ForkJoinWorkerThreadFactory {
    @Override
    public ForkJoinWorkerThread newThread(ForkJoinPool pool) {
        return new CybozuWorkerThread(pool);
    }
}

public class CybozuWorkerThread extends ForkJoinWorkerThread {
    protected CybozuWorkerThread(ForkJoinPool pool) {
        super(pool);
    }
}

上記クラスを定義した上でシステムプロパティで ThreadFactory を指定すると、意図したとおりのポリシーが用いられるようになります。

System.setProperty("java.util.concurrent.ForkJoinPool.common.threadFactory",
                      "com.cybozu.common.concurrent.CybozuForkJoinThreadFactory");

終わりに

JSM を用いるとアプリケーションの意図しない挙動を防ぐことができるので、よりセキュアにアプリケーションを運用することができます。万が一リモートコード実行のような脆弱性が見つかっても、 JSM が有効な範疇でコードが実行されるなら被害を最小限に留めることが出来るかも知れません。

とは言え、JSM を有効にしたからといって安心安全というわけでは決してありません。人間が作るものなので完全なポリシーを作ることなどきっと不可能でしょう。そうでなくとも、サーバーサイドで JSM を有効にしたとしても、 例えばエスケープ漏れによる SQL Injection や XSS などの脆弱性を防げるようになるわけではありません。JSM は数ある脅威のうちのほんのごく一部を防げるだけに過ぎず、過信してはならないことを肝に命じた上で JSM を扱いましょう。

参考

Javaセキュリティ・アーキテクチャ