Javaとリバース・エンジニアリング
まず、逆コンパイラを作成する前に、知っておかなくてはならないことがあります。それらは前章の「Java逆コンパイル技術」に詳しく書かれています。また、Javaの構成要素(属性の型と名前、属性の型、戻り値と名前など)の関係などは知っておく必要があります。Java言語で出来たクラスファイルを解析しようとしているのですから当然ですよね。
Java(TM) コアリフレクション API は、現在の Java 仮想マシン内のクラスやオブジェクトに関する内部情報の参照と変更をサポートするAPIです。この API により、 (実行時のクラスに基づく) ターゲットオブジェクトの public メンバまたは所定クラスが宣言したメンバへのアクセスが可能になります。 これによって何が出来るか分かるでしょうか?「クラスやオブジェクトに関する内部情報の参照と変更」という点が重要なのです。その中でもとくに、「内部情報」です。つまり、すでに存在するクラスファイルの内部情報を覗けるわけです。これがなぜ重要なのでしょうか?
多くのプログラマにとって、他の人が書いた実行ファイルの内部情報を覗けるというのは、たいへん魅力です。プログラムは頭の中の世界ですから、人より優れたアルゴリズムを開発した人の方が成功するわけです。また、大抵の人は、優れたアルゴリズムを開発したらそれを他の人には秘密にするはずです。それが自分の武器になるからです。しかし、リフレクションAPIを使えば、自分より優れたアルゴリズムを書く人がどのようなコードを書いているかを推測する(あるいは断定する)重要な助けになります。
Sunが提供しているJDK1.1以上の中に、javapという逆アッセンブラがあります。このユーティリティーの-privateオプションを使って次のような内容を持つクラスファイルを逆コンパイルしてみましょう。
public class TV { private int m_nChannel;// 現在のチャンネル private int m_nVolume;// 現在のボリューム final int MIN_CHANNEL = 1; // 最少チャンネル final int MAX_CHANNEL = 12; // 最大チャンネル // チャンネルの変更 public void Channel (int nNewChannel) { // Channnelメソッドはm_nMAX_CHANNEL以上のチャンネル入力を拒否します。 if (nNewChannel > MIN_CHANNEL && nNewChannel < MAX_CHANNEL) { m_nChannel = nNewChannel; } } // ボリュームの変更 public void Volume (int nNewVolume) { m_nVolume = nNewVolume; } } |
結果は、次のようになります。
Compiled from TV.java public synchronized class TV extends java.lang.Object /* ACC_SUPER bit set */ { private int m_nChannel; private int m_nVolume; final int MIN_CHANNEL; final int MAX_CHANNEL; public void Channel(int); public void Volume(int); public TV(); } |
多少の違いはありますが、大体の情報は引き出せています。定数のfinalや型情報のint、アクセス情報なども完全に復元できています。public TV()は、もとのファイルにはありませんが、これは、デフォルト・コンストラクタが暗黙のうちに呼び出されているからです。extends java.lang.Objectも、デフォルトで暗黙のうちに呼び出されるものです。
ここでは、このような結果を表示するユーティリティーを、リフレクションAPIを使って作成してみようと思います。
・・・どうもベタですが、「クラス情報を解析するツール」という意味で、"ClassAnalyzer"という名前で作ってみることにしましょう。
クラスビューワを作るには、java.lang.Classとjava.lang.reflectionパッケージを使います。
java.lang.Classは、指定されたクラスに関する情報を得るのにまず必要な、エントリー・ポイントとでもいえるものです。クラス Class は、以下のことが実行できます。
Class
オブジェクトが配列型を表すかどうかの判別Class
オブジェクトがプリミティブ型を表すかどうかの判別・・・どうもよく分かりません。実際にやってみたらそのうち分かってくるのでしょうか。
では、実際にクラスの情報をClassやjava.lang.reflect.*などを使って獲得してみましょう。まず、「public class TVPhone extends TV implements Phone」や「interface TVPhone」などの、クラスの宣言文を得るコードを書くことにしましょう。
ClassAnalyzerステップ1のソースは次のようになります。
import java.lang.reflect.*; // クラスの概要を表示するプログラム public class ClassAnalyzer { // コマンドラインよりクラス名を読み取る。拡張子は含まれない。 public static void main(String[] args) throws ClassNotFoundException { try { Class c = Class.forName(args[0]); printClass(c); } catch (ClassNotFoundException e) { System.out.println("class " + args[0] + " is not found."); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("Usege: java ClassAnalyzer class ..."); } } // 修飾子、名前、スーパークラス、そしてインターフェースを表示する。 public static void printClass(Class c) { // 修飾子、型(クラスかインターフェース)、名前とスーパークラスを //表示する if (c.isInterface()) { // 修飾子はここに"interface"キーワードを含む System.out.print(Modifier.toString(c.getModifiers()) + " " + c.getName()); } else System.out.print(Modifier.toString(c.getModifiers()) + " class " + c.getName() + " extends " + c.getSuperclass().getName()); // そのクラスのスーパーインターフェースかインターフェースを表示する Class[] interfaces = c.getInterfaces(); if ((interfaces != null) && (interfaces.length > 0)) { if (c.isInterface()) System.out.println(" extends "); else System.out.print(" implements "); for(int i = 0; i < interfaces.length; i++) { if (i > 0) System.out.print(", "); System.out.print(interfaces[i].getName()); } } } } |
ではメインメソッドから見ていきましょう。それは、次のようになっています。
// コマンドラインよりクラス名を読み取る。拡張子は含まれない。 public static void main(String[] args) throws ClassNotFoundException { try { Class c = Class.forName(args[0]); printClass(c); } catch (ClassNotFoundException e) { System.out.println("class " + args[0] + " is not found."); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("Usege: java ClassAnalyzer class ..."); } } |
メインメソッドは、コマンドラインからクラス名を読み取り、そのクラス名を使ってClass オブジェクトを作成します。このClassオブジェクトは、コマンドラインから読み取ったクラスそのものを表現します。つまり、このクラスオブジェクトを使って、このクラスに含まれる情報を引き出せるわけです。このメインメソッドは、ClassNotFoundExceptionを捕捉して、クラス名が見つからなかった時にはメッセージを表示して終了します。また、コマンドライン文字列が見つからなかった場合には使用方法を表示します。例外が発生しなかった場合には、printClassメソッドにクラスオブジェクトを渡します。printClassメソッドは、修飾子、名前、スーパークラス、そしてインターフェース名を取得して表示します。
では、printClassメソッドに入りましょう。printClassメソッドは、次のようになっています。
public static void printClass(Class c) { // 修飾子、型(クラスかインターフェース)、名前とスーパークラスを //表示する if (c.isInterface()) { // 修飾子はここに"interface"キーワードを含む System.out.print(Modifier.toString(c.getModifiers()) + " " + c.getName()); } else System.out.print(Modifier.toString(c.getModifiers()) + " class " + c.getName() + " extends " + c.getSuperclass().getName()); // そのクラスのスーパーインターフェースかインターフェースを表示する Class[] interfaces = c.getInterfaces(); if ((interfaces != null) && (interfaces.length > 0)) { if (c.isInterface()) System.out.println(" extends "); else System.out.print(" implements "); for(int i = 0; i < interfaces.length; i++) { if (i > 0) System.out.print(", "); System.out.print(interfaces[i].getName()); } } } |
ここでまず気付くのは、Classオブジェクトのほかに、Modifierという新しいオブジェクトが出てきたことです。Modifierとは、「修飾子」のことで、このオブジェクトは、クラスおよびそのメンバに関する Java 言語修飾子情報の解読に役立ちます。要するに、修飾子情報を得るためにこのオブジェクトを使うわけです。では、見ていきましょう。
最初に、メインメソッドから渡されたClassオブジェクトがインターフェースかどうかを判断するif文に入ります。インターフェースだった場合は、そのクラスオブジェクトから修飾子情報を取得し、Classオブジェクトの名前を取得して表示します。ここで、例えば「interface ITVPhone」などのような文字列が表示されるわけです。
そして、Classオブジェクトがインターフェースではなかった場合、Classオブジェクトから取得した修飾子情報を文字列に変換し、スーパークラスの名前を取得して、表示します。インターフェースを含んでいた場合、そしてそのClassオブジェクトがインターフェースだった場合、スーパーインターフェースを表示します。Classオブジェクトがクラスだった場合は、implementsしているインターフェースを表示します。
以上のコードで、得られる文字列は、例えば以下のようなものです。
public synchronized class TV extends java.lang.Object
public synchronized class TVApplet extends java.applet.Applet implements java.awt.event.ActionListener, java.awt.event.ItemListener
synchronizedやjava.lang.Objectなど、元のソースには無いものが付け足されていますが、これらは、暗黙のうちに呼ばれているもので、コンパイル時に追加されたものです。クラスビューワの方が元のソースファイルより相関関係をより正確に描き出すわけですね。まあ、とりあえずこれでクラスの宣言部分は取得することができました。バイトコードであるクラスファイルからもとのソースファイルの一部分を復元できたと言う点で、これでもう十分逆コンパイラの機能はあるといえます。
次は、クラスに含まれるメンバのリストを取得してみましょう。クラスに含まれるメンバとは、コンストラクタ、メソッド、メンバ関数などがあります。まず、コンストラクタを取得するコードを追加してみましょう。
ここでは、コンストラクタの情報を取得するために、また新しいオブジェクトが出てきます。Constructor オブジェクトです。私たちは、クラス Constructor のメソッドを使って、基本コンストラクタの形式パラメータ型と確認済みの例外の型を取得することができます。では、じっさいコーディングに入りましょう。
コンストラクタは、複数存在する可能性があります。Javaはメソッドのオーバーロードをすることができるからです。コンストラクタオブジェクトの配列は、Classオブジェクトから、getDeclaredConstructors()することで取得することができます。
Constructor[] constructors = c.getDeclaredConstructors();
コンストラクタは複数存在する可能性があるため、取得したコンストラクタオブジェクトを、コンストラクタ情報を取得するためのメソッドにループで一つ一つ渡します。
for (int i = 0; i <
constructors.length; i++)
printMethod ( constructors[i] );
ここで、関数名がprintMethodとなっているのは、実は意味があるのです。コンストラクタも一種のメソッドだと私が理解しているのと、後でメソッドのリスト表示関数と統合する予定であることに関係があるのですが、今は気にしないことにしましょう。
では、printMethodのコーディングへいきましょう。以下は、実際のprintMethodメソッドと、関係するメソッド群です。
public static void printMethod (Member member) { Class returntype=null, parameters[], exceptions[]; Constructor c = (Constructor) member; parameters = c.getParameterTypes(); exceptions = c.getExceptionTypes(); System.out.print(" " + modifiers(member.getModifiers()) + ((returntype!=null) ? typeName(returntype)+" " : "") + member.getName() + "("); for(int i = 0; i < parameters.length; i++) { if (i > 0) System.out.print(", "); System.out.print(typeName(parameters[i])); } System.out.print(")"); if (exceptions.length > 0) System.out.print(" throws "); for(int i = 0; i < exceptions.length; i++) { if (i > 0) System.out.print(", "); System.out.print(typeName(exceptions[i])); } System.out.println(";"); } public static String typeName(Class t) { String brackets = ""; while(t.isArray()) { brackets += "[]"; t = t.getComponentType(); } return t.getName() + brackets; } public static String modifiers(int m) { if (m == 0) return ""; else return Modifier.toString(m) + " "; } |
typeNameやmodifiersというメソッドはよく使うので、一つのメソッドとしてまとめてあります。ところで、なぜprintMethodの引数が、Memberというオブジェクトになっているのでしょうか?私がprintMethodメソッドに渡したのはConstructorオブジェクトだったはずです。いったいどうなっているのでしょうか?
実は、Memberというのはインターフェースで、クラス
Field
, Method
および Constructor
は、Member
インタフェースを実装します。と、いうことは、Memberインターフェースを引数として設定しておけば、printMethodメソッド一つで、Field,
Method,Constructorオブジェクトを引数に取れるわけです。「ポリモーフィズム」ってやつですね。ここでは、MethodオブジェクトもしくはConstructorオブジェクトが渡されます。
次に、戻り値、パラメータ、例外を示すClassオブジェクトを作成します。そして、引数がConstructorオブジェクトの場合は、printMethodメソッドに渡されたMemberオブジェクトをConstructorにキャストして、Constructorオブジェクトを復元します。そして、Classオブジェクトであるparameterに、getParameterTypesを使ってConstructor オブジェクトが表すコンストラクタの形式パラメータ型を表す Class オブジェクトの配列を、宣言順に取得します。基本コンストラクタがパラメータを取らない場合は、長さが 0 の配列が返されます。
その次に、Classオブジェクトであるexceptionsに、getExceptionTypesを使用して、この Constructor オブジェクトが表す基本コンストラクタがスローする、確認済み例外のクラスを表す Class オブジェクトの配列を取得します。コンストラクタが確認済み例外をスローしない場合は、長さが 0 の配列が返されます。
次はコンストラクタの修飾子と戻り値、そしてのコンストラクタの名前処理です。ここで、コンストラクタには戻り値はないので、Constructorオブジェクトが渡された場合は、returntypeはいつもnullになります。ここで、modifierというメソッドが出てきました。getModifiersメソッドはインタフェースの Java 言語修飾子を整数型にコード化して返すので、このメソッドは、渡されたオブジェクトが0の場合(修飾子が無い場合)には空白文字列を返し、それ以外の場合には修飾子情報を文字列にして返します。
最後にパラメータと例外を取得するわけですが、ここではtypeNameメソッドにループで各オブジェクトを渡しています。typeNameはなにをするかと言えば、渡されたオブジェクトが配列かどうかをチェックし、配列であれば"[]"をオブジェクトの名前の後ろに追加して返しています。"オブジェクトが配列であるかもしれない"可能性のことを考えなくてはいけないためです。
コンストラクタの表示が出来たら、メソッドの表示は簡単です。Method もConstructor も、Member インターフェースを実装しているからです。メソッドオブジェクトの配列は、次のようにClassオブジェクトから取得することが出来ます。
Method[] methods = c.getDeclaredMethods();
そして、ループでprintMethodにメソッドオブジェクトを渡します。
for(int i = 0; i <
methods.length; i++)
printMethod ( methods[i] );
printMethodメソッドをMethod オブジェクトにも対応できるようにするために、printMethodメソッドを少し変えなくてはいけません。変更する所は、以下の通りです。
Constructor c = (Constructor) member; parameters = c.getParameterTypes(); exceptions = c.getExceptionTypes(); |
を
if (member instanceof Method) { Method m = (Method) member; returntype = m.getReturnType(); parameters = m.getParameterTypes(); exceptions = m.getExceptionTypes(); } else { Constructor c = (Constructor) member; parameters = c.getParameterTypes(); exceptions = c.getExceptionTypes(); } |
に変更します。memberのインスタンスがMethodだった場合、つまり、printMethodメソッドに渡されたMemberオブジェクトがMethodオブジェクトだった場合、Methodにキャストして、各種設定します。これの意味は分かると思います。
はい、ステップ3は簡単でしたね。次はフィールド情報を表示させることにしましょう。
「フィールド」とは何でしょうか?フィールドとは、メンバ変数や定数のことです。基本フィールドはクラス変数 (static フィールド) でもインスタンス変数 (非 static フィールド) でも構いません。フィールド情報へのアクセスは、Fieldクラスのメソッドを使用します。
基本的に、Fieldオブジェクトを取得するための作業は今までと変わりません。Fieldオブジェクトは、次のようにして、Classオブジェクトから取得します。
Field[] fields = c.getDeclaredFields();
このフィールドオブジェクトを、新しいメソッドに渡します。
for(int i = 0; i <
fields.length; i++) // Display them.
printField(fields[i]);
では、printFiledメソッドを作成することにしましょう。printFieldメソッドは、次のようなものです。
public static void printField( Field f ) { System.out.println(" " + modifiers( f.getModifiers() ) + typeName( f.getType() ) + " " + f.getName() + ";"); } |
getModifierメソッドで修飾子情報を取得し、modifiersメソッドでそれを名前にし、タイプ情報を名前+配列か名前にのみに変更して、変数名を取得して、表示します。このプロセスは、今までやってきたこととほとんど同じなので、理解がしやすいと思います。
ここまでで出来上がった、ClassAnalyzerのソースファイルは、ここにあります。
さて、ここまで来た所で、プログラミングにおける一番の難所である、「デバッグ」に進みましょう。これが一番いやな作業なんですよね、自分がいかに考えて作ってないかっていうのをまざまざと見せられますから。とりあえず、このクラスビューワのバグを発見してみましょう。
まず、最初のファイル名入力のところで、ユーザが誤って「TV.class」など、クラスファイル名を拡張子を付けたまま指定してしまったばあい、今のままでは、「class TV.class is not found」と表示されてしまいます。内部では「TV.class」は、「TV/class」として、フォルダ/クラスファイル名という風に処理されているので、そのように表示した方が良いかもしれません。つまり、「class TV/class is not found」という風なメッセージに変えればユーザにも比較的分かりやすくなるかもしれません。少なくとも、「class TV.class is not found」よりはマシなはずです。
また、今のままでは、メソッドやフィールドを検索した時に、Javaが、そこに使用されている型情報を取得できなかった時などに、例外が発生する可能性があります。そのような時に、try〜catchブロックで例外を捕捉しておかないと、プログラムの実行がそこでストップしてしまいます。せめて、エラーストリームにでも例外発生を通知するようにしておいた方が親切だと思います。とにかく、プログラムが実行途中で異常終了するような事体だけはなんとしても避けなくてはいけません。
最後に、java.langパッケージはデフォルトでインポートされているので、java.lang.reflectパッケージ以外の、java.lang.String name などという出力を、String name のみにするように変更します。
・・・などなど、いろいろバグ修正したClassAnalyzerのソースファイルは、ここにあります。コンパイル済みのクラスファイルは ここにあります。
今のままでは、フィールド値などのパッケージ修飾がそのまま現れてしまい、正確さは増しますが、結果的に出力が見にくくなってしまっています。そこで、例えば、java.awt.Button ctlHideButton などを、ファイルの先頭にimport java.awt.Button; を加えてButton ctlHideButton のみにするようにプログラムを変更してみてはいかがでしょうか。(責任逃れモード。)そして、さらなるステップアップのために、java.lang.reflect.Method や java.lang.reflect.Field クラスのAPIドキュメントを読み、その中のinvolkメソッドやgetメソッドを上手く使えば、定数値やグローバル変数の値を取得することも多分可能でしょう。また、オブフスケートされたクラスファイルの内容を解析するためにもう一工夫考えることもできるでしょう。オプションをもっと増やして、よりユーザが使いやすいようにするもの良いかもしれません。(例えば、-m オプションでメソッドのみを表示させるとか。)
ここまで、リフレクションAPIを使ってクラスビューワを作ってきたわけですが、このクラスビューワは、ある意味で限定的な逆コンパイラであるといえます。もちろん、このクラスファイルもJavaで出来ているわけですから、この逆コンパイラもこの逆コンパイラによって逆コンパイルできるわけです。なんだか哲学的なものを感じませんか?(^^;
世の中にはJavaで出来た優秀な逆コンパイラがあります。それに対抗する手段として、オブフスケーション(曖昧化)などという方法もあるにはありますが(大抵は逆コンパイラを作った人が作っています。)、それらもその逆コンパイラによって解読されてしまいます。逆コンパイラですら比較的容易に逆コンパイルできるのです。そのことは、逆コンパイラを作った人でさえもそれに対するまともな対抗策がない、と言うことを示していると思います。それは、Javaにとって大変致命的な欠点であると思います。ここまでJavaが発展しても、まだ商用のJavaアプリが少ないのもこの要因があるからではないでしょうか。いくら苦労して作っても簡単に解読されてしまうようでは、発展の道はありません。それとも、Sunがなんとかしてくれるのでしょうか?結局、コンパイル済みのクラスファイルを配布するのは、ソースコードを配っているのと同じことだ、という認識でプログラムした方が良いでしょう。