内部クラスの逆コンパイル失敗パターン

内部クラス(Inner クラス)は外部クラスへの参照を持つため、 単体で逆コンパイルすると外部クラス情報が見つからず警告が出ることがある。

失敗パターン — 外部クラスが見つからない場合
// CFR の出力に含まれる警告コメント
/*
 * Unable to fully structure code
 * Reason: Outer class not found
 */
class Service$Builder {
    /* synthetic */ final /* synthetic */ Service this$0;   // 外部クラス参照が不明
    ...

対処:外部クラスと内部クラスを一緒に逆コンパイルする。

Shell — 外部クラスと内部クラスをまとめて処理
# NG:内部クラスのみ指定
java -jar cfr.jar Service\$Builder.class

# OK:ディレクトリごと指定(外部クラスが同じ場所にあれば自動解決)
java -jar cfr.jar ./com/example/ --outputdir decompiled/

# または JAR を直接指定(最も確実)
java -jar cfr.jar myapp.jar --outputdir decompiled/

ラムダ式・ストリームの読み方

Java 8 以降のラムダ式は invokedynamic という JVM 命令として実装されており、 ツールによって復元方法が異なる。Procyon が最も元のラムダ形式に近い。

Java — 元のソース(ラムダ + ストリーム)
// 元のソース
List<String> result = list.stream()
    .filter(s -> s.startsWith("A"))
    .map(String::toUpperCase)
    .collect(Collectors.toList());
CFR による復元(良好)
List<String> result = list.stream()
    .filter(s -> s.startsWith("A"))
    .map(String::toUpperCase)
    .collect(Collectors.toList());
古いツール / 難しいケースでの復元(可読性が落ちる)
// ラムダが内部クラスとして展開される場合
List<String> result = list.stream()
    .filter(new Predicate<String>() {
        @Override
        public boolean test(String s) {
            return s.startsWith("A");
        }
    })
    .map(new Function<String, String>() {
        @Override
        public String apply(String s) {
            return s.toUpperCase();
        }
    })
    .collect(Collectors.toList());

どちらの形式でも意味は同じなので、読む際は「何をフィルタして何に変換しているか」に注目すれば理解できる。

難読化(ProGuard)とは

ProGuard(および後継の R8)はクラス名・メソッド名・フィールド名を短縮文字列(a/b/c)に置換し、 デッドコードを除去することで逆コンパイル後の可読性を意図的に下げるツールだ。 Android アプリのリリースビルドで標準的に使用される。

難読化後の逆コンパイル例
// 元のコード(難読化前)
public class UserService {
    public User findUserById(long id) {
        return userRepository.findById(id);
    }
}

// 難読化後(ProGuard適用)
public class a {
    public b c(long d) {
        return this.e.f(d);
    }
}

難読化の有無を判定する

Shell — 難読化判定
# 1. クラス一覧から判定(1文字クラス名が多ければ難読化されている)
jar tf myapp.jar | grep -E "^[a-z]/[a-z]\.class" | wc -l

# 2. javap でクラス構造を確認
javap -p extracted/a.class

# 3. jadx-gui で開いて確認
# → クラス名が 1〜2 文字の短縮名ならほぼ確実に難読化

# 4. META-INF/proguard/ ディレクトリの存在確認
jar tf myapp.jar | grep -i proguard
特徴難読化なし難読化あり
クラス名意味のある名前(UserService1〜2文字(a/ab
パッケージ構造深い階層(com.example.serviceフラット化(a.b
メソッド数設計どおりデッドコード除去で減少
文字列定数そのまま残る一部は暗号化されることもある

難読化コードを部分的に読む技術

名前が読めなくても、型情報・構造・文字列定数・外部 API の呼び出しは残っていることが多い。 これらを手がかりに意味を推測できる。

① マッピングファイルを使う

ProGuard はビルド時に mapping.txt(難読化前後の対応表)を出力する。 これが入手できれば一括でリネームが可能だ。

Shell — ProGuard mapping.txt でリネーム
# ProGuard の retrace ツールでスタックトレースを元の名前に戻す
java -jar retrace.jar mapping.txt obfuscated_stacktrace.txt

# jadx にマッピングファイルを渡す
./bin/jadx -d output/ --show-bad-code myapp.jar
# jadx-gui: File → Load Proguard mapping → mapping.txt を選択

② 型・構造から意味を推測する

読解例 — 難読化クラスの推測
// 難読化後のコード
public class a {
    private b c;          // b 型のフィールド
    private String d;     // String なのでそのまま
    private long e;

    public a(b var1, String var2, long var3) {
        this.c = var1;
        this.d = var2;
        this.e = var3;
    }

    public String f() {
        return this.d;   // String を返す getter
    }

    public long g() {
        return this.e;   // long を返す getter
    }
}

// 推測:String フィールド d を返す getter f() → 名前系の情報(name? email?)
//       long フィールド e を返す getter g()   → ID か timestamp?
// → 使われ方をたどることで目的を特定していく

完全復元が不可能なケースと対処

状況理由現実的な対処
文字列が暗号化されている 難読化ツールが文字列をバイト配列に変換 実行時にデバッガでメモリをダンプして確認
制御フローが難読化されている ジャンプ命令の大量挿入でフロー解読が困難 実行トレース(JVMTI)で動的解析
ネイティブコード(JNI) C/C++ の .so / .dll に処理が逃げている Java 部分は逆コンパイル可能。ネイティブは別途解析
カスタムクラスローダーで動的ロード 実行時まで .class が復号されない 実行中に Java エージェントでクラス定義を傍受

⚠️ 難読化解除の限界を認識する

ProGuard 等の難読化は「完全に読めなくする」ではなく「読むコストを上げる」ものだ。 型情報・定数・外部 API 呼び出しは残るため、ビジネスロジックの大枠を把握することは多くの場合可能。 しかし完全なソース復元を期待するのは非現実的であり、その用途であれば元の開発者にコンタクトすることが先決だ。

次の PART では…

PART 07 では IntelliJ IDEA と VS Code を使って、逆コンパイルしたソースを IDE に組み込んで効率よく読む方法を解説する。

→ PART 07 — IDE で活用するへ