【Java】クラスファイルの内容を確認する方法

Java

Javaのクラスファイル

クラスファイルは、人が読むことを想定していない バイナリファイル です。コンパイルとは非可逆的な処理であるため、クラスファイルのみがまとめられたJARファイルの形式で配布されたライブラリは、そこに格納されたクラスファイルから完全な元のソースファイルを復元することはできません。(オープンソースのライブラリの場合、コンパイルされたクラスファイルとは別にソースファイルが公開されています。例:Apache Commons Lang

しかし逆アセンブルができるツールを使えば、定義されたフィールドやメソッドを確認することができ、さらに逆コンパイルができるツールを使えば、メソッドの定義だけでなくその処理内容も、不完全ながら元のソースコードを復元することにより確認ができます。

このように本来そのままでは人が読むことのできないクラスファイルの内容を確認したい場合、クラスファイルしか手元にない状況下であっても、得たい情報によってはそれが可能なツールを使って取り出すことが可能です。ここでは、逆アセンブルや逆コンパイルによって得られるクラスファイルの情報と、そのために利用できるツールを整理していきます。

逆アセンブラ

逆アセンブラ(逆アセンブルを行うツール)を使うと、ソースコードの復元はできませんが、フィールドやメソッドあるいはそのアクセス修飾子など、クラスに定義されている情報を人が読める形式に変換できます。

javap

javapコマンドJDK に含まれている、クラスファイルの逆アセンブルを行うためのコマンドです。( Descriptionjavap command prints ~ という記述から、print がコマンド名のもとになっているものと予想されます。)

javapコマンドは逆アセンブラであり逆コンパイラではないため、メンバ(フィールドやメソッド)の定義情報を確認することはできますが、そのロジック等メソッド内で行われる処理内容といった中身のソースコードを確認することはできません。しかし 後述 するCFRやJD-GUI等の逆コンパイラと異なり、JDKに同梱されている物であるため、別途ツールをダウンロードする必要がないというメリットがあります。

基本的な使い方は次の通りです。まず事前準備はほとんど必要ありません。javaコマンドが使える環境であれば、多くの場合JDKのbinディレクトリPATHが通っている状態であるため、そのまま javap -version と入力し、エラーにならずバージョン情報が出力されれば既に javapコマンドを使用できる状態になっています。

引数にはクラスファイルのパスや完全修飾クラス名を指定します。デフォルトでは指定されたクラスファイルに定義されたメンバのうち、アクセス修飾子がprivate以外の物だけを出力します。例えば次のようなSampleクラスを逆アセンブルする時、オプションを指定しない場合は次のようになります。

Sample.java
package com.example;

public class Sample {

    private int value;

    public Sample(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) throws Exception {

        if (value < 0) {
            throw new Exception("Value must not be negative.");
        }

        this.value = value;
    }
}
# Sample.java をコンパイルして com\example\Sample.class を作成
> javac com\example\Sample.java

# PATHが通っており javap コマンドが使用可能であることを確認
> javap -version
21.0.6

# javap で逆アセンブル(クラスファイルのパスを指定)
> javap com\example\Sample.class
Compiled from "Sample.java"
public class com.example.Sample {
  public com.example.Sample(int);
  public int getValue();
  public void setValue(int) throws java.lang.Exception;
}

# javap で逆アセンブル(完全修飾クラス名を指定)
> javap com.example.Sample
Compiled from "Sample.java"
public class com.example.Sample {
  public com.example.Sample(int);
  public int getValue();
  public void setValue(int) throws java.lang.Exception;
}

デフォルトではprivateなメンバしか出力されませんが、-pオプション を使用することでアクセス修飾子がprivateである物も含め全てのメンバを出力対象とすることができます。

# -pオプションを付けることでデフォルトでは出力されていなかったprivateフィールドが出力される
> javap -p com.example.Sample
Compiled from "Sample.java"
public class com.example.Sample {
  private int value; # -pオプションによるprivateフィールドの出力
  public com.example.Sample(int);
  public int getValue();
  public void setValue(int) throws java.lang.Exception;
}

またその他、-vオプション を付加することでより詳細な追加情報を出力することができますが、これは特にクラスファイルのバージョンを確認したい場合に活用されます。

# クラスファイルのバージョンが出力される
#   minor version: 0
#   major version: 65
> javap -v com.example.Sample
・・・略・・・
  Compiled from "Sample.java"
public class com.example.Sample
  minor version: 0
  major version: 65
・・・略・・・
javapコマンド(-vオプション)を使ったクラスファイルのバージョン確認について詳細はこちらです。

javapコマンドで使用可能なオプションは、JDKツール仕様 のほか、helpオプション -h によっても確認することができます。

> javap -h
使用方法: javap <options> <classes>
使用可能なオプションには次のものがあります:
  --help -help -h -?               このヘルプ・メッセージを出力する
  -version                         バージョン情報
  -v  -verbose                     追加情報を出力する
  -l                               行番号とローカル変数表を出力する
  -public                          publicクラスおよびメンバーのみを表示する
  -protected                       protected/publicクラスおよびメンバーのみを表示する
  -package                         package/protected/publicクラスおよび
                                   メンバーのみを表示する(デフォルト)
  -p  -private                     すべてのクラスとメンバーを表示する
  -c                               コードを逆アセンブルする
  -s                               内部タイプ署名を出力する
  -sysinfo                         処理しているクラスのシステム情報(パス、サイズ、日付、SHA-256ハッシュ)
                                   を表示します
  -constants                       final定数を表示する
  --module <module>  -m <module>   逆アセンブルされるクラスを含むモジュールを指定する
  -J<vm-option>                    VMオプションを指定する
  --module-path <path>             アプリケーション・モジュールを検索する場所を指定する
  --system <jdk>                   システム・モジュールを検索する場所を指定する
  --class-path <path>              ユーザー・クラス・ファイルのある場所を指定する
  -classpath <path>                ユーザー・クラス・ファイルを検索する場所を指定する
  -cp <path>                       ユーザー・クラス・ファイルを検索する場所を指定する
  -bootclasspath <path>            ブートストラップ・クラス・ファイルの場所をオーバーライドする
  --multi-release <version>        マルチリリースJARファイルで使用するバージョンを指定します

GNUスタイル・オプションでは、オプションの名前とその値を区切るために空白ではなく=を
使用できます。

表示される各クラスは、ファイル名、URLまたはその完全修飾クラス名
で指定できます。例:
   path/to/MyClass.class
   jar:file:///path/to/MyJar.jar!/mypkg/MyClass.class
   java.lang.Object

このようにjavapによる逆アセンブルでは、フィールドやメソッド、コンストラクタ等の定義情報を確認することはできますが、ソースコードを復元するわけではないため、例えばメソッドの中身のロジックがどうなっているか等、その中身の処理内容を確認したい場合は次に示す逆コンパイラを使用する必要があります。

# javapで出力されるのは以下のような定義情報のみ
> javap -p com.example.Sample
Compiled from "Sample.java"
public class com.example.Sample {
  private int value;                                    # フィールド
  public com.example.Sample(int);                       # コンストラクタ
  public int getValue();                # メソッド名やその引数と戻り値
  public void setValue(int) throws java.lang.Exception; # および送出され得る例外の定義
}

逆コンパイラ

逆コンパイラ(逆コンパイルを行うツール)を使うと、完全に元通りのソースコードを復元することはできませんが、クラスファイルを基にソースファイルを生成することができます。

例えば変数名やコメント等はコンパイルの段階で失われてしまう情報のため、元に戻すことは困難です。しかしプログラムのロジックを確認したい場合など、高レベルで復元されたソースコードからは必要な情報を十分に得ることができます。

Javaの逆コンパイラには様々なものがありますが、例えばCLIツールには「CFR」、GUIツールには「JD-GUI」があります。

CFR

CFRは、コマンドラインで利用可能な逆コンパイラです。JARファイルやZIPファイルの他、単一のクラスファイルを指定することもでき、比較的精度の高い逆コンパイルが可能です。

JD-GUI

JD-GUIは、GUIで直感的に操作可能な逆コンパイラです。基本的にはJARファイルやZIPファイルの逆コンパイル結果をウィンドウ表示するのが主な使用方法ですが、場合によっては単一のクラスファイルやディレクトリを指定して表示することも可能です。GUIツールのため使いやすさの点ではコマンドラインベースのCFRよりも優れていますが、精度に関してはやや劣ることがあります。

また、プラグイン JD-Eclipse により、Eclipseに組み込んで使用することも可能です。

CFRとJD-GUIの比較

多少の精度は問わずGUIで簡単にJARやZIPを確認したい場合はJD-GUIを、JARやZIPに加え単一のクラスファイルも対象に高精度での逆コンパイルを行いたい場合はCFRを用いるといった使い分けができます。

CFRJD-GUI
インターフェースCLIGUI
精度○ 比較的高い△ やや低い
単一クラスファイルの指定○ 可能△ バージョンによっては可能
ディレクトリの指定× 不可△ やり方次第では可能
JARファイルの指定○ 可能○ 可能
ZIPファイルの指定○ 可能○ 可能

精度の違い

CFRが変数名やコメント等を除き多くのロジックを比較的正確に解釈してソースファイルを復元するのに対して、JD-GUIでは例えば以下のように try-with-resources文continue文、複雑なラムダ式、等で復元が不完全になることがあります。

TryWithResourcesSample.java
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesSample {

    // try-with-resources 文
    static String readFirstLineFromFile(String path) throws IOException {
        try (BufferedReader br = new BufferedReader(new FileReader(path))) {
            return br.readLine();
        }
    }
}
CFRによる逆コンパイル結果
> java -jar cfr-0.152.jar TryWithResourcesSample.class
/*
 * Decompiled with CFR 0.152.
 */
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesSample {
    static String readFirstLineFromFile(String string) throws IOException {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(string));){
            String string2 = bufferedReader.readLine();
            return string2;
        }
    }
}
JD-GUIによる逆コンパイル結果
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesSample {
  static String readFirstLineFromFile(String paramString) throws IOException {
    BufferedReader bufferedReader = new BufferedReader(new FileReader(paramString));
    try {
      String str = bufferedReader.readLine();
      bufferedReader.close();
      return str;
    } catch (Throwable throwable) {
      try {
        bufferedReader.close();
      } catch (Throwable throwable1) {
        throwable.addSuppressed(throwable1);
      } 
      throw throwable;
    } 
  }
}
ContinueStatementSample.java
import java.util.List;

public class ContinueStatementSample {

    // continue 文
    static void continueStatement(List<Integer> numbers) {
        String searchMe = "peter piper picked a " + "peck of pickled peppers";
        int max = searchMe.length();
        int numPs = 0;

        for (int i = 0; i < max; i++) {
            // interested only in p's
            if (searchMe.charAt(i) != 'p')
                continue;

            // process p's
            numPs++;
        }
        System.out.println("Found " + numPs + " p's in the string.");
    }
}
CFRによる逆コンパイル結果
> java -jar cfr-0.152.jar ContinueStatementSample.class
/*
 * Decompiled with CFR 0.152.
 */
import java.util.List;

public class ContinueStatementSample {
    static void continueStatement(List<Integer> list) {
        String string = "peter piper picked a peck of pickled peppers";
        int n = string.length();
        int n2 = 0;
        for (int i = 0; i < n; ++i) {
            if (string.charAt(i) != 'p') continue;
            ++n2;
        }
        System.out.println("Found " + n2 + " p's in the string.");
    }
}
JD-GUIによる逆コンパイル結果
import java.util.List;

public class ContinueStatementSample {
  static void continueStatement(List<Integer> paramList) {
    String str = "peter piper picked a peck of pickled peppers";
    int i = str.length();
    byte b1 = 0;
    for (byte b2 = 0; b2 < i; b2++) {
      if (str.charAt(b2) == 'p')
        b1++; 
    } 
    System.out.println("Found " + b1 + " p's in the string.");
  }
}
LambdaExpressionSample.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class LambdaExpressionSample {

    // ラムダ式
    public static void lambdaExpression() throws Exception {
        Path homeDirectory = Paths.get(System.getProperty("user.home"));
        Files.list(homeDirectory)
             .filter(path -> path.endsWith(".log"))
             .forEach(logFile -> {
                 try {
                     Files.lines(logFile)
                          .peek(System.out::println)
                          .forEach(line -> {
                              if (line.contains("error")) {
                                  throw new RuntimeException(line);
                              }
                          });
                 } catch (IOException e) {
                     throw new RuntimeException(e);
                 }
             });
    }
}
CFRによる逆コンパイル結果
> java -jar cfr-0.152.jar LambdaExpressionSample.class
/*
 * Decompiled with CFR 0.152.
 */
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class LambdaExpressionSample {
    public static void lambdaExpression() throws Exception {
        Path path2 = Paths.get(System.getProperty("user.home"), new String[0]);
        Files.list(path2).filter(path -> path.endsWith(".log")).forEach(path -> {
            try {
                Files.lines(path).peek(System.out::println).forEach(string -> {
                    if (string.contains("error")) {
                        throw new RuntimeException((String)string);
                    }
                });
            }
            catch (IOException iOException) {
                throw new RuntimeException(iOException);
            }
        });
    }
}
JD-GUIによる逆コンパイル結果
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;

public class LambdaExpressionSample {
  public static void lambdaExpression() throws Exception {
    Path path = Paths.get(System.getProperty("user.home"), new String[0]);
    Files.list(path)
      .filter(paramPath -> paramPath.endsWith(".log"))
      .forEach(paramPath -> {
          try {
            Objects.requireNonNull(System.out);
            Files.lines(paramPath).peek(System.out::println).forEach(());
          } catch (IOException iOException) {
            throw new RuntimeException(iOException);
          } 
        });
  }
}

対象の違い

JD-GUIは こちら に記載の通り、場合によっては単一ファイルを指定できたり、やり方次第ではディレクトリを対象に開いたりすることも可能ですが、基本的にはJARファイルやZIPファイルが逆コンパイルの対象となります。一方でCFRは、ディレクトリを指定することはできないものの、JARファイルやZIPファイル、単一のクラスファイルを引数に指定することができます。