ここ数週間、私は x86_64 アセンブリ、AArch64 アセンブリ、そしてさまざまな基礎を学んできました。最近、ついに人生で初めてリバースエンジニアリングを体験しました。この体験について、もっと詳しく共有できることをうれしく思います。
リバースエンジニアリングとは何か
コンピュータは実のところ、かなり頭の悪い機械です。自分で問題を解決することはできません。解決策を実行するだけです。より正確に言えば、人間から与えられた命令を一つずつ順番に実行するだけです。このような機械で問題を解くには、通常、次のような手順を踏みます。
- 問題 を分析する;
- その問題に対する 解決策 を見つける;
- アルゴリズム を設計して解決策を実装する;
- コード を書いてアルゴリズムを実装する;
- コンパイルしてコードから 実行ファイル を生成する;
- その実行ファイルを、その頭の悪いコンピュータに与えて問題を解かせる。
これは、解決策 を コード に変換し、さらに 命令 に変換する手順です。
通常、コードを読めば、そのプログラムが何をしているかを容易に理解できます。これは、解決策と命令の中間にあるものです。1970年代には、コードはふつう人民のあいだで共有されていたので、誰もが共同体の中で解決策を学び、改良し、共有することができました。
しかし、あの極めて邪悪なプロプライエタリ・ソフトウェア開発の怪物どもが現れて以来、状況は変わりました。彼らは人民から コード へのアクセスを拒み、利用者の計算機に対する制御を奪い、利用者を搾取するためのあらゆる悪意ある機能を実装し、自分たちのプログラムが何をしているのか——すなわち 解決策 ——を隠すために、あらゆる手段を用います。彼らは障壁を築いています。共有を破壊しています。災厄と絶望を生み出しています。彼らは人民、共同体、そして人類に逆らうことばかりしています。彼らはそのすべてを、非倫理的で不公正な利益 のために行っているのです。
ソフトウェアは人民に奉仕すべきであり、資本家たちの非倫理的で不公正な利益に奉仕すべきではありません。しかし、世界を悪くしているのは、まさに存在すべきではなく、この世界から一掃されるべき非倫理的で不公正なプロプライエタリ・ソフトウェア開発者たちです。とはいえ、プロプライエタリ・ソフトウェアを一気に排除することはできません。私たちはその代わりとなる自由ソフトウェアを開発し、計算に対する制御を徐々に取り戻していきます。
したがって、今こそ上の手順を逆順で行う時です。言い換えれば、私たちは次のことを行います。
- 実行ファイル を分析する;
- その実行ファイルが行っていることをする コード を復元しようとする;
- そのコードが実装している アルゴリズム を見つける;
- そのアルゴリズムが実装している 解決策 を逆向きにたどる;
- その 問題 を解くために解決策を再実装する。
これは、命令 を コード に、そして 解決策 に変換することです。
このように逆順で行う実践こそが、リバースエンジニアリング、すなわち RE です。
なぜリバースエンジニアリングが重要なのか
リバースエンジニアリングが重要である最も大きな理由は、postmarketOS や LineageOS のような自由ソフトウェアのオペレーティングシステム を、さまざまな端末へ移植するのに役立つからです。これが、私がリバースエンジニアリングを学んでいる主な理由でもあります。
デスクトップ PC では、ソフトウェアの自由はずっと実現しやすく、たいていはさまざまな自由な GNU/Linux ディストリビューションを深刻な問題なく動かすことができます。しかし、スマートフォンやタブレットのようなモバイル機器ではそうではありません。
これらの機器には、ベンダー固有かつ機種固有の調整が数多くあり、自由な OS を普遍的に動かすための一つの万能解を見つけるのはほとんど不可能です。さらに悪いことに、製造者はそうした技術的詳細を隠すことを選び、Google でさえ device tree や vendor blob をもう公開しなくなりました。したがって今では、バイナリ のファームウェア blob を解析し、そのロジックを推測して、postmarketOS、LineageOS、あるいはほかの自由ソフトウェアのオペレーティングシステムをそれらの端末で動かさなければなりません。
実際、Free Software Foundation も Librephone プロジェクトの下でこの問題に取り組んでいます。このプロジェクトは、リバースエンジニアリングなしには継続できません。
ただし、リバースエンジニアリングの用途は自由な OS の移植に限られません。次のような場面でも非常に重要です。
- プロプライエタリなプロトコルの詳細を突き止めること、
- プロプライエタリ・ソフトウェアにおける悪意ある機能の証拠を見つけること、
- デジタル制限管理の回避、
- そしてそのほか多数。
リバースエンジニアリングのやり方
典型的なリバースエンジニアリングは、次の二段階から成ります。
- 実行ファイルをアセンブリに変換する;この過程は 逆アセンブル と呼ばれます。
- アセンブリコードを読んで、考え方を復元したり、等価なコードを書き直したりする。
これは、実行せずに実行ファイルそのものに対して直接行うこともでき、これは 静的解析 として知られます。あるいは、実行中に行うこともでき、これは 動的解析 として知られます。
静的解析の例
たとえば、次のようなものです。
#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
すると、次のように表示されます。

グラフでは、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 の呼び出し規約によれば、比較される二つの文字列へのポインタはそれぞれ x0 と x1 レジスタに入っていて、その後で sym.imp.strncmp を呼び出します。結果は x0 レジスタに格納されます(w0 は x0 の下位 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 を二回押してプロンプトに戻ります。afv と afvd を実行して、変数とその情報を一覧表示します。
[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
少し混乱するかもしれません。なぜなら、ここには剰余演算(sdiv、mul、sub など)が見えないからです。代わりに、次のような命令が見えます。
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
ここでは sdiv、mul、sub の命令が見えます。これらが組み合わさって剰余演算を実現しています。
では動的解析はどうか
動的解析には GDB が必要です。GDB を使えば、逆アセンブリの表示、ブレークポイントの設定、メモリやレジスタの確認、さらにはプログラムの実行中にレジスタや変数の値を変更することまでできます。
私はまだ動的解析を深く掘り下げてはいません。将来、動的解析を説明する新しいブログ記事を書くかもしれません。
推奨リソース
ここで説明したことは、氷山の一角にすぎません。リバースエンジニアリングを学ぶには、さらに深く掘り下げ、もっと練習し、実際に手を動かして学ぶ必要があります。以下に推奨リソースを挙げます。
- Pwn.college は始めるのに良い場所です。入門 dojo で x86_64 アセンブリを学び、その後 Intro to Cybersecurity モジュールの Reverse Engineering 部分へ進めます。
- Reverse Engineering for Beginners はリバースエンジニアリングに関する非常に良い本です。これは自由な本であり、CC BY-SA 4.0 の下で公開され、ここ からダウンロードできます。
- ARM は AArch64 アーキテクチャ向けの公式ドキュメントを提供しています。
- ここ から crackme をダウンロードして、リバースエンジニアリングの技能を練習できます。
- ここ は、リバースエンジニアリング学習用の多くの資料を含む GitHub リポジトリです。
免責事項
この記事は教育目的のみを意図しています。何をしてはいけないかについては、あなたの地域の法律を確認してください。私はあなたのいかなる行動に対しても責任を負いません。