🌊

暗号 DEX 作成のための Android の動的クラスロード入門

2021/10/21に公開

暗号 DEX 作成のための Android の動的クラスロード入門

この原稿は、 DroidKaigi に向けて作成したものの、採択されなかった残念な文章です。 GDG四国 内で発表したことはあるものの、役立つ人はまだいると思いますので、公開します。ただ、情報は Android 4.* 時代のもので、大変古くなっていますので、気を付けて使ってください。特に、ここでサンプルとして使っているコードは、 Android 8.0 以降ではだいぶ手を入れたほうが、セキュリティ上好ましいものになります。

あらすじ

Android は Java 言語で開発できるということは周知と思う。 Java 言語をコンパイルすると、 JavaVM で実行できるバイナリコードである class ファイルが出来上がるが、 Android はこの class ファイルを直接実行することは出来ない。なぜなら、 Android 上で動いている JavaVM は Dalvik と呼ばれる JavaVM を簡易化したものであるためだ。

Dalvik で動作させるためには、 class ファイルをさらに簡素化したコードに直す必要がある。そのため、 Android の開発環境は .class ファイルをさらにコンパイルする。このときに出来上がるのが DEX ファイルであり、 Java で開発した Android アプリに内包されるプログラム本体である。

Java プログラマならご存知の方も多いと思うが、 class ファイルからコンパイル前の Java 文に逆コンパイルすることはある程度可能である。これは DEX ファイルでも同じであり、 DEX ファイルから元の Java プログラムに逆コンパイルすることも同じである。

場合によっては、 DEX ファイルからの逆コンパイルを防ぎたいこともあるだろうし、 DEX ファイルに改変、改造を加えられるのを防ぎたいこともあると思う。この文章では、 DEX ファイルを暗号化するための基礎となる技術である、 DEX ファイルの動的ロードについて記す。なお、この文章では、 DEX ファイルの具体的な暗号化方法や鍵の保管方法については触れない。

Java クラスの動的ロード

DEX ファイルの動的ロードが実現できるようになると、暗号化した DEX ファイルをプログラム本体から分離し、必要なときに復号化して読み込むことが可能になる。この DEX ファイルの動的ロード方法の説明の前に、 Java クラスの動的ロードについて説明を行う。

Java のクラスロードは ClassLoader クラスが行う。この ClassLoader クラスは JavaVM が基本として持っている SystemClassLoader 以外にも Jar ファイルからクラスを読み込める JarClassLoader や、 URL で示したファイルから class ファイルを読み込む URLClassLoader などが幾つか定義されているが、派生クラスを定義してユーザー自身が好みの ClassLoader を作成することも可能である。

百聞は一見にしかず、サンプルを以下に示す。

public class BinaryClassLoader extends ClassLoader {
    private java.util.Map<String, byte[]> class_map;

    public BinaryClassLoader(java.util.Map<String, byte[]> map) {
        this.class_map = map;
    }

    @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        byte[] b = this.class_map.get(name);
        if (b == null) {
            throw new ClassNotFoundException(name);
        }
        return defineClass(name, b, 0, b.length);
    }
}

このサンプルは、クラス名とそのクラスのバイト列が格納された Map を元にした ClassLoader の派生クラスとなっている。肝となるのはオーバーライドされた findClass の中で呼び出している defineClass メソッドである。

このクラスローダーがまだロードしていないクラスを参照する必要が出てきたとき、まず findClass メソッドが呼ばれる。ここで、参照解決出来ないクラスであれば、 ClassNotFoundException を返せばよい。逆に参照できるクラスであった場合、 defineClass メソッドにてクラスのバイト列をクラス名とともに定義して返してやると、その後このクラスローダー経由で該当のクラスを呼び出すことが出来るようになる。

使い方についても触れておこう。以下のように書くと、 com.example.Test クラスの main(String[]) メソッドを、引数 null で呼び出すことが出来る。 binary 変数は動的ロードするクラスファイルのバイナリバイト列である。読者の方で入れてほしいが、手っ取り早いのはコンパイル済みのクラスファイルを読み込んでしまうことである。

public static void main(String argv[]) throws Exception {
    java.util.Map<String, byte[]> map = new java.util.Map<String, byte[]>();
    map.put(com.example.Test, binary);
    ClassLoader loader = new BinaryClassLoader(map);
    Class<?> clazz = loader.findClass(com.example.Test);
    Method method = clazz.getMethod("main", new Class[]{String[].class});

    method.invoke(null, new Object[]{null});
}

これで分かるように、 Java では動的ロードを使用した場合、クラスやメソッドは直接コードの中で参照するのではなく、リフレクション機能を使って呼び出すことになる。

Android での動的ロード

先にも述べたとおり、 Android は JavaVM ではなく Dalvik で動いている。そのため、直接 Java の class ファイルを動的ロードに使うことは出来ない。しかし、 DEX ファイルであれば動的ロードに使えるように API が用意されている。それが DexFile クラスであり、これを自前のクラスローダー内で使用すればよい。

面白いことに、 DexFile を用いてロードしたクラスは、上記の Java での動的ロード時の呼び出しのように、呼び出しにリフレクションを使用する 必要がない 。このため、 Android では動的ロードを用いても、ある程度は既にクラスが定義されていたかのように使用することが出来る。

これを詳しく見ていこう。 DexFile を用いた動的ロードの簡単な使い方は以下である。

DexFile dex_file = DexFile.loadDex(source_dex, dst_path, flags);
dex_file.loadClass(com.example.Test, parent_classloader);

DexFile.loadDex で DexFile オブジェクトの作成を行っている。セキュリティに関係するので触れておくが、 source_dex が元となる dex ファイルのパス、そして dst_path が最適化された DEX ファイルの出力先のパスとなる。一度ファイルシステムに書き出す必要があるため、出力先は該当のアプリケーションしか読み書きできないディレクトリにするなど、十分注意してほしい。 flags は現在は使用されていない。 DexFile オブジェクトの作成には他にも方法があるが、通常はこのメソッドを使うことになる。

次の dex_file.loadClass メソッドにて、 DEX ファイル内にあるファイル filename が自動で読み込まれ、アプリケーション内のクラスローダーにクラスとして定義される。一旦クラスローダー内に定義されると、 “com.example.Test” クラスは以後どこからでも利用できるようになる。つまり、以下のように記述できる。

com.exmaple.Test test = new com.example.Test();

簡単ではあるが、先の Java での動的ロードが理解できたならば、このような記述が出来ることに驚かれる読者もいるかも知れない。しかし、アプリケーション読み込み直後ではまだクラスローダーが外部にあるはずの DEX ファイルを読み込んでいないため、このような記述が出来るのはクラスローダーで DEX ファイルを読み込んだ後である。

これらを踏まえて、汎用的に使えるクラスローダーを作っていこう。以下にサンプルを示す。

    @Override
    protected Class<?> findClass(String className)
            throws ClassNotFoundException {
        try {
            Class<?> clazz = this.parent.loadClass(className);
            return clazz;
        } catch (ClassNotFoundException e) {
            Class<?> clazz = findLoadedClass(className);
            if (clazz != null) {
                return clazz;
            }
            String entry = className.replaceAll("\\.", "/");
            clazz = this.dexFile.loadClass(entry, this.parent);
            if (clazz == null) {
                throw new ClassNotFoundException();
            }
            return clazz;
        }
    }

簡単に流れを追うと、親クラスローダーなどですでに解決済みのクラスであれば、そのクラスを返し、そうでなければ dexFile の loadClass を用いて、クラス定義を行っているだけである。

前半のすでに解決済みのクラスを探すところは理解できると思うが、すでに解決済みのクラスを再度定義しようとするとエラーが起こり失敗することに注意してほしい。

そして、クラス名は “com.example.Test” のように ‘.’ で区切られているので、それを DEX ファイル内部での表現の ‘/’ 区切りに変更し、 “com/example/Test” と変換している。その後、 loadClass メソッドにてクラスの定義を行えば、完了である。

もうひと工夫

以上でクラスローダーが簡単に作成できたが、これだけではまだ問題がある。このままでは突然未解決クラスが現れた際に自動で findClass を呼んでくれない。しかし、読み込むファイルが DEX ファイルであれば、 DEX ファイル内にあるファイル一覧を取得することが可能であるため、予めクラスローダーのコンストラクタにて全部読み込んでしまうことによって解決できる。

それが以下である。単純に DEX ファイル内のすべてのファイルをクラスとして定義しているだけである。標準エラー出力を使用していて行儀が悪いが、基本的には起こるものではないので、読者の方で適宜修正して使用していただきたい。

    public TamDexClassLoader(DexFile dexFile, ClassLoader parent) {
        this.dexFile = dexFile;
        this.parent = parent;

        Enumeration<String> entries = dexFile.entries();
        while (entries.hasMoreElements()) {
            String entry = entries.nextElement();
            Class<?> clazz = null;
            try {
                clazz = loadClass(entry);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
            if (clazz == null) {
                System.err.println("Class not found: " + entry);
            }
        }
    }

また、 Android 5.0 以降では Dalvik ではなく、 ART が採用されている。 ART はアプリケーションの読み込み時に DEX ファイルをネイティブバイナリコードへコンパイルして読み込んで実行してくれるが、驚くことに、 ART 環境下でも DexFile#loadClass はネイティブバイナリコードへコンパイルして動的参照が出来るようにうまく処理してくれる。

ただし、 loadClass で読み込もうとするクラス内にて使用している他のクラスがすべて解決済みでなければならないという制限がある。そのため、 Lollipop 以降では以下のように loadClass の手前で、 TamDexClassLoader 内にて必要なクラスをすべて再帰的に解決するコードが必要になる。

    @Override
    protected Class<?> findClass(String className)
            throws ClassNotFoundException {
        try {
            Class<?> clazz = this.parent.loadClass(className);
            return clazz;
        } catch (ClassNotFoundException e) {
            Class<?> clazz = findLoadedClass(className);
            if (clazz != null) {
                return clazz;
            }
            String entry = className.replaceAll("\\.", "/");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                // for recursive resolve
                clazz = this.dexFile.loadClass(entry, this);
            }
            clazz = this.dexFile.loadClass(entry, this.parent);
            if (clazz == null) {
                throw new ClassNotFoundException();
            }
            return clazz;
        }
    }

Lollipop 移行の ART では、すでに定義されているクラスを再定義しようとすると、エラーが発生してしまうことにも注意して置かなければならない。そのため、このように再帰的にクラスを解決してしまうと、先の DEX ファイルの中にあるすべてのクラスを定義していく箇所で、重複クラスでの再定義エラーが出てしまうことが懸念されるが、そこは findClass メソッド前半にある、すでに定義されていれば再定義せずに返り値を返す部分にて解決する。

こられにより、最終的に完成したクラスローダーは以下のようになる。

final public class TamDexClassLoader extends ClassLoader {
    private DexFile dexFile = null;
    private ClassLoader parent = null;

    public TamDexClassLoader(DexFile dexFile, ClassLoader parent) {
        this.dexFile = dexFile;
        this.parent = parent;

        Enumeration<String> entries = dexFile.entries();
        while (entries.hasMoreElements()) {
            String entry = entries.nextElement();
            Class<?> clazz = null;
            try {
                clazz = loadClass(entry);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
            if (clazz == null) {
                System.err.println("Class not found: " + entry);
            }
        }
    }

    @Override
    protected Class<?> findClass(String className)
            throws ClassNotFoundException {
        try {
            Class<?> clazz = this.parent.loadClass(className);
            return clazz;
        } catch (ClassNotFoundException e) {
            Class<?> clazz = findLoadedClass(className);
            if (clazz != null) {
                return clazz;
            }
            String entry = className.replaceAll("\\.", "/");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                // for recursive resolve
                clazz = this.dexFile.loadClass(entry, this);
            }
            clazz = this.dexFile.loadClass(entry, this.parent);
            if (clazz == null) {
                throw new ClassNotFoundException();
            }
            return clazz;
        }
    }
}

以上。

Discussion