はじめに
「社内勉強会」は、弊社の社長(組み込みのスペシャリスト)が書いた資料をもとに、学んだことを記録していくシリーズです。
第1回のテーマは「コンパイラが使えないとき、バグをどう直すか」。
その答えは、機械語の直接書き換えでした。
コードの編集もコンパイルもなしに、バグを直してしまう。
普段 Visual Studio で作業している私には、理解しがたい世界です。
この記事でやること
- 完成したプログラムの文字を、コンパイルなしで書き換える
- ループの回数を、1バイトの書き換えで変える
- 空きのない場所へ、あとから処理を割り込ませる
補足
資料は社長の了承を得たうえで、図や例は転載せず、私の理解でまとめました。
資料と同じ現象を小さなサンプルで再現し、実際の機械語も載せています。
低レイヤーは専門外のため、誤りがあればコメントで教えていただけると幸いです。
資料の内容:コンパイラなしでバグを直す
資料が扱っていたのは、1人1台のPCがなかった時代の話です。
バグを直したくても、コンパイラを使えない場面があったそうです。
そんなときは、機械語(CPU が直接読む命令の数字の列)を直接書き換えて、その場を乗り切ったといいます。
こうした、完成したプログラムの直接書き換えは、「バイナリパッチ」と呼ばれることがあるようです。
サンプルプログラム
資料の内容を再現するため、小さなサンプルを用意しました。
文字を出し、* を10個並べ、最後に Bye. と出すだけのプログラムです。
#include <unistd.h>
/* 実験用の空き領域(何もしない NOP 命令を16個並べておく) */
__attribute__((noinline)) void cave(void)
{
__asm__ volatile(".rept 16\n nop\n .endr");
}
int main(void)
{
write(1, "Hello, World!\n", 14);
for (int i = 0; i != 10; i++) {
write(1, "*", 1);
}
write(1, "\n", 1);
write(1, "Bye.\n", 5);
return 0;
}
関数 cave は、あとで処理を書き込むための空き領域として置いています。
ARM64 の Linux で、次のようにビルドして実行します。
gcc -O0 -no-pie -fno-pic -o hello hello.c
./hello
Hello, World!
**********
Bye.
動作確認の環境
ARM64(aarch64)向けに GCC 13.3 でクロスビルドし、エミュレータ(QEMU)上で動かして確認しました。
機械語やアドレスは、CPU の種類やコンパイラのバージョンで変わります。
お試しの際は、objdump -d hello で、環境に応じた数値を確かめてください。
このサンプルでは、アドレスから 0x400000 を引くと、ファイル先頭からの位置になります。
たとえば、アドレス 0x400738 の命令は、ファイルの 0x738 バイト目にあります。
機械語を書き換えてみる
実験1:HelloWorld は、CPU からどう見えているのか
資料では、実際の機械語を並べたうえで、「ここが HelloWorld の出力にあたる部分」と示されていました。
ソースコードで見慣れた "Hello, World" も、プログラムのファイルの中では、数字の列として入っています。
サンプルで、ファイル内の位置を探してみます。
strings -t x hello | grep Hello
xxd -s 0x790 -l 14 hello
790 Hello, World!
00000790: 4865 6c6c 6f2c 2057 6f72 6c64 210a Hello, World!.
左端がファイル先頭からの位置(0x790)、真ん中が中身の16進数、右端が文字です。
48 が H、65 が e、末尾の 0a が改行にあたります。文字もプログラムの中では、文字コードという数字で並んでいるわけです。
実験2:HelloWorld を書き換える
World の W は、先ほどの 0x790 から7バイト進んだ 0x797 にあります。
そこから5バイトを、文字コード(4b 69 74 74 79)に書き換えます。
cp hello hello_kitty
echo "00000797: 4b69 7474 79" | xxd -r - hello_kitty
./hello_kitty
これで何がどう変わるのかというと…
Hello, Kitty!
**********
Bye.
コードの編集もコンパイルもしていないのに、とっても可愛くなりました!
書き換え前後の中身は、次のとおりです。
(前)00000797: 576f 726c 64 World
(後)00000797: 4b69 7474 79 Kitty
ソースコードもコンパイラも、一度も登場していません。
World と Kitty はどちらも5文字なので、ほかの部分がずれることなく入れ替えられます。
実験3:ループを 10 回から 15 回に
繰り返しの回数を決めている数字を探して、書き換えます。
objdump -d hello でループの部分を見ると、次の2行が見つかります。
400738: 7100281f cmp w0, #0xa
40073c: 54fffec1 b.ne 400714 <main+0x24>
cmp w0, #0xa は「カウンターが 10(0xa)か比べる」命令、b.ne は「10 でなければループの先頭に戻る」命令です。
この cmp の機械語 7100281f は、ファイルの中では 1f 28 00 71 の順に並んでいます。
このうち 28 が 10 を表す部分で、位置は 0x738 から1バイト進んだ 0x739 です。
命令の中では、数字が2ビット左にずれた形で入っているため、10 は 0x28、15 は 0x3c になります(今回は w0 との比較で、数字が63以下の場合)。
なぜ「2ビット左にずれる」のか(クリックで補足を見る)
cmp w0, #10 の機械語 0x7100281f を、2進数で書くと次のとおりです。
0111 0001 0000 0000 0010 1000 0001 1111
この32ビットは、役割ごとに、次のように区切られています。
ビット 31-23 22 21-10 9-5 4-0
中身 命令の種類 0 数字(10) Rn(w0) Rd
数字を入れる欄は、ビット10から始まります(ビットは右端を0番として数えます)。
一方、ファイルの中では、32ビットが8ビットずつのバイトに区切られて、後ろのバイトから順に並びます。そのため、71 00 28 1f が、1f 28 00 71 の順になります。
0x28 のバイト(ビット15〜8)の中身は、次のとおりです。
ビット 15 14 13 12 11 10 | 9 8
値 0 0 1 0 1 0 | 0 0
└ 数字の下位6ビット(=10) ┘ └ Rn の上位2ビット
(w0 なので 0)
数字の欄はビット10から、バイトの区切りはビット8から始まります。
その差が2ビットなので、バイトの中では、数字が2ビット左にずれて、4倍の値になります。
10 × 4 = 40 = 0x28
15 × 4 = 60 = 0x3c
この説明が成り立つのは、次の2つの条件を満たすときです。
- 比べる相手が
w0で、バイトの下位2ビットが 0 であること - 数字が63以下で、このバイトの6ビットに収まること
64以上になると、数字が次のバイトにはみ出します。
28 を 3c に、1バイトだけ書き換えます。
cp hello hello_15
echo "00000739: 3c" | xxd -r - hello_15
./hello_15
Hello, World!
***************
Bye.
* が15個に増えました。
書き換え後の objdump では、cmp w0, #0xf に変わっています。
ソースコードの 10 を 15 に直すのと同じことを、完成したプログラムの上で、たった1バイトの書き換えで行ったわけです。
実験4:隙間のないところに、処理を割り込ませる
資料の中で、いちばんオモシロかった部分です。
既存のプログラムには、処理を書き足す余白がありません。それでも資料では、次の手順で、あとから処理を割り込ませていました。
- 割り込ませたい場所の命令を、別の場所へ飛ぶ命令(ジャンプ)に書き換える
- 飛んだ先の空き領域に、追加したい処理を書く
- ジャンプで上書きして消した、元の命令も、空き領域で実行する
- 元の場所の続きへ、ジャンプで戻る
追加した処理のあとで元の流れに戻るので、もともとの動きは壊れません。
飛び先の空き領域は、「コードケイブ」と呼ばれることがあるようです。
サンプルでやってみます。
Bye. を出す直前に、Inserted! と出す処理を割り込ませます。
Bye. を出す処理の先頭は、アドレス 0x400754 にあります。
400754: d28000a2 mov x2, #0x5
この命令を、空き領域(アドレス 0x4006a8)へ飛ぶ命令に書き換えます。
400754: 17ffffd5 b 4006a8 <cave>
ジャンプ命令(b)には、飛び先までの距離を4で割った数が入っています。
今回は 0x4006a8 と 0x400754 の差 -172 を4で割った -43 が入り、機械語は d5 ff ff 17 になります。
空き領域には、次の処理を書き込みます。
4006a8: d2800142 mov x2, #0xa // 書く文字数(10)
4006ac: 100000a1 adr x1, 4006c0 // 文字列の場所
4006b0: 52800020 mov w0, #0x1 // 標準出力
4006b4: 97ffff9f bl 400530 <write@plt> // 文字を出す
4006b8: d28000a2 mov x2, #0x5 // ← 上書きして消した、元の命令
4006bc: 14000027 b 400758 // 元の場所の続きへ戻る
4006c0: "Inserted!\n" // 出す文字列(10バイト)
4006b8 の mov x2, #0x5 は、ジャンプで上書きして消してしまった命令です。
これを空き領域でもう一度実行してから、続きへ戻ります。
ジャンプと空き領域の中身を、まとめて書き込みます。
cp hello hello_cave
xxd -r - hello_cave <<'EOF'
00000754: d5ff ff17
000006a8: 4201 80d2 a100 0010 2000 8052 9fff ff97
000006b8: a200 80d2 2700 0014 496e 7365 7274 6564
000006c8: 210a
EOF
./hello_cave
Hello, World!
**********
Inserted!
Bye.
main の中には1バイトの空きもなかったのに、Inserted! を割り込ませられました。
なお、書き換え後の空き領域を objdump で見ると、末尾の文字列は命令ではなく文字なので、意味のない命令として表示されます。
おわりに
普段はコンパイラに任せきりで、低レイヤーに触れる機会はまずありません。
それだけに、「コンパイラという通訳を介さず、CPU に直接話しかける世界」をのぞけたのは、本当に貴重な時間でした。
この記事は、仕組みを学ぶためのものです。
他人が作ったソフトウェアを書き換えることは、利用規約やライセンスで禁止されていたり、法律に触れたりすることがあります。