見出し画像

Javaの難デコンパイル化用ClassLoader

Javaは簡単にデコンパイルできるから簡単にノウハウが流出してしまう・・・みたいな話を聞かなくなって久しいですね。対策としては難読化やAOTコンパイラによるネイティブコードへの変換があるわけですが、今回は難デコンパイルの実装コードを実用レベルに近い形で公開しちゃいます。

難デコンパイルを実現する製品について軽く調べてみたところ、ClassGuardが現在もメンテナンスされているようです。今回公開させていただくのはClassGuardほど完全なものではありませんが、ClassLoader自作のノウハウについて全公開するつもりですので、最後までお付き合いいただけますと幸いです。

メリット・デメリット

実現方法を公開する前に、私が感じている難デコンパイルのメリット・デメリットについて触れておきます。

vs 難読化

難読化と比較した場合、まず外せないのがスタックトレースの読みやすさです。難読化しているので当たり前なのですが、スタックトレースのクラス名、メソッド名がそのままでは役に立ちません。一方、難デコンパイルはクラスバイナリを変換しているだけで、クラス名、メソッド名には手が加えられていません。環境さえ整えれば、難デコンパイル化した状態で元のソースコードと組み合わせたデバッグ実行までできてしまいます。
また、リフレクションとの相性も良いです。文字列でクラス名を指定してロードするようなケースでも問題なく動作します。
デメリットは使用されるClassLoaderを意識する必要がある点です。専用のClassLoaderを経由せずにロードされたクラスから難デコンパイル化されたクラスをロードしようとするとClassNotFoundExceptionに悩まされることになります。そういったケースではClassLoaderを指定する仕組みが提供されているなど、回避策があることがほとんどで、致命的な問題になったことは一度もありません。

vs AOTコンパイル

AOTコンパイルと比較した場合、OSやCPUアーキテクチャの縛りがないことがメリットです。対応するJavaVMさえあれば特別なことをしなくてもそのまま動作します。
デメリットは起動速度の差でしょうか。難デコンパイル化していなくても差が出るものですが、難デコンパイル化されている場合は、クラスロードにオーバーヘッドが発生するため、余計に差が生じてしまいます。
ClassLoaderが解析のウイークポイントになってしまうところもAOTコンパイルに比べて劣る点です。

実現方法

基本的にClassGuardと同じです。事前にJavaのClassファイルを暗号化しておき、専用のClassLoaderを使用して復号化しながらロードします。復号化の部分をネイティブコードで実装しているのかが主な違いになります。
また、ClassLoaderの実装をメイン題材としているため、鍵管理についてはサンプルとして動作する必要最低限に留めています。

前準備

前準備として、鍵の生成とClassファイルを暗号化するツールを作成します。

鍵の生成

Classファイルの暗号化にはAESを使用しますので、事前に鍵を生成しておきます。
今回は鍵生成用のコードも作成しました。

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;

public class AESKeyGenerator {

    public static void main(String... args) throws Exception {
        KeyGenerator generator = KeyGenerator.getInstance("AES");
        generator.init(128);
        SecretKey secretKey = generator.generateKey();

        System.out.println("Key: " + encodeSecretKey(secretKey));
        System.out.println("IV: " + encodeSecretIV(secretKey));
    }

    private static String encodeSecretKey(SecretKey secretKey) throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        bout.write(secretKey.getEncoded());

        Base64.Encoder encoder = Base64.getEncoder();
        return encoder.encodeToString(bout.toByteArray());
    }

    private static String encodeSecretIV(SecretKey secretKey) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);

        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        bout.write(cipher.getIV());

        Base64.Encoder encoder = Base64.getEncoder();
        return encoder.encodeToString(bout.toByteArray());
    }
}

実行すると以下のようにKeyとIVが出力されますので、後で利用するためにメモしておきます。

Key: Y/ihgWY/5H2Q4zEFM1QPXQ==
IV: 1/Sty8ad5+Yrzx/w

Classファイルの暗号化

Classファイル暗号化用のツールも作成します。
先ほどメモしたKey、IVとコンパイル済みクラスのあるフォルダを指定して実行することでClassファイルを暗号化します。

import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.security.spec.AlgorithmParameterSpec;
import java.util.Base64;
import java.util.zip.GZIPOutputStream;

import static javax.crypto.Cipher.ENCRYPT_MODE;

public class ClassEncryptionTool {

    private static final FileFilter CLASS_OR_DIRECTORY_FILTER =
            path -> path.isDirectory() || path.getName().endsWith(".class");

    /**
     * @param args
     *      [0]     Key
     *      [1]     IV
     *      [3]     暗号化対象のフォルダ or ファイルのパス
     */
    public static void main(String[] args) throws Exception {
        String encodedKey = args[0];
        String encodedIV = args[1];
        File file = new File(args[2]);
        new ClassEncryptionTool(encodedKey, encodedIV).encrypt(file);
    }

    private final SecretKey key;
    private final AlgorithmParameterSpec spec;

    public ClassEncryptionTool(String encodedKey, String encodedIV) {
        Base64.Decoder decoder = Base64.getDecoder();
        key = new SecretKeySpec(decoder.decode(encodedKey), "AES");
        spec = new GCMParameterSpec(16 * 8, decoder.decode(encodedIV));
    }

    public void encrypt(File file) throws Exception {
        if (file.isDirectory()) {
            encryptDir(file);
        }
        else {
            encryptFile(file);
        }
    }

    private void encryptDir(File dir) throws Exception {
        File[] files = dir.listFiles(CLASS_OR_DIRECTORY_FILTER);
        if (files != null) {
            for (File file : files) {
                encrypt(file);
            }
        }
    }

    private void encryptFile(File file) throws Exception {
        String name = file.getName();
        int end = name.lastIndexOf('.');
        if (end != -1) {
            name = name.substring(0, end);
        }
        name += ".clazz";

        File outFile = new File(file.getParentFile(), name);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(ENCRYPT_MODE, key, spec);

        try (
                InputStream in = new BufferedInputStream(new FileInputStream(file));
                OutputStream out = new GZIPOutputStream(
                        new CipherOutputStream(
                                new BufferedOutputStream(
                                        new FileOutputStream(outFile)), cipher), 8192)
        ) {

            int len;
            byte[] buf = new byte[8192];
            while ((len = in.read(buf)) != -1) {
                out.write(buf, 0, len);
            }
            out.flush();
        }

        outFile.setLastModified(file.lastModified());
    }
}

生成された暗号化済みClassファイルを格納したJARを作成すれば準備は完了となります。ライブラリとしてJARを提供する場合は、コンパイル用に通常のClassファイルを格納したJARも必要となります。

  • 暗号化してあるクラスを識別できるように、拡張子は.classの代わりに.clazzを使用しています。

  • 暗号化前に圧縮しているのは、暗号化によりJARを作成する際に圧縮効率が落ちる問題への対策です。サイズを気にしない場合は省いてしまって大丈夫です。

復号化用のClassLoader

暗号化したClassファイルをロードするためのClassLoaderを作成します。

import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.*;
import java.security.spec.AlgorithmParameterSpec;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.jar.Manifest;
import java.util.zip.GZIPInputStream;

import static javax.crypto.Cipher.DECRYPT_MODE;

public class DecryptClassLoader extends URLClassLoader {

    static {
        ClassLoader.registerAsParallelCapable();
    }

    private final ClassLoader parent;

    private final SecretKey key;
    private final AlgorithmParameterSpec spec;

    private final ConcurrentMap<String, String> notFound = new ConcurrentHashMap<>();

    public DecryptClassLoader(String encodedKey, String encodedIV) {
        super(new URL[0]);
        this.parent = getParent();

        Base64.Decoder decoder = Base64.getDecoder();
        key = new SecretKeySpec(decoder.decode(encodedKey), "AES");
        spec = new GCMParameterSpec(16 * 8, decoder.decode(encodedIV));
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

        synchronized (getClassLoadingLock(name)) {
            Class<?> clazz = findLoadedClass(name);
            if (clazz == null) {
                // 暗号化されたClassを最優先で探す
                 clazz = findClass(name);
            }
            if (clazz == null) {
                // 見つからない場合は上位のClassLoaderを使用する
                clazz = parent.loadClass(name);
            }

            if (resolve) {
                resolveClass(clazz);
            }
            return clazz;
        }
    }

    @Override
    protected Class<?> findClass(String name) {
        if (name.startsWith("java")) {
            // java or javax
            return null;
        }
        if (notFound.containsKey(name)) {
            return null;
        }

        String path = name.replace('.', '/');
        URL url = parent.getResource(path + ".clazz");

        if (url == null) {
            notFound.put(name, "");
            return null;
        }

        definePackage(name, url);

        Cipher cipher;
        try {
            cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(DECRYPT_MODE, key, spec);
        }
        catch (Exception e) {
            e.printStackTrace();
            return null;
        }

        try (
                InputStream in = new GZIPInputStream(
                        new CipherInputStream(
                                new BufferedInputStream(url.openStream()), cipher), 8192);
                ByteArrayOutputStream out = new ByteArrayOutputStream(8192)
        ) {

            int len;
            byte[] buf = new byte[8192];
            while ((len = in.read(buf)) != -1) {
                out.write(buf, 0, len);
            }

            byte[] bytesClazz = out.toByteArray();
            CodeSource codeSource = new CodeSource(url, (CodeSigner[]) null);
            return defineClass(name, bytesClazz, 0, bytesClazz.length, codeSource);
        }
        catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    @SuppressWarnings("deprecation")
    private void definePackage(String name, URL url) {
        int end = name.lastIndexOf('.');
        if (end == -1) {
            return;
        }

        String pkgName = name.substring(0, end);
        if (getPackage(pkgName) != null) {
            return;
        }

        Manifest manifest = null;
        if (url.getProtocol().equalsIgnoreCase("jar")) {
            try {
                JarURLConnection jarConnection = (JarURLConnection) url.openConnection();
                manifest = jarConnection.getManifest();
            }
            catch (IOException e) {
                e.printStackTrace();
            }
        }

        try {
            if (manifest != null) {
                definePackage(pkgName, manifest, url);
            }
            else {
                definePackage(pkgName, null, null, null, null, null, null, null);
            }
        }
        catch (IllegalArgumentException e) {
            if (getPackage(pkgName) != null) {
                // パッケージの定義を複数スレッドで同時に実行した場合なので、そのまま続行
            }
            else {
                throw e;
            }
        }
    }
}

継承元Class

継承元に使うのはURLClassLoaderです。URLClassLoaderは起動時に設定されたClassPath以外からClassをロードできるので、プラグインを実装する際によく使いますが、今回はURLClassLoaderとしての機能はほぼ封印しています。definePackageを使うために継承しているだけですので、不要な場合はSecureClassLoaderに切り替えることができます。

public class DecryptClassLoader extends URLClassLoader {
}

ロード時の排他ロックを最小化

以下のメソッドを実行しておくことで、getClassLoadingLockが返すオブジェクトが変わります。通常はClassLoaderのインスタンス単位なのですが、Class名単位に変えることで排他ロックの範囲を最小化します。

    static {
        ClassLoader.registerAsParallelCapable();
    }

Classのロード順を入れ替え

通常は上位のClassLoaderから優先してClassを探しますが、暗号化されたClassを優先的に探すように変更してあります。これによりclassとclazzがJARに混じっている場合でもclazzが優先的のロードされます。

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

        synchronized (getClassLoadingLock(name)) {
            Class<?> clazz = findLoadedClass(name);
            if (clazz == null) {
                // 暗号化されたClassを最優先で探す
                 clazz = findClass(name);
            }
            if (clazz == null) {
                // 見つからない場合は上位のClassLoaderを使用する
                clazz = parent.loadClass(name);
            }

            if (resolve) {
                resolveClass(clazz);
            }
            return clazz;
        }
    }

対象外のClassを除外

標準ライブラリや存在しないことがわかっているClassは処理しないようにしています。

        if (name.startsWith("java")) {
            // java or javax
            return null;
        }
        if (notFound.containsKey(name)) {
            return null;
        }

暗号化されたClassファイルの取得

暗号化されたClassファイルの取得には、上位のClassLoaderを使用します。
これにより設定されたClassPathからClassをロードすることが可能になります。

        String path = name.replace('.', '/');
        URL url = parent.getResource(path + ".clazz");

        if (url == null) {
            notFound.put(name, "");
            return null;
        }

Classへの変換

復号化したClassのバイナリデータをClassに変換するにはdefineClassを使用します。

            byte[] bytesClazz = out.toByteArray();
            CodeSource codeSource = new CodeSource(url, (CodeSigner[]) null);
            return defineClass(name, bytesClazz, 0, bytesClazz.length, codeSource);

Manifestの登録

definePackageはManifestを使用する場合に必要となります。
ロード元のファイルがJARの場合に限定して対応しています。

        Manifest manifest = null;
        if (url.getProtocol().equalsIgnoreCase("jar")) {
            try {
                JarURLConnection jarConnection = (JarURLConnection) url.openConnection();
                manifest = jarConnection.getManifest();
            }
            catch (IOException e) {
                e.printStackTrace();
            }
        }

        try {
            if (manifest != null) {
                definePackage(pkgName, manifest, url);
            }
            else {
                definePackage(pkgName, null, null, null, null, null, null, null);
            }
        }
        catch (IllegalArgumentException e) {
            if (getPackage(pkgName) != null) {
                // パッケージの定義を複数スレッドで同時に実行した場合なので、そのまま続行
            }
            else {
                throw e;
            }
        }

実行

ClassLoaderだけでは扱いにくいので、暗号化されたClassを簡単に実行するためのClassも作成しました。
Key、IVと暗号化された実行対象のClass、引数を指定して実行することで暗号化されたClassを実行することができます。

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.AccessControlException;

public class DecryptMain {

    public static ClassLoader DEFAULT_CLASS_LOADER;

    /**
     * @param args
     *      [0]     Key
     *      [1]     IV
     *      [2]     実行クラス名
     *      [3...n] 実行クラスに渡す引数
     */
    public static void main(String...args)
            throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        if (args.length < 3) {
            throw new IllegalArgumentException();
        }

        String encodedKey = args[0];
        String encodedIV = args[1];
        String className = args[2];

        String[] original = args;

        int size = args.length - 3;
        args = new String[size];
        if (size > 0) {
            System.arraycopy(original, 3, args, 0, size);
        }

        try {
            DEFAULT_CLASS_LOADER = new DecryptClassLoader(encodedKey, encodedIV);
            Class<?> clazz = DEFAULT_CLASS_LOADER.loadClass(className);
            Method method = clazz.getMethod("main", args.getClass());
            method.invoke(clazz, (Object) args);
        }
        catch (ClassNotFoundException | AccessControlException e) {
            System.out.println("java.class.path=" + System.getProperty("java.class.path"));
            System.out.println("protection domain=" + DecryptMain.class.getProtectionDomain());
            throw e;
        }
    }
}

さいごに

最後まで目を通していただきありがとうございます。
難デコンパイル化用ClassLoaderと言いつつ、鍵管理はやっつけなのでそのままでは使い物になりませんが、Java標準のライブラリ以外は一切使用していないので、コピペで簡単に動作させることができます。
ClassLoaderを自作する際のヒントくらいにはなっていると嬉しいです。


この記事が気に入ったらサポートをしてみませんか?