ネイティブマシンコードを簡単に逆コンパイルできないのはなぜですか?


16

Java、VB.NET、C#、ActionScript 3.0などのバイトコードベースの仮想マシン言語では、インターネットからデコンパイラーをダウンロードしてバイトコードを1回実行するだけの簡単さについて時々耳にします。多くの場合、数秒で元のソースコードからそれほど離れていないものを見つけます。おそらく、この種の言語はそれに対して特に脆弱です。

私は最近、ネイティブバイナリコードに関して、これが元々どの言語で書かれていたのか(したがって、どの言語に逆コンパイルしようとするのか)を少なくとも知っているのに、なぜこれについて聞いていないのだろうと思い始めました。長い間、ネイティブマシン言語が典型的なバイトコードよりも非常にクレイジーで複雑だからだと考えていました。

しかし、バイトコードはどのように見えますか?次のようになります。

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)オペレーティングシステムがプログラムをページ分割し、その断片をばらまく傾向があり、多くの問題を引き起こすという事実です。それらの可能性のいずれかが正しい場合、説明してください。しかし、いずれにせよ、基本的にこれを聞いたことがないのはなぜですか?

注意

私は答えの一つを受け入れようとしていますが、まず何かを言及したいと思います。ほとんどすべての人が、元のソースコードの異なる部分が同じマシンコードにマッピングされる可能性があるという事実に言及しています。ローカル変数名は失われ、元々使用されていたループのタイプなどはわかりません。

しかし、今述べた2つのような例は、私の目にはささいなものです。しかし、いくつかの答えは、マシンコードと元のソースの違いは、この些細なことよりもはるかに大きいと述べる傾向があります。

しかし、たとえば、ローカル変数名やループ型などに至ると、バイトコードもこの情報を失います(少なくともActionScript 3.0の場合)。私は前に逆コンパイラによってその原料のバックを引っ張ってきた、と私は本当に変数が呼び出されたかどうか気にしませんでしたstrMyLocalString:Stringloc1。私はまだその小さなローカルスコープを調べて、それがどのように使用されているのかをそれほど問題なく見ることができました。そして、forループはまったく同じものですwhileあなたがそれについて考えるなら、ループ。また、ソースをirrFuscator(secureSWFとは異なり、メンバー変数と関数名をランダム化するだけではありません)を介して実行する場合でも、特定の変数と関数を小さなクラスで分離し始めるように見えますそれらの使用方法を確認し、独自の名前を割り当てて、そこから作業します。

これが大したことであるためには、マシンコードはそれより多くの情報を失う必要があります、そして、答えのいくつかはこれに入ります。


35
ハンバーガーから牛を作るのは難しいです。
カズドラゴン14

4
主な問題は、ネイティブバイナリがプログラムに関するメタデータをほとんど保持しないことです。クラスに関する情報は保持せず(C ++を特に逆コンパイルするのが難しくなります)、関数に関する情報もありません。CPUは本質的に、一度に1命令ずつかなり線形にコードを実行するため、必要ありません。さらに、コードとデータを区別することは不可能です(link)。詳細については、RE.SEでの検索または再質問を検討してください
ntoskrnl 14

回答:


39

コンパイルのすべてのステップで、回復不可能な情報を失います。元のソースから失われる情報が多いほど、逆コンパイルが難しくなります。

最終的なターゲットマシンコードを生成するときに保存される情報よりも多くの情報が元のソースから保存されるため、バイトコード用の便利な逆コンパイラを作成できます。

コンパイラの最初のステップは、多くの場合ツリーとして表される中間表現のソースにソースを変換することです。従来、このツリーにはコメント、空白などの非意味的な情報は含まれていません。これが破棄されると、そのツリーから元のソースを復元することはできません。

次のステップは、最適化を容易にする何らかの中間言語の形式にツリーをレンダリングすることです。ここにはかなりの選択肢があり、各コンパイラインフラストラクチャには独自のものがあります。ただし、通常、ローカル変数名、大きな制御フロー構造(forループまたはwhileループを使用したかどうかなど)などの情報は失われます。通常、ここでいくつかの重要な最適化が行われます。定数伝播、不変コードモーション、関数のインライン化などです。

その後のステップは、共通の命令パターンの最適化されたバージョンを生成する「ピープホール」最適化と呼ばれるものを含むかもしれない実際の機械命令を生成することです。

各ステップでは、元のコードに似たものを回復することが不可能になるほど多くの情報を失うまで、ますます多くの情報を失います。

一方、バイトコードは通常、ターゲットマシンコードが生成されるJITフェーズ(ジャストインタイムコンパイラー)まで興味深く、変換可能な最適化を保存します。バイトコードには、ローカル変数タイプ、クラス構造などの多くのメタデータが含まれており、同じバイトコードを複数のターゲットマシンコードにコンパイルできます。この情報はすべてC ++プログラムでは必要ではなく、コンパイルプロセスで破棄されます。

さまざまなターゲットマシンコード用のデコンパイラがありますが、元のソースの多くが失われるため、有用な結果(変更してから再コンパイルできるもの)を生成しないことがよくあります。実行可能ファイルのデバッグ情報があれば、さらに良い仕事をすることができます。ただし、デバッグ情報がある場合は、おそらく元のソースもあります。


5
JITがよりよく機能するように情報が保持されるという事実が重要です。
btilly 14

その場合、C ++ DLLは簡単に逆コンパイルできますか?
パンツァークライシス14

1
役に立つと思うものは何もありません。
chuckj 14

1
メタデータは「同じバイトコードを複数のターゲットにコンパイルできるようにする」ためではなく、リフレクションのためにあります。リターゲット可能な中間表現には、そのメタデータが必要ありません。
SKロジック

2
それは真実ではありません。データの多くはリフレクション用にありますが、リフレクションだけが使用されるわけではありません。たとえば、インターフェイスおよびクラス定義を使用して、フィールドオフセットの定義、ターゲットマシンでの仮想テーブルの構築などを行い、ターゲットマシンで最も効率的な方法で構築できるようにします。これらのテーブルは、ネイティブコードを生成するときにコンパイラまたはリンカー、あるいはその両方によって構築されます。これが完了すると、それらの作成に使用されたデータは破棄されます。
chuckj 14

11

他の回答で指摘されているように、情報の損失は1つのポイントですが、それは取り壊しではありません。すべての後、あなたは元のプログラムの背中を期待していない、あなただけしたい任意の高水準言語での表現を。コードがインライン化されている場合は、そのままにするか、一般的な計算を自動的に除外できます。原則として、多くの最適化を取り消すことができます。ただし、原則的に不可逆的な操作がいくつかあります(少なくとも無限のコンピューティングなしで)。

たとえば、分岐は計算されたジャンプになる場合があります。このようなコード:

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が外部から来て1または2であることが保証されていない場合、基本的にどこにでもジャンプできる悪いバグがあります。しかし、プログラムにこの種のバグがない場合、推論するのははるかに簡単です。(これは、「安全な」中間CLR ILのような言語やJavaバイトコードもさておき、メタデータを設定し、逆コンパイルする方がはるかに簡単であることは偶然ではない。)ので、実際に、逆コンパイルすることは可能である必要があり、特定の、行儀プログラム。私は、副作用がなく、明確に定義された入力がない、個別の機能スタイルルーチンを考えています。単純な関数の擬似コードを提供できる逆コンパイラがいくつかあると思いますが、そのようなツールの経験はあまりありません。


9

マシンコードを元のソースコードに簡単に変換できない理由は、コンパイル中に多くの情報が失われるためです。メソッドとエクスポートされていないクラスはインライン化でき、ローカル変数名は失われ、ファイル名と構造は完全に失われ、コンパイラは明白ではない最適化を行うことができます。別の理由は、複数の異なるソースファイルがまったく同じアセンブリを生成する可能性があることです。

例えば:

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メソッドが本質的に定数を返すことを知っていること、およびメソッド呼び出しとメソッド自体。

コンパイラの目的は、ソースファイルをバンドルする方法ではなく、アセンブリを作成することです。


最後の命令をjustに変更することを検討し、retCの呼び出し規約を想定していたとだけ言ってください。
chuckj 14

3

ここでの一般的な原則は、多対1のマッピングと標準的な代表の欠如です。

多対1現象の簡単な例として、ローカル変数を使用して関数を取得し、それをマシンコードにコンパイルするとどうなるかを考えることができます。変数は単にメモリアドレスになるため、変数に関するすべての情報は失われます。ループについても同様のことが起こります。foror whileループを取ることができ、それらがちょうど適切に構造化されている場合、jump命令を含む同一のマシンコードを取得できます。

また、これは、マシンコード命令の元のソースコードからの標準的な代表の欠如をもたらします。ループを逆コンパイルしようとすると、jump命令をループ構造にどのようにマッピングし直しますか?あなたは彼らが作るんforループまたはwhileループします。

この問題は、現代のコンパイラがさまざまな形式の折りたたみとインライン化を実行するという事実によってさらに悪化します。そのため、マシンコードに到達するまでに、低レベルのマシンコードがどのような高レベルの構成要素に由来しているかを知ることはほとんど不可能です。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.