ファイナライザの脆弱性

項目7でファイナライザの挙動を調べてて、以下の記事を見つけた。

IBM developerWorks ヒント: ファイナライザーによる脆弱性からコードを保護する

どうやら Java のファイナライザには脆弱性があるらしい。

ファイナライザの脆弱性

項目7で書いたように、ファイナライザは即時性がなく、あとからシステムが任意のタイミングで呼び出すが、このときに this を用いると自身のインスタンスを参照できるらしい。

public class Zombie {
    static Zombie zombie;
    public void finalize() {
        // static フィールドに自身のインスタンスを設定。
        // この後、Zombie.zombie でインスタンスを参照できる。
        zombie = this;
    }
}

これがなぜ問題になるかというと、以下のようにコンストラクタで引数チェックを行って、例外を発生させ、インスタンスを作成しなかったつもりになっている場合にも参照できてしまうからだ。

public class Zombie {
    static Zombie zombie;
    int value;
    public Zombie(int value) {
        // 負の value を持つ Zombie インスタンスは生成させない
        if (value < 0)
            throw new IllegalArgumentException("'The value should not negative.");
    } 
    public void finalize() {
        // static フィールドに自身のインスタンスを設定
        // この後、Zombie.zombie でインスタンスを参照できる。
        zombie = this;
    }
}

脆弱性を利用した攻撃

例えば以下のように、コンストラクタパーミッションをチェックしたあと、インスタンスメソッド経由でファイルに何かを書き込むクラスがあるとする。

public class SecureFileAccess {
    public SecureFileAccess () {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            FilePermission fp = new FilePermission("index", "write");
            // パーミッションがなければ例外が発生する
            sm.checkPermission(fp);
        }
    }

    public void writeFile() {
        // ファイルの書込み処理
        System.out.print("call write file");
    }
}

このクラスを以下のように拡張し、finalize をオーバーライドすると、パーミッションチェックに失敗したインスタンスに対して、writeFile を呼び出すことができてしまう。

public class AttackSecureFileAccess extends SecureFileAccess {
    public static AttackSecureFileAccess obj;

    @Override
    public void finalize() throws Throwable {
        super.finalize();
        obj = this;
    }

    public static void main(String[] args) {
        try {
            new AttackSecureFileAccess();
        } catch (Exception e) {
            System.out.println(e);
        }
      
        // ファイナライザの実行を促す。ファイナライザの実行を保証はできない。項目7を参照。
        System.gc();
        System.runFinalization();

        if (obj != null) {
            obj.writeFile();
        } else {
            System.out.println("Attack Failed");
        }
    }
}

実行結果は以下の通り

java.security.AccessControlException: access denied ("java.io.FilePermission" "index" "write")
call write file

パーミッションがないため、例外が発生し、SecureFileAccess は正しい状態でインスタンスは生成されない。 ただし、ファイナライザの脆弱性が利用され、SecureFileAccess のインスタンスの writeFile が呼び出せてしまっている。

攻撃を防ぐ

Java 5 までは、この攻撃を防ぐためには以下の方法があった。(個々の説明は割愛)

  • initialized フラグを利用してオブジェクトが適切に初期化されたことをチェックする
  • サブクラス化を防ぎ、不正なファイナライザを定義できないようにする
  • final のファイナライザを定義する

しかし、これらの対処法はいずれも欠点があり満足できる方法ではなかった。

Java 6 においては仕様が変更され、java.lang.Object が生成される前に例外がスローされた場合、そのインスタンスのファイナライザは呼び出されなくなった。

12.6.1. Implementing Finalization

An object o is not finalizable until its constructor has invoked the constructor for Object on o and that invocation has completed successfully (that is, without throwing an exception).

しかし、java.lang.Object が作成される前に例外をスローするのは実は難しい。 実際、上述した AttackSecureFileAccess の場合でも java.lang.Object は生成されており、Java7 においても "call write file" が表示される。

java.lang.Object が生成される前に例外をスローするためには、コンストラクタへのパラメータが他のコードよりもはやく処理されることを利用する。

public class RobustSecureFileAccess {
    public RobustSecureFileAccess () {
        // セキュリティチェック用コンストラクタのパラメータとして、パーミッションチェックを与える
        this(checkPermission());
    }

    // セキュリティチェック用コンストラクタ
    private RobustSecureFileAccess(Void secureCheck) {
    }
    
    // パーミッションチェックメソッド
    private static Void checkPermission() {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            FilePermission fp = new FilePermission("index", "write");
            // パーミッションがなければ例外が発生する
            sm.checkPermission(fp);
        }
        return null;
    }

    public void writeFile() {
        /* ファイルの書込み処理 */
        System.out.print("call write file");
    }
}

このようにすると、java.lang.Object のコンストラクタが呼ばれる前に checkPermission() を実行することができる。 そして、セキュリティチェックが失敗した場合には例外がスローされ、java.lang.Object は生成されず、ファイナライザは呼ばれないため、攻撃は失敗に終わる。

public class AttackSecureFileAccess extends RobustSecureFileAccess {
    public static AttackSecureFileAccess obj;

    @Override
    public void finalize() throws Throwable {
        super.finalize();
        obj = this;
    }

    public static void main(String[] args) {
        try {
            new AttackSecureFileAccess();
        } catch (Exception e) {
            System.out.println(e);
        }
        System.gc();
        System.runFinalization();
        if (obj != null) {
            obj.writeFile();
        } else {
            System.out.println("Attack Failed");
        }
    }
}

このコードの Java 7 での実行結果は以下の通り。

java.security.AccessControlException: access denied ("java.io.FilePermission" "index" "write")
Attack Failed

感想

この件は読んでておもしろかったのでまとめてみた。 いやー、ほんと攻撃者ってこういうのよく見つけるよねーって感じ。

ただ、実際、この脆弱性防御法が使われてるのみたことないなぁ、、、。 まぁガチなセキュリティプログラミングなんて仕事でも趣味でもやったことないからだろうけど。

ニュースとかでよく「〇〇で致命的な脆弱性発見!」ってニュース見ると、なんだか一瞬は他人事な感じなんだけど、よくよく調べると普段やっちゃうあるあるなプログラミングミスが原因だったりして、こういう脆弱性の詳細を追うのはけっこう楽しい。 けど、だいたいの場合、細かい話が入り組んでいて、理解するのはすごく時間がかかる。 その点、この脆弱性は分かりやすくまとめやすかった。