Proguardの難読化結果をすぐに確認する

Proguardは、Javaプログラムのコンパイル結果である.classの集合体.jarファイルの中身を難読化してくれるため、プログラムコードにあるノウハウを盗まれたく無い場合には、Javaプログラムにとって必須のツールと言えるのだが、しかし初心者にはかなり敷居が高いのも事実である。

おそらく最初にやらかすことは、「何も考えずに難読化」してみて、「全く動作しない」という状況だろう。やり方を習得して賢く難読化の方法を習得しても、動かしてみると失敗していることがわかる。これを何度となく繰り返し、やっと意図どおりの難読化にたどりつくわけだ。

この中心部分にある問題点は、Proguardの設定がややこしいことと、それによる難読化結果が成功しているかどうかは「動かしてみないとわからない」点にある。

素早く確認するためのアイデア

この問題の解決をいくらかでも楽にするには、次を行えば良いのである。

  1. proguardで難読化する
  2. 生成されたjarファイルを調査し、所望の難読化であるかを確認するのだが、これを半自動で行う。

いちいちアプリを起動しなくとも、少なくともproguardの難読化設定は正しいことが確認できる。

確認するための方策1

さて、生成物であるjarファイルについて、これをどうやって確認するかだが、例えば、次のクラス名のkeepは必要無いが、フィールド名はkeepしたいとする。@KeepFieldはそれを指示するためのアノテーションであるとする。

package foobar;
public class Sample {
  @KeepField
  public int field;
}

難読化した結果、foobar.Sampleクラスのfieldフィールドがkeepされていることを確認しようとしても、foobar.Sampleのクラス名が変更されてしまっているので見つからない。

Sampleを探し出すには、proguardが出力するマッピングファイルを使い、元のSampleという名前から変更された名前を探し出さなければならないのだ。そのマップからクラス名がa.bなどという名前に変更されていることがわかり、a.bクラスをロードし、そのフィールドとしてfieldが存在するかを確認することになる。

これはいかにも面倒だ。もっと簡単な方法は無いものか。

確認するための方策2

難読化が意図通りに起こっているのかを知りたいのだから、そのためのテスト用のクラスを作ってしまえば良いのである。つまり、製品のアプリでは使用しないようなテスト用クラスを追加し、そこにあらゆる難読化のテストコードを入れこんでしまい、生成結果のjarを読み出し、所望の難読化結果であるかを確認すればよい。

※ただし、proguardで最適化を行うと、使用されていないクラスが除去されてしまうことがある点には注意すること。

難読化されたjarファイルをロードするためのクラス

以下に難読化されたjar(というよりもどんなjarにも使用できるが)をロードするためのクラスを示す。単純に調査対象である難読化されたjarと、その実行に必要なライブラリjarがあれば、その格納フォルダを合わせて指定するだけである。


import java.io.*; import java.net.*; import java.util.*; import java.util.stream.*; import java.util.zip.*; /** * 難読化されたjarファイルをチェックするための情報 */ public class ObJarInfo { /** 調査対象Jarファイル中のクラスのロードに使用するクラスローダ */ final LocalClassLoader classLoader; /** 調査対象Jarファイル中のすべてのクラス名称と上記クラスローダでロードされたクラス */ final Map<String, Class<?>>classes = new HashMap<>(); /** 調査対象Jarファイル中のすべてのリソース一覧 */ final List<String>resources = new ArrayList<String>(); /** jarファイルを読み込み、クラスをロードする */ public ObJarInfo(File jarFile, File...libDirs) throws IOException { // クラスローダを作成する classLoader = new LocalClassLoader(jarFile, libDirs); // 対象jarファイルを調べ、そのすべてのクラスをロードする。 try (ZipFile inZip = new ZipFile(jarFile)) { inZip.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> { String name = entry.getName().replaceAll("\\/", "."); if (!name.endsWith(".class")) { resources.add(name); return; } name = name.substring(0, name.length() - 6); try { classes.put(name, classLoader.loadClass(name)); } catch (ClassNotFoundException ex) { System.err.println("class not found " + name); } }); } } /** * クラスローダ。システムクラスローダを継承しないことに注意。 * このクラスローダは、現在実行中のプログラムとは無関係で、独立している。 */ public static class LocalClassLoader extends URLClassLoader { /** * 調査対象の単一のjarファイルと、その実行に必要な複数のライブラリjarファイルの格納された複数のフォルダを指定する * @param jarFile 調査対象jarファイル * @param libDirs 上記の実行に必要なライブラリjarが格納された複数のフォルダ */ public LocalClassLoader(File jarFile, File...libDirs) { // 親クラスローダなしとする。デフォルトでは現在実行中のプログラムのシステムクラスローダが指定されてしまうので、明示的にnullとする super(getFiles(jarFile, libDirs), null); } /** 対象となるすべてのjarファイルを集め、そのURLの配列を返す */ private static URL[]getFiles(File jarFile, File...libDirs) { List<File>files = new ArrayList<File>(); files.add(jarFile); Arrays.stream(libDirs).forEach(dir->files.addAll(getLibJars(dir))); return files.stream().map(file->{ try { return file.toURI().toURL(); } catch (MalformedURLException e) { throw new RuntimeException(e); } }).collect(Collectors.toList()).toArray(new URL[0]); } /** 指定フォルダ以下のすべてのjarファイルを集めてまわる */ private static Collection<File>getLibJars(File dir) { List<File>files = new ArrayList<File>(); new Object() { void gatherFiles(File parent) { Arrays.stream(parent.listFiles()).forEach(file-> { if (file.isDirectory()) { gatherFiles(file); return; } if (file.getName().endsWith(".jar")) { files.add(file); } }); } }.gatherFiles(dir); return files; } } }

これを以下のように使う。

ObJarInfo objar = new ObJarInfo(new File("sample.jar"), new File("lib"));

存在チェックユーティリティ

チェック項目としては以下である。

  • keep指定したクラス名が存在しているか?
  • keep指定していないクラス名が消えているか?
  • keep指定したフィールド名が存在しているか?
  • keep指定していないフィールド名が消えているか?
  • keep指定したメソッド名が存在しているか?
  • keep指定していないメソッド名が消えているか?

ここで「消えている」の意味は、当然ながら、クラス・フィールド・メソッド自体が消えてしまうわけではなく、別の名前になっているという意味である。proguardにかけると、sampleFieldといった名前がaなどの短い名前になってしまうため、元のsampleFieldという名前が存在しないことを意味する。

上記のチェックを行うためのメソッドを用意する。意図通りでない場合には単純に例外が発生する。

  static Class<?>classExists(ObJarInfo obJar, String name) {
    Class<?>clazz = obJar.classes.get(name);
    if (clazz == null) throw new RuntimeException("class not found:" + name);
    return clazz;
  }

  static void classNotExists(ObJarInfo obJar, String name) {
    if (obJar.classes.get(name) != null) throw new RuntimeException("class found! " + name);
  }

  static void fieldExists(Class<?>clazz, String name) {
    try {
      clazz.getDeclaredField(name);
    } catch (NoSuchFieldException ex) {
      throw new RuntimeException(ex);
    }
  }

  static void fieldNotExists(Class<?>clazz, String name) {
    try {
      clazz.getDeclaredField(name);
      throw new RuntimeException("field exists");
    } catch (NoSuchFieldException ex) {
    }
  }

  static void methodExists(Class<?>clazz, String name, Class<?>...params) {
    try {
      clazz.getDeclaredMethod(name, params);
    } catch (NoSuchMethodException ex) {
      throw new RuntimeException(ex);
    }
  }

  static void methodNotExists(Class<?>clazz, String name, Class<?>...params) {
    try {
      clazz.getDeclaredMethod(name, params);
      throw new RuntimeException("method exists");
    } catch (NoSuchMethodException ex) {
    }
  }

keep用のアノテーションとgradleの設定

以下にproguardのサンプル設定を示す。基本的には、アノテーションでkeep指定を行う。以下の機能である。

  • @KeepField フィールドをkeepする
  • @SerializedData Javaシリアライゼーションに必要なすべてのkeepを行う。ただし、本来はクラスのkeepもしなければならないのだが、別投稿で示したように、ここではクラスのkeepをする必要がない。クラス名は変更されうる。
  • @DontMove クラス名のkeepのみ行う。
package sample.util;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface KeepField { 
}
.............
package sample.util;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface SerializedData {
}
............
package sample.util;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DontMove {
}

gradleでのproguardの設定は以下。

※注意:これはgradle用のproguardプラグインの設定であり、設定ファイルを使う場合は読み替えが必要。

  keep '@sample.util.DontMove class *'
  keepclassmembers 'class * {\
    @sample.util.KeepField\
    <fields>;\
  }'
  keepclassmembers '@sample.util.SerializedData class * {\
    static final long serialVersionUID;\
    static final java.io.ObjectStreamField[] serialPersistentFields;\
    !static !transient <fields>;\
    private void writeObject(java.io.ObjectOutputStream);\
    private void readObject(java.io.ObjectInputStream);\
    java.lang.Object writeReplace();\
    java.lang.Object readResolve();\
  }'

サンプルクラス

以下のクラスを製品のコードに含めておく、もちろんこれは何の用途にも使用しないので、好きなようにkeep設定を行うことができる。

package sample.util;

@DontMove
public class TestOfKeep {

  @KeepField
  public int field1 = 123;
  public int field2 = 456;

  @DontMove
  @SerializedData
  public static class Serialized1 {    
    public int field;
    public transient int transient_field;
    public static int static_field;
    public void method() {}
  }

  @SerializedData
  public static class Serialized2 {    
    public int field;
    public transient int transient_field;
    public static int static_field;
    public void method() {}
  }
}

チェック

以上でチェックの手はずが整ったので、実際のチェックを行う。

    ObJarInfo objar = new ObJarInfo(new File("sample.jar"), new File("lib"));

    {
      Class<?>c = this.classExists(objar, "sample.util.TestOfKeep"); // このクラス名は元のまま
      fieldExists(c, "field1"); // このフィールドは存在するはず
      fieldNotExists(c, "field2"); // このフィールドは名前が変更されてるはず
    }

    {
      Class<?>c = classExists(objar, "sample.util.TestOfKeep$Serialized1"); // このクラス名は元のまま
      fieldExists(c, "field"); // このフィールドは存在するはず
      fieldNotExists(c, "transient_field");
      fieldNotExists(c,  "static_field");
      methodNotExists(c, "method");
      classNotExists(objar, "sample.util.TestOfKeep$Serialized2"); // このクラスは見つからない
    }