Java、VB.NET、C#、ActionScript 3.0などのバイトコードベースの仮想マシン言語を使用すると、インターネットからデコンパイラーをダウンロードしてバイトコードを一度に実行するのがいかに簡単であるかを時々耳にします。多くの場合、ほんの数秒で元のソースコードからそれほど遠くないものを思い付きます。おそらく、この種の言語は特にそれに対して脆弱です。
私は最近、ネイティブバイナリコードに関してこれ以上何も聞かないのか、少なくともそれが元々どの言語で書かれていたのか(したがって、どの言語に逆コンパイルしようとしているのか)を知っているのに、なぜ不思議に思うようになりました。長い間、ネイティブマシン言語が典型的なバイトコードよりもはるかにクレイジーで複雑なためと考えていました。
しかし、バイトコードはどのように見えますか?次のようになります。
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
そして、ネイティブマシンコードは(16進数で)どのように見えますか?もちろん、次のようになります。
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
そして、指示はやや似たような考え方から来ています。
1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: Push ECX
では、ネイティブバイナリをC++などに逆コンパイルしようとする言語を考えると、何がそんなに難しいのでしょうか。すぐに頭に浮かぶ唯一の2つのアイデアは、1)バイトコードよりもはるかに複雑である、または2)オペレーティングシステムがプログラムをページ分割し、それらの断片を分散する傾向があるという事実についての問題の多くです。これらの可能性のいずれかが正しい場合は、説明してください。しかし、どちらにしても、基本的にこれを聞いたことがないのはなぜですか。
[〜#〜]ノート[〜#〜]
私は答えの1つを受け入れようとしていますが、最初に何かについて触れたいと思います。ほとんどすべての人が、元のソースコードの異なる部分が同じマシンコードにマッピングされる可能性があるという事実に言及しています。ローカル変数名が失われたり、元々使用されていたループのタイプがわからないなど。
しかし、今述べた2つのような例は、私の目には取るに足らないものです。ただし、一部の回答では、マシンコードと元のソースの違いは、この些細なものよりもはるかに大きいと述べられています。
しかし、たとえば、ローカル変数名やループタイプなどのことになると、バイトコードはこの情報も失います(少なくともActionScript 3.0の場合)。以前は逆コンパイラを介してそのようなものをプルしたことがあり、変数がstrMyLocalString:String
と呼ばれるのか、loc1
と呼ばれるのかはあまり気にしていませんでした。それでも、その小さなローカルスコープを調べて、問題なく使用されていることを確認できました。また、for
ループは、考えれば、while
ループとまったく同じです。また、irrFuscatorを介してソースを実行する場合(secureSWFとは異なり、メンバー変数と関数名をランダム化するだけではありません)でも、特定の変数と関数を小さなクラスで分離し始めることができるように見えました、図それらがどのように使用されるかを調べ、自分の名前を割り当て、そこから作業します。
これが大きな問題になるためには、マシンコードがそれよりもはるかに多くの情報を失う必要があり、答えのいくつかはこれに当てはまります。
コンパイルのすべてのステップで、回復不可能な情報を失います。元のソースから失う情報が多いほど、逆コンパイルが難しくなります。
最終的なターゲットマシンコードを生成するときに保持されるよりも多くの情報が元のソースから保持されるため、バイトコード用の便利な逆コンパイラを作成できます。
コンパイラーの最初のステップは、ソースを、しばしばツリーとして表される中間表現のいくつかに変換することです。従来、このツリーにはコメントや空白などの意味論的でない情報は含まれていません。これを破棄すると、そのツリーから元のソースを復元することはできません。
次のステップは、最適化を容易にする何らかの形式の中間言語にツリーをレンダリングすることです。ここにはかなり多くの選択肢があり、各コンパイラインフラストラクチャには独自の選択肢があります。ただし、通常、ローカル変数名、大きな制御フロー構造(forループまたはwhileループを使用したかどうかなど)などの情報は失われます。いくつかの重要な最適化は通常ここで行われ、一定の伝播、不変のコードの動き、関数のインライン化などです。それぞれが、表現を同等の機能を持つ表現に変換しますが、外観は大きく異なります。
その後のステップは、一般的な命令パターンの最適化されたバージョンを生成する、「のぞき穴」最適化と呼ばれるものを含む実際の機械語命令を生成することです。
各ステップで、最終的には元のコードに似たものを復元できなくなるほど多くの情報が失われます。
一方、バイトコードは、通常、対象となるマシンコードが生成されるJITフェーズ(ジャストインタイムコンパイラー)まで、興味深い変換の最適化を保存します。バイトコードには、ローカル変数タイプ、クラス構造などの多くのメタデータが含まれており、同じバイトコードを複数のターゲットマシンコードにコンパイルできます。このすべての情報はC++プログラムでは必要なく、コンパイルプロセスで破棄されます。
さまざまなターゲットマシンコードの逆コンパイラがありますが、元のソースの多くが失われるため、有用な結果(変更してから再コンパイルできるもの)を生成しないことがよくあります。実行可能ファイルのデバッグ情報がある場合は、さらに優れたジョブを実行できます。しかし、デバッグ情報があれば、おそらく元のソースもあるでしょう。
他の回答で指摘されている情報の損失は1つのポイントですが、それは取引のブレーカーではありません。結局のところ、元のプログラムが元に戻ることを期待するのではなく、高水準言語でany表現が必要なだけです。コードがインライン化されている場合は、そのままにするか、一般的な計算を自動的に除外できます。原則として、多くの最適化を元に戻すことができます。ただし、原則として元に戻せない操作がいくつかあります(少なくとも、無限の計算は必要ありません)。
たとえば、ブランチは計算されたジャンプになる可能性があります。このようなコード:
select (x) {
case 1:
// foo
break;
case 2:
// bar
break;
}
コンパイルされる可能性があります(これは実際のアセンブラではありません):
0x1000: jump to 0x1000 + 4*x
0x1004: // foo
0x1008: // bar
0x1012: // qux
ここで、xが1または2であることがわかっている場合は、ジャンプを見てこれを簡単に反転できます。しかし、アドレス0x1012はどうですか? case 3
も作成する必要がありますか?どのような値が許可されているかを把握するには、最悪の場合にプログラム全体をトレースする必要があります。さらに悪いことに、すべての可能なユーザー入力を考慮する必要があるかもしれません!問題の核心は、データと命令を区別できないことです。
そうは言っても、私は完全に悲観的になるわけではありません。上記の「アセンブラー」でお気づきかもしれませんが、xが外部から来て、notが1または2であることが保証されている場合、本質的に、どこにでもジャンプできるバグがあります。しかし、プログラムにこの種のバグがなければ、推論するのははるかに簡単です。 (CLR ILやJavaバイトコードのような「安全な」中間言語は、メタデータを脇に置いても、逆コンパイルがはるかに簡単です。)したがって、実際には逆コンパイルできるはずです- 特定の、適切に動作するプログラム。副作用がなく、入力が明確に定義された、個々の関数型のルーチンを考えています。簡単な関数の疑似コードを提供できる逆コンパイラがいくつかあると思います、しかし私はそのようなツールについてはあまり経験がありません。
マシンコードを元のソースコードに簡単に変換できないのは、コンパイル中に多くの情報が失われるためです。メソッドとエクスポートされていないクラスはインライン化でき、ローカル変数名は失われ、ファイル名と構造体は完全に失われ、コンパイラーは自明ではない最適化を行うことができます。別の理由は、複数の異なるソースファイルがまったく同じアセンブリを生成する可能性があるためです。
例えば:
int DoSomething()
{
return Add(5, 2);
}
int Add(int x, int y)
{
return x + y;
}
int main()
{
return DoSomething();
}
次のようにコンパイルできます。
main:
mov eax, 7;
ret;
私のアセンブリはかなり錆びていますが、コンパイラが最適化を正確に実行できることを確認できれば、そうします。これは、コンパイルされたバイナリがDoSomething
とAdd
の名前を知る必要がないこと、およびAdd
メソッドに2つの名前付きパラメーターがあること、コンパイラーも知っているためです。 DoSomething
メソッドは基本的に定数を返すため、メソッド呼び出しとメソッド自体の両方をインライン化できます。
コンパイラの目的は、アセンブリを作成することであり、ソースファイルをバンドルする方法ではありません。
ここでの一般的な原則は、多対1のマッピングであり、正規の代表がありません。
多対1の現象の簡単な例として、ローカル変数を含む関数を取り、それをマシンコードにコンパイルするとどうなるかを考えることができます。変数はメモリアドレスになるため、変数に関するすべての情報が失われます。ループでも同様のことが起こります。 for
またはwhile
ループを取ることができ、それらが適切に構成されている場合は、jump
命令を使用して同一のマシンコードを取得できます。
これはまた、マシンコード命令の元のソースコードからの正規の代表の欠如をもたらします。ループを逆コンパイルしようとすると、jump
命令をループ構造にどのようにマッピングしますか?それらをfor
ループまたはwhile
ループにしますか。
この問題は、現代のコンパイラーがさまざまな形式の折りたたみとインライン化を実行するという事実によってさらに悪化します。したがって、マシンコードにたどり着くまでに、低レベルのマシンコードがどの高レベルの構造から来たかを知ることはほとんど不可能です。