リバースエンジニアリングの簡単な入門

ここ数週間、私は x86_64 アセンブリ、AArch64 アセンブリ、そしてさまざまな基礎を学んできました。最近、ついに人生で初めてリバースエンジニアリングを体験しました。この体験について、もっと詳しく共有できることをうれしく思います。

リバースエンジニアリングとは何か

コンピュータは実のところ、かなり頭の悪い機械です。自分で問題を解決することはできません。解決策を実行するだけです。より正確に言えば、人間から与えられた命令を一つずつ順番に実行するだけです。このような機械で問題を解くには、通常、次のような手順を踏みます。

  • 問題 を分析する;
  • その問題に対する 解決策 を見つける;
  • アルゴリズム を設計して解決策を実装する;
  • コード を書いてアルゴリズムを実装する;
  • コンパイルしてコードから 実行ファイル を生成する;
  • その実行ファイルを、その頭の悪いコンピュータに与えて問題を解かせる。

これは、解決策コード に変換し、さらに 命令 に変換する手順です。

通常、コードを読めば、そのプログラムが何をしているかを容易に理解できます。これは、解決策と命令の中間にあるものです。1970年代には、コードはふつう人民のあいだで共有されていたので、誰もが共同体の中で解決策を学び、改良し、共有することができました。

しかし、あの極めて邪悪なプロプライエタリ・ソフトウェア開発の怪物どもが現れて以来、状況は変わりました。彼らは人民から コード へのアクセスを拒み、利用者の計算機に対する制御を奪い、利用者を搾取するためのあらゆる悪意ある機能を実装し、自分たちのプログラムが何をしているのか——すなわち 解決策 ——を隠すために、あらゆる手段を用います。彼らは障壁を築いています。共有を破壊しています。災厄と絶望を生み出しています。彼らは人民、共同体、そして人類に逆らうことばかりしています。彼らはそのすべてを、非倫理的で不公正な利益 のために行っているのです。

ソフトウェアは人民に奉仕すべきであり、資本家たちの非倫理的で不公正な利益に奉仕すべきではありません。しかし、世界を悪くしているのは、まさに存在すべきではなく、この世界から一掃されるべき非倫理的で不公正なプロプライエタリ・ソフトウェア開発者たちです。とはいえ、プロプライエタリ・ソフトウェアを一気に排除することはできません。私たちはその代わりとなる自由ソフトウェアを開発し、計算に対する制御を徐々に取り戻していきます。

したがって、今こそ上の手順を逆順で行う時です。言い換えれば、私たちは次のことを行います。

  • 実行ファイル を分析する;
  • その実行ファイルが行っていることをする コード を復元しようとする;
  • そのコードが実装している アルゴリズム を見つける;
  • そのアルゴリズムが実装している 解決策 を逆向きにたどる;
  • その 問題 を解くために解決策を再実装する。

これは、命令コード に、そして 解決策 に変換することです。

このように逆順で行う実践こそが、リバースエンジニアリング、すなわち RE です。

なぜリバースエンジニアリングが重要なのか

リバースエンジニアリングが重要である最も大きな理由は、postmarketOS や LineageOS のような自由ソフトウェアのオペレーティングシステム を、さまざまな端末へ移植するのに役立つからです。これが、私がリバースエンジニアリングを学んでいる主な理由でもあります。

デスクトップ PC では、ソフトウェアの自由はずっと実現しやすく、たいていはさまざまな自由な GNU/Linux ディストリビューションを深刻な問題なく動かすことができます。しかし、スマートフォンやタブレットのようなモバイル機器ではそうではありません。

これらの機器には、ベンダー固有かつ機種固有の調整が数多くあり、自由な OS を普遍的に動かすための一つの万能解を見つけるのはほとんど不可能です。さらに悪いことに、製造者はそうした技術的詳細を隠すことを選び、Google でさえ device tree や vendor blob をもう公開しなくなりました。したがって今では、バイナリ のファームウェア blob を解析し、そのロジックを推測して、postmarketOS、LineageOS、あるいはほかの自由ソフトウェアのオペレーティングシステムをそれらの端末で動かさなければなりません。

実際、Free Software Foundation も Librephone プロジェクトの下でこの問題に取り組んでいます。このプロジェクトは、リバースエンジニアリングなしには継続できません。

ただし、リバースエンジニアリングの用途は自由な OS の移植に限られません。次のような場面でも非常に重要です。

  • プロプライエタリなプロトコルの詳細を突き止めること、
  • プロプライエタリ・ソフトウェアにおける悪意ある機能の証拠を見つけること、
  • デジタル制限管理の回避、
  • そしてそのほか多数。

リバースエンジニアリングのやり方

典型的なリバースエンジニアリングは、次の二段階から成ります。

  1. 実行ファイルをアセンブリに変換する;この過程は 逆アセンブル と呼ばれます。
  2. アセンブリコードを読んで、考え方を復元したり、等価なコードを書き直したりする。

これは、実行せずに実行ファイルそのものに対して直接行うこともでき、これは 静的解析 として知られます。あるいは、実行中に行うこともでき、これは 動的解析 として知られます。

静的解析の例

たとえば、次のようなものです。

#include <stdio.h>
#include <string.h>

const char *PASS = "weakpasswd";

int main()
{
    char buf[64];
    for (int i = 0; i < 3; i++)
    {
        printf("%d 回の試行が残っています。\nパスワード: ", 3 - i);
        fgets(buf, sizeof buf, stdin);
        if (strncmp(PASS, buf, strlen(PASS)))
            puts("パスワードが違います!\n");
        else
        {
            puts("正解です!\nリバースエンジニアリングの世界へようこそ!\n");
            break;
        }
    }
    return 0;
}

これを GCC でコンパイルします。

$ gcc -o ./demo ./demo.c

実行ファイルを radare2 で開きます。

$ r2 ./demo

すると、次のように表示されます。

[0x00000800]> 

では、実行ファイルを解析してみましょう。

[0x00000800]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
INFO: Analyze imports (af@@@i)
INFO: Analyze entrypoint (af@ entry0)
INFO: Analyze symbols (af@@@s)
INFO: Analyze all functions arguments/locals (afva@@F)
INFO: Analyze function calls (aac)
INFO: Analyze len bytes of instructions for references (aar)
INFO: Finding and parsing C++ vtables (avrr)
INFO: Analyzing methods (af @@ method.*)
INFO: Finding function preludes (aap)
INFO: Emulate functions to find computed references (aaef)
INFO: Recovering local variables (afva@@@F)
INFO: Type matching analysis for all functions (afft)
INFO: Propagate noreturn information (aanr)
INFO: Use -AA or aaaa to perform additional experimental analysis
INFO: Finding xrefs in noncode sections (e anal.in=io.maps.x; aav)
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0

関数一覧を表示します。

[0x00000800]> afl
0x00000750    1     16 sym.imp.strlen
0x00000760    1     16 sym.imp.__libc_start_main
0x00000770    1     16 sym.imp.__cxa_finalize
0x00000780    1     32 sym.imp.strncmp
0x000007a0    1     16 sym.imp.abort
0x000007b0    1     16 sym.imp.puts
0x000007c0    1     16 sym.imp.printf
0x000007d0    1     20 sym.imp.fgets
0x00000800    1     48 entry0
0x00000834    3     20 sym.call_weak_fn
0x00000860    4     48 sym.deregister_tm_clones
0x00000890    4     60 sym.register_tm_clones
0x000008cc    5     80 entry.fini0
0x00000920    1      8 entry.init0
0x000009f8    1     24 sym._fini
0x00000928    7    208 main
0x00000708    1     28 sym._init
0x00000730    1     32 fcn.00000730

では、main 関数へ移動して逆アセンブリを表示します。

[0x00000800]> s main
[0x00000928]> pdf
            ; DATA XREF from entry0 @ 0x820(r)
            ; DATA XREF from entry.fini0 @ 0x8e0(r)
┌ 208: int main (int argc);
│ `- args(x0) vars(5:sp[0x4..0x70])
│           0x00000928      fd7bb9a9       stp x29, x30, [sp, -0x70]!
│           0x0000092c      fd030091       mov x29, sp
│           0x00000930      f30b00f9       str x19, [var_10h]
│           0x00000934      ff6f00b9       str wzr, [var_6ch]          ; argc
│       ┌─< 0x00000938      29000014       b 0x9dc
│       │   ; CODE XREF from main @ 0x9e4(x)
│      ┌──> 0x0000093c      61008052       mov w1, 3
│      ╎│   0x00000940      e06f40b9       ldr w0, [var_6ch]
│      ╎│   0x00000944      2000004b       sub w0, w1, w0
│      ╎│   0x00000948      e103002a       mov w1, w0
│      ╎│   0x0000094c      00000090       adrp x0, 0
│      ╎│   0x00000950      00a02891       add x0, x0, str._d_retries_remaining._nPassword: ; 0xa28 ; "%d 回の試行が残っています。\nパスワード: " ; const char *format
│      ╎│   0x00000954      9bffff97       bl sym.imp.printf           ; int printf(const char *format)
│      ╎│   0x00000958      e00000f0       adrp x0, 0x1f000
│      ╎│   0x0000095c      00e447f9       ldr x0, [x0, 0xfc8]         ; [0x1ffc8:4]=0
│      ╎│                                                              ; reloc.stdin
│      ╎│   0x00000960      010040f9       ldr x1, [x0]                ; int size
│      ╎│   0x00000964      e0a30091       add x0, sp, 0x28            ; char *s
│      ╎│   0x00000968      e20301aa       mov x2, x1                  ; FILE *stream
│      ╎│   0x0000096c      01088052       mov w1, 0x40
│      ╎│   0x00000970      98ffff97       bl sym.imp.fgets            ; char *fgets(char *s, int size, FILE *stream)
│      ╎│   0x00000974      00010090       adrp x0, reloc.strlen       ; 0x20000
│      ╎│   0x00000978      00600191       add x0, x0, 0x58
│      ╎│   0x0000097c      130040f9       ldr x19, [x0]               ; [0xa18:4]=0x6b616577 ; "weakpasswd"
│      ╎│   0x00000980      00010090       adrp x0, reloc.strlen       ; 0x20000
│      ╎│   0x00000984      00600191       add x0, x0, 0x58
│      ╎│   0x00000988      000040f9       ldr x0, [x0]                ; [0xa18:4]=0x6b616577 ; "weakpasswd" ; const char *s
│      ╎│   0x0000098c      71ffff97       bl sym.imp.strlen           ; size_t strlen(const char *s)
│      ╎│   0x00000990      e10300aa       mov x1, x0
│      ╎│   0x00000994      e0a30091       add x0, sp, 0x28
│      ╎│   0x00000998      e20301aa       mov x2, x1                  ; size_t n
│      ╎│   0x0000099c      e10300aa       mov x1, x0                  ; const char *s2
│      ╎│   0x000009a0      e00313aa       mov x0, x19                 ; const char *s1
│      ╎│   0x000009a4      77ffff97       bl sym.imp.strncmp          ; int strncmp(const char *s1, const char *s2, size_t n)
│      ╎│   0x000009a8      1f000071       cmp w0, 0
│     ┌───< 0x000009ac      a0000054       b.eq 0x9c0
│     │╎│   0x000009b0      00000090       adrp x0, 0
│     │╎│   0x000009b4      00402991       add x0, x0, str.Wrong_password__n ; 0xa50 ; "パスワードが違います!\n" ; const char *s
│     │╎│   0x000009b8      7effff97       bl sym.imp.puts             ; int puts(const char *s)
│    ┌────< 0x000009bc      05000014       b 0x9d0
│    ││╎│   ; CODE XREF from main @ 0x9ac(x)
│    │└───> 0x000009c0      00000090       adrp x0, 0
│    │ ╎│   0x000009c4      00a02991       add x0, x0, str.Correct__nWelcome_to_the_world_of_reverse_engineering__n ; 0xa68 ; "正解です!\nリバースエンジニアリングの世界へようこそ!\n" ; const char *s
│    │ ╎│   0x000009c8      7affff97       bl sym.imp.puts             ; int puts(const char *s)
│    │┌───< 0x000009cc      07000014       b 0x9e8
│    ││╎│   ; CODE XREF from main @ 0x9bc(x)
│    └────> 0x000009d0      e06f40b9       ldr w0, [var_6ch]
│     │╎│   0x000009d4      00040011       add w0, w0, 1
│     │╎│   0x000009d8      e06f00b9       str w0, [var_6ch]
│     │╎│   ; CODE XREF from main @ 0x938(x)
│     │╎└─> 0x000009dc      e06f40b9       ldr w0, [var_6ch]
│     │╎    0x000009e0      1f080071       cmp w0, 2
│     │└──< 0x000009e4      cdfaff54       b.le 0x93c
│     │     ; CODE XREF from main @ 0x9cc(x)
│     └───> 0x000009e8      00008052       mov w0, 0
│           0x000009ec      f30b40f9       ldr x19, [var_10h]
│           0x000009f0      fd7bc7a8       ldp x29, x30, [sp], 0x70
└           0x000009f4      c0035fd6       ret

radare2 は、アセンブリコードだけでなく、各命令の対応するアドレスや、それらの間の呼び出し関係も示してくれます。

しかし、まだあまり明確ではありません。そこで、逆アセンブリをグラフで表示してみましょう。

[0x00000928]> VV

すると、次のように表示されます。

radare2 の逆アセンブリ・グラフ表示

グラフでは、t は条件が真のときに跳ぶことを意味します。f は条件が偽のときに跳ぶことを意味します。v は無条件ジャンプを意味します。

こうなると、ずっと理解しやすくなります。radare2 はすでにアセンブリコードの各部分の論理関係を把握しているので、あとはその各ブロック自体を理解すればよいのです。

グラフの [0x93c] ブロックの末尾には、パスワード確認のロジックを簡単に見つけられます。

; int strncmp(const char *s1, const char *s2, size_t n)
bl sym.imp.strncmp
cmp w0, 0
b.eq 0x9c0

ここでは sym.imp.strncmp を呼び出し、その関数が w0 に返した値を確認しています。もしそれが 0 に等しければ、成功を示す [0x9c0] ブロックへ跳びます。そうでなければ、失敗を示す [0x9b0] ブロックへ進みます。

AArch64 の呼び出し規約によれば、比較される二つの文字列へのポインタはそれぞれ x0x1 レジスタに入っていて、その後で sym.imp.strncmp を呼び出します。結果は x0 レジスタに格納されます(w0x0 の下位 32 ビットにすぎません)。二つの文字列が完全に同じなら、結果は 0 になります。

では後ろに戻って見てみましょう。

mov x0, x19

さらにさかのぼって、x19 を操作している命令を探します。

; [0xa18:4]=0x6b616577
; "weakpasswd"
ldr x19, [x0]

radare2 は、すでに極秘情報を教えてくれていました。 では試してみましょう。

$ ./demo
3 回の試行が残っています。
パスワード: weakpasswd
正解です!
リバースエンジニアリングの世界へようこそ!

以上です。

グラフをさらに詳しく見ると、パスワード以上のことも分かります。たとえば、グラフの中に [0x9dc] -> [0x93c] -> [0x9b0] -> [0x9d0] -> [0x9dc] という巡回が見えます。グラフにおける巡回は、しばしばループの制御フローを示します。これはリバースエンジニアリングにおいて非常に重要な考え方です。

[0x9dc] ブロックでは次のようになっています。

ldr w0, [var_6ch]
cmp w0, 2
b.le 0x93c

これは変数 var_6ch をレジスタに読み込み、それを 2 と比較しています。もしそれより大きければ [0x9e8] ブロックに進んで終了します。そうでなければ [0x93c] ブロックに進みます。

[0x93c] ブロックの末尾を見てください。すでに分かっているように、パスワード確認に成功すれば [0x9c0] ブロックへ跳び、成功メッセージを表示し、その後 [0x9e8] ブロックへ跳んで、そこでプログラムが終了します。

では、確認に失敗したらどうなるのでしょうか。[0x9b0] ブロックへ進みます。そこでは単にエラーメッセージを表示するだけなので、特に面白いものはありません。では [0x9d0] ブロックへ進みましょう。ここで興味深いものが見つかります。

ldr w0, [var_6ch]
add w0, w0, 1
str w0, [var_6ch]

これは var_6ch をレジスタに読み込み、値を 1 増やし、その値を再び保存しています。そして [0x9dc] ブロックに戻り、そこで var_6ch の値を確認します。

q を二回押してプロンプトに戻ります。afvafvd を実行して、変数とその情報を一覧表示します。

[0x000009b0]> afv
arg int argc @ x0
var int64_t var_70h @ sp+0x0
var int64_t var_70h_2 @ sp+0x8
var int64_t var_10h @ sp+0x10
var char * s2 @ sp+0x28
var int64_t var_6ch @ sp+0x6c
[0x000009b0]> afvd
arg argc = 0x00000000 0x00010102464c457f   .ELF.... @ pstate
var var_6ch = 0x0017806c = (qword)0x0000000000000000
var s2 = 0x00178028 = ""
var var_10h = 0x00178010 = (qword)0x0000000000000000
var var_70h = 0x00178000 = (qword)0x0000000000000000
var var_70h_2 = 0x00178008 = (qword)0x0000000000000000

var_6ch の値は int64_t であり、初期値は 0 であることが分かります。

ここから、var_6ch はカウンタだと推測できます。初期値は 0 です。パスワード確認に失敗すると、カウンタは 1 増えます。2 を超えると、プログラムはもうパスワードを尋ねずに終了します。これが C コードの for (int i = 0; i < 3; i++) に対応していると分かります。

しかし、これはとても単純な例です。現実の世界では、プロプライエタリ・ソフトウェアの開発者はしばしば対解析・対デバッグ技術を使うため、もっと難しくなります。

コンパイラ最適化を理解する

では、さらに単純なこのコードをコンパイルしてみましょう。

#include <stdio.h>

int main()
{
    int a;
    scanf("%d", &a);
    printf("%d", a % 65536);
    return 0;
}

これは入力から整数を読み取り、その 65536 に対する剰余を出力します。

main 関数を逆アセンブルしてみましょう。

[0x00000700]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
......
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0
[0x00000700]> s main
[0x00000828]> pdf
            ; DATA XREF from entry0 @ 0x720(r)
            ; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 76: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│           0x00000828      fd7bbea9       stp x29, x30, [sp, -0x20]!
│           0x0000082c      fd030091       mov x29, sp
│           0x00000830      e0730091       add x0, sp, 0x1c
│           0x00000834      e10300aa       mov x1, x0
│           0x00000838      00000090       adrp x0, 0
│           0x0000083c      00602291       add x0, x0, 0x898
│           0x00000840      94ffff97       bl sym.imp.__isoc23_scanf
│           0x00000844      e01f40b9       ldr w0, [var_1ch]
│           0x00000848      e103006b       negs w1, w0
│           0x0000084c      003c0012       and w0, w0, 0xffff
│           0x00000850      213c0012       and w1, w1, 0xffff
│           0x00000854      0044815a       csneg w0, w0, w1, mi
│           0x00000858      e103002a       mov w1, w0
│           0x0000085c      00000090       adrp x0, 0
│           0x00000860      00602291       add x0, x0, 0x898           ; const char *format
│           0x00000864      97ffff97       bl sym.imp.printf           ; int printf(const char *format)
│           0x00000868      00008052       mov w0, 0
│           0x0000086c      fd7bc2a8       ldp x29, x30, [sp], 0x20
└           0x00000870      c0035fd6       ret

少し混乱するかもしれません。なぜなら、ここには剰余演算(sdivmulsub など)が見えないからです。代わりに、次のような命令が見えます。

and w0, w0, 0xffff
and w1, w1, 0xffff

これは、現代のコンパイラはかなり賢く、ある種の特別な計算パターンを検出して、より単純な命令へ最適化できるからです。65536 は 2^16 なので、65536 で割った余りは、二進数で見れば下位 16 ビットそのものです。ビット演算だけで十分であり、加算器や乗算器は必要ありません。

しかし、65536 を別の値、たとえば 50000 に変えると、もはやそうではありません。

            ; DATA XREF from entry0 @ 0x720(r)
            ; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 80: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│           0x00000828      fd7bbea9       stp x29, x30, [sp, -0x20]!
│           0x0000082c      fd030091       mov x29, sp
│           0x00000830      e0730091       add x0, sp, 0x1c
│           0x00000834      e10300aa       mov x1, x0
│           0x00000838      00000090       adrp x0, 0
│           0x0000083c      00602291       add x0, x0, 0x898
│           0x00000840      94ffff97       bl sym.imp.__isoc23_scanf
│           0x00000844      e01f40b9       ldr w0, [var_1ch]
│           0x00000848      016a9852       mov w1, 0xc350
│           0x0000084c      020cc11a       sdiv w2, w0, w1
│           0x00000850      016a9852       mov w1, 0xc350
│           0x00000854      417c011b       mul w1, w2, w1
│           0x00000858      0000014b       sub w0, w0, w1
│           0x0000085c      e103002a       mov w1, w0
│           0x00000860      00000090       adrp x0, 0
│           0x00000864      00602291       add x0, x0, 0x898           ; const char *format
│           0x00000868      96ffff97       bl sym.imp.printf           ; int printf(const char *format)
│           0x0000086c      00008052       mov w0, 0
│           0x00000870      fd7bc2a8       ldp x29, x30, [sp], 0x20
└           0x00000874      c0035fd6       ret

ここでは sdivmulsub の命令が見えます。これらが組み合わさって剰余演算を実現しています。

では動的解析はどうか

動的解析には GDB が必要です。GDB を使えば、逆アセンブリの表示、ブレークポイントの設定、メモリやレジスタの確認、さらにはプログラムの実行中にレジスタや変数の値を変更することまでできます。

私はまだ動的解析を深く掘り下げてはいません。将来、動的解析を説明する新しいブログ記事を書くかもしれません。

推奨リソース

ここで説明したことは、氷山の一角にすぎません。リバースエンジニアリングを学ぶには、さらに深く掘り下げ、もっと練習し、実際に手を動かして学ぶ必要があります。以下に推奨リソースを挙げます。

  1. Pwn.college は始めるのに良い場所です。入門 dojo で x86_64 アセンブリを学び、その後 Intro to Cybersecurity モジュールの Reverse Engineering 部分へ進めます。
  2. Reverse Engineering for Beginners はリバースエンジニアリングに関する非常に良い本です。これは自由な本であり、CC BY-SA 4.0 の下で公開され、ここ からダウンロードできます。
  3. ARM は AArch64 アーキテクチャ向けの公式ドキュメントを提供しています。
  4. ここ から crackme をダウンロードして、リバースエンジニアリングの技能を練習できます。
  5. ここ は、リバースエンジニアリング学習用の多くの資料を含む GitHub リポジトリです。

免責事項

この記事は教育目的のみを意図しています。何をしてはいけないかについては、あなたの地域の法律を確認してください。私はあなたのいかなる行動に対しても責任を負いません。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。