去る8月28日に、私が執筆に参加した「Binary Hacks Rebooted」が無事に発売されました。
「Binary Hacks Rebooted」はLinuxを前提に書かれたHackが多いです(特に前半)。しかし、読者の中にはMacを使っているという方も一定数いるのではないかと思います。というか、Macを使っている人にも読んでもらいたいです。
もちろん、Mac上でもDocker, multipass, QEMU等を使ってLinuxを動かせば、本の内容は再現できます。しかし、バイナリアンの気持ちとしては、マシンでネイティブに動いているOSを使ってHackしたいものではないでしょうか。少なくとも私はそうです。(まあMac上でネイティブにLinuxを入れるという手もありますが、それだと機材がMacである必要がないのでは?と私は思ってしまいます。)
また、x86-64 Linuxであれば本の内容がそのまま動くのは当たり前(※)なので、違う環境でどうなるか試行錯誤することによって圧倒的成長💪💪💪が見込まれます。(※と言いつつ、Linuxでもディストロによって、あるいはUbuntuであってもバージョンが違うと無変更で動くかはわからないわけですが。本に載っている実行例はUbuntu 22.04のものが多いと思います。)
というわけで、MacでmacOSを使ってBinary Hacks Rebootedの内容を試したいです。
「はじめに」の「Macユーザーのための読み方ガイド」に書いたことをもう一度繰り返すと、Macで本の内容を試す際は
- OSの違い(Linux vs macOS)
- 実行ファイルフォーマットの違い(ELF vs Mach-O)
- 命令セットアーキテクチャの違い(x86-64 vs AArch64)
- 2020年暮れにAArch64への移行が始まったので、買い替えサイクルを考えるとIntel Macはそろそろ無視できる数になってきたかもしれません。
- ツールチェインの違い(GNU vs LLVM)
について意識する必要があります。と言っても、意識しろと言われて対処できたら苦労はしない!という読者もいるかもしれないので、著者陣の中の数少ないMacユーザーとして、私が「Binary Hacks Rebootedの内容をMacで試すとどうなるか」を実践してみたいと思います。
(執筆裏話:執筆中、原稿を管理するgitリポジトリに「ファイル名の大文字と小文字が違う2つのファイル」が紛れ込んで対処が面倒だったことがありました。foo.txt
と Foo.txt
みたいなファイルが独立して存在するわけですね。Linuxでデフォルトで使われるファイルシステムだとそういうファイルを区別できますが、Macのデフォルトはファイル名の大文字と小文字を区別しないので実体が同一となり、git的には「foo.txt
の中身が Foo.txt
で上書きされた状態」か「Foo.txt
の中身が foo.txt
で上書きされた状態」のどちらかになります。何も変更していないのにgit的には「変更がある」状態になってしまう。そうするとrebase等の操作が非常に面倒になります。もちろん私もLinuxの環境を持っているので対処はできますけどね。)
今回は「1章 イントロダクション」の内容を扱いますが、今後の記事で取り上げる順番は必ずしも本の通りとは限りません。
本のサンプルコードを写経するのが面倒な場合はサポートリポジトリから取ってきてください。
Hack #1 未知のバイナリの読み方
Macにも file
コマンドはあるので、概ね本文の通りに実行できるのではないかと思います。
ただし、本文はLinuxで実行しているので ls
コマンドの実体がELFフォーマットとなっていますが、MacはMach-Oフォーマットを使うのでその辺の出力が違います。readelf
コマンドもありません。
また、Macのtarコマンドは --old-archive
オプションを受け付けず、デフォルトで「古い形式」を出力するようです。
ちなみに、最近のMacのデフォルトのシェルはzshですが、zshだとダブルクォートを使った
$ echo "Hello, World!" > hello.txt
は期待通りに動作しません。なので本文ではシングルクォートを使いました(執筆裏話)。
Hack #2 アセンブリ入門
まず、本文では gcc
コマンドを使っており、Macでも gcc
コマンドは使えますが、Macの gcc
コマンドは実体が clang
なので、ここでは混乱を避けるために明示的に clang
コマンドを使います(これは今後も同様です)。
また、Macの objdump
はLLVMベースで、GNUのものと受け付けるオプションが若干異なります。特に、 --disassemble
オプションの代わりに --disassemble-symbols
オプションを使うようです。MacではCのシンボルにはアセンブリレベルでは先頭にアンダースコアがつくので、アンダースコアをつけた名前を指定します。
アーキテクチャについてですが、最近はApple Silicon Macの人が多いと思われるので、AArch64のアセンブリを解説します。Intel Macを使っている人は本も参考にしてください。
$ clang -o add add.c
$ objdump --disassemble-symbols=_add --no-addresses add
add: file format mach-o arm64
Disassembly of section __TEXT,__text:
<_add>:
d10043ff sub sp, sp, #16
f90007e0 str x0, [sp, #8]
f90003e1 str x1, [sp]
f94007e8 ldr x8, [sp, #8]
f94003e9 ldr x9, [sp]
8b090100 add x0, x8, x9
910043ff add sp, sp, #16
d65f03c0 ret
機械語とアセンブリ言語が表示されました。これを読み解いていきます。
左側に出ている16進法8桁が機械語です。x86-64は可変長ですが、AArch64のA64命令セットは4バイト(32ビット)の固定長です。人間が読むのは右側の逆アセンブル結果です。
本にあるx86-64の例はROP/JOP/COPなどの攻撃への対策となる endbr64
という命令で始まっていました。Armにも類似の命令として BTI
というものがあるようですが、ここでは使われていません。ArmではFEAT_BTIという拡張機能でBranch Target Identificationをやるようですが、私が使っているApple M1はこれに対応していません。
関数の最初の sub sp, sp, #16
というのはスタックポインター sp
の値を16減らすという意味です。AArch64ではスタックはアドレスが小さくなる方に伸びるので、これは使えるスタック領域を16バイト分増やすという意味です。ややこしいですね。
スタックに確保した16バイトで何をするのかというと、ローカル変数 a
と b
を保存します。最初の引数は x0
レジスターで渡され、2番目の引数は x1
レジスターで渡されるので、str
命令(ストア命令)でそれらをスタックに保存しています。[sp, #8]
はアドレス sp + 8
の内容物を意味します。
変数をスタックに保存したのはいいのですが、AArch64では演算を行う際には演算対象をレジスターに持ってこないといけません。そういうわけで、その次の ldr
命令(ロード命令)でローカル変数の内容を再びレジスター x8
と x9
に配置しています。
レジスターで受け取った引数をスタックに保存して、それを演算のためにまた読み出すなんて、無駄なことをしていると思いませんか?実際この簡単な関数ではこれは無駄で、コンパイル時に最適化オプション -O
を有効にするとスタックの操作は全部消えます。
実際の加算は add x0, x8, x9
命令で行っています。最初のオペランドである x0
は結果を保存するレジスターで、残りの x8
と x9
が入力です。ちなみにx86-64では add
命令は2オペランドで、オペランドの一つが入力と出力を兼ねています。
add sp, sp, #16
では確保したスタックを元に戻しています。
ret
は関数の呼び出し元に戻る命令です。x86-64と同じですね。
main
関数も見ておきます。
$ objdump --disassemble-symbols=_main --no-addresses add
add: file format mach-o arm64
Disassembly of section __TEXT,__text:
<_main>:
d10083ff sub sp, sp, #32
a9017bfd stp x29, x30, [sp, #16]
910043fd add x29, sp, #16
b81fc3bf stur wzr, [x29, #-4]
d2800020 mov x0, #1
d2800041 mov x1, #2
97fffff2 bl 0x100003f58 <_add>
a9417bfd ldp x29, x30, [sp, #16]
910083ff add sp, sp, #32
d65f03c0 ret
この中で最も重要なのが bl ... <_add>
で、この命令が add
関数を呼び出しています。x86-64では同等の命令は call
ですが、AArch64では bl
と言います。これはBranch with Linkの略で、Branchはジャンプを意味するようです(分岐に限らない)。Linkは呼び出し元の情報を保存する、みたいな意味だと思います。
Armの機械語やアセンブリの命令については、Arm Architecture Reference Manualというのがオフィシャルな情報源です。Arm Architecture Reference Manual for A-profile architecture から入手できますが、執筆時点の最新版(K.a)で1万4千ページあります。ちなみにIntelのマニュアル(Intel SDM)は5000ページ程度です。ArmのやつにはAArch64に加えてAArch32の情報も載っているからでかいのでしょうか。
Hack #3 Hello, World!再訪
これも本文には「x86-64上で動くLinuxを対象とします」とあります。ですが、途中まで(C言語だけで書かれた分)ならmacOSでもそのまま動きます。Unixですからね。
標準CのHello worldは当然macOS/Clangでも同様にコンパイル・実行できます。
$ clang -o hello_c hello.c
$ ./hello_c
Hello, World!
write
システムコールを使ったやつも同様にコンパイル・実行できます(Unixなので)。
$ clang -o hello_c_syscall hello_syscall.c
$ ./hello_c_syscall
Hello, World!
syscall
関数はどうでしょうか。
$ clang -o hello_c_syscall_2 hello_syscall_2.c
hello_syscall_2.c:3:18: warning: 'syscall' is deprecated: first deprecated in macOS 10.12 - syscall(2) is unsupported; please switch to a supported interface. For SYS_kdebug_trace use kdebug_signpost(). [-Wdeprecated-declarations]
int main(void) { syscall(SYS_write, 1, "Hello, World!\n", 14); }
^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/unistd.h:748:6: note: 'syscall' has been explicitly marked deprecated here
int syscall(int, ...);
^
1 warning generated.
$ ./hello_c_syscall_2
Hello, World!
動きはするものの、syscall
関数は非推奨というようなメッセージが出てきました(私はmacOS 13で試しています)。
アセンブリ言語でのHello worldですが、macOS(というかDarwin)のシステムコールのABIは一般ユーザーが使うことを想定していないようです。なので、ここでは詳しい解説はしません。どうしても試したい場合は below/HelloSilicon: An introduction to ARM64 assembly on Apple Silicon Macs などのページを参照してください。
【追記】昔のGo言語はmacOS上で直接システムコールを呼び出していたようですが、ABIの不安定性によりlibSystem経由に変更しています:
- runtime: failed build, compile and use compiled binary on macOS Sierra Beta 4(16A270f) · Issue #16570 · golang/go
- cmd/link: use libsystem_kernel.dylib or libSystem.dylib for syscalls on macOS · Issue #17490 · golang/go
【/追記】
というわけで、Binary Hacks Rebootedの第1章をMacで実践してみました。私はmacOS (Darwin) に関する深い知識を持ち合わせているわけではないので調べつつにはなりますが、今後も不定期的に記事を書いていきたいと思います。