TL;DR
PIC/PIEなバイナリを実現するためにはPC相対参照が必要だが、x86(not x86_64)ではCPUによりPC相対参照がサポートされていない。
その代わりに、PC相対参照はソフトウェア的に実装される。
PIC/PIE
PIC(Place Independent Code)/PIE(Place Independent Excecutable)とは、メモリ上のどのアドレスに配置されたとしても動作するコードのことです。
PICはライブラリ・実行ファイルも含め広く使われますが、PIEとは実行形式ファイルのことを通常は指すようです。
今回の記事ではPICで統一します。
PICは主に共有ライブラリで使用されます。
共有ライブラリは、その特性上、どこに配置されても動作するほうが嬉しいです。(アプリケーション側のコードのサイズや配置される場所がわからない)
もちろん、PICではない形で共有ライブラリを作り、それを利用する側のコードに、配置場所・コードサイズの制約を課すこともできますが、これでは不便です。
例えば、コードサイズが違うことにより、共有ライブラリの配置場所が異なる。
もちろん、共有ライブラリの配置場所を事前に決めても良いが、そうなるとアプリケーション側でコードサイズや配置場所に制限が出てしまう。
アプリケーションA アプリケーションB
+---------------------------------+ +---------------------------------+
| | | Application code |
| Application code | +---------------------------------+
| | | |
+---------------------------------+ | |
| | | Shared Library |
| | | |
| Shared Library | +---------------------------------+
| | | .data |
+---------------------------------+ | .rodata |
| .data | +---------------------------------+
| .rodata | | stack |
+---------------------------------+ +---------------------------------+
| stack |
+---------------------------------+
また、PICはセキュリティ面でもメリットがあります。
実行時(正確にはロード時)に配置場所をランダムに決定できれば、アドレス決め打ちで行うような攻撃ができなくなります。
最近では共有ライブラリだけでなく、アプリケーションもPICとするのが一般的なようです。
PICの実現
PICを実現するには、プログラム上の様々なデータを相対参照させるようにすれば良いです。
そこで、次に実行される命令のアドレスが入っているPC(プログラムカウンタ)(x86ではEIP)を使用します。
例えば、x86のjmp命令などは、EIPにオペランドを加減算することによりジャンプ先が決定されます。
どこに配置されていたとしても、相対的なアドレスを指定でジャンプが可能なので、これはPICなコードです。
では、C言語のグローバル変数などへのアクセス(.dataセクション)ではどうでしょう?
ローカル変数や引数では、ESPやEBPを基準にオフセットしてアクセスができます。
一方で、グローバル変数については、メモリ上の配置場所を相対的に指定しようと思うと、EIPを基準に参照するしかありません。
しかし、実はx86(not x86_64)にはEIPを基準に、メモリにアクセスできるような命令が存在しません。
x86で.dataセクションをPIC的に参照しようとすると一工夫が必要になります。
EIPの値を基準に相対参照する方法
基本的になアイデアとしては、何かしらの方法でEIPの値をメモリ・レジスタに持ってくることを考えます。
結論を言えば、call命令が使用できます。
call命令は、関数呼び出しに使用される命令で、実行するとcall命令の次の命令のアドレス(EIPの値)がスタックに格納されます。
これは通常、呼び出し側の関数からret命令で呼び出し元に戻る際の、戻り先のアドレスとして利用されます。
この格納された戻り先アドレスを基準に、メモリアドレスの計算を行います。
実際のコード
こんなコードを書いて、get_x()
関数をPICな場合とそうじゃない場合とで比べてみます。
int x = 0x11223344;
int get_x(){
return x;
}
int main(){
return get_x();
}
gccバージョンは以下のとおりです
salacia@Vega:~/pictest$ gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
PICではない場合
gcc -m32 -fno-pic -O0 -o pic -fno-stack-protector test.c
まず、PICではない場合に注目してみると、0x4008
を参照していることがわかります。
変数xの場所が直接指定されているので、このプログラムは指定されたアドレスに配置されていなければなりません。
000011ad <get_x>:
11ad: f3 0f 1e fb endbr32
11b1: 55 push %ebp
11b2: 89 e5 mov %esp,%ebp
11b4: a1 08 40 00 00 mov 0x4008,%eax
11b9: 5d pop %ebp
11ba: c3 ret
.dataセクションを覗いてみると、0x4008からの4byteにデータが格納されていることがわかります。
このアドレスを絶対参照しているわけです。
salacia@Vega:~/pictest$ objdump -s --start-address 0x4008 nopic
nopic: file format elf32-i386
Contents of section .data:
4008 44332211 D3".
salacia@Vega:~/pictest$ objdump -t nopic
nopic: file format elf32-i386
SYMBOL TABLE:
~省略~
00004000 l d .data 00000000 .data
0000400c l d .bss 00000000 .bss
~省略~
PICの場合
gcc -m32 -fpic -O0 -o nopic -fno-stack-protector test.c
000011ad <get_x>:
11ad: f3 0f 1e fb endbr32
11b1: 55 push %ebp
11b2: 89 e5 mov %esp,%ebp
11b4: e8 30 00 00 00 call 11e9 <__x86.get_pc_thunk.ax>
11b9: 05 23 2e 00 00 add $0x2e23,%eax
11be: 8d 80 2c 00 00 00 lea 0x2c(%eax),%eax
11c4: 8b 00 mov (%eax),%eax
11c6: 5d pop %ebp
11c7: c3 ret
000011e9 <__x86.get_pc_thunk.ax>:
11e9: 8b 04 24 mov (%esp),%eax
11ec: c3 ret
11ed: 66 90 xchg %ax,%ax
11ef: 90 nop
こちらは、グローバル変数を参照したいだけなのに、なぜかcall命令が使用されています。
前述の通り、PICの基本的な考え方は、EIPの位置にもとづいて、あらゆるアドレスを相対参照させるというものです。
となるとEIPのアドレスがほしいわけですが、EIPのアドレスはmov命令で取ってくることができません。
EIPアドレスを取得するにはどうすればいいのでしょうか?
答えは、「call命令を使用し、スタックにEIPを保存してしまう」です。
本来ここで積まれるEIPのデータは、ret命令での復帰アドレスとして使われるものですが、このデータを相対参照に使うわけです。
call命令で__x86.get_pc_thunk.ax
という関数を呼んでいますが、これがスタックに積まれたEIPのデータを返す関数です。
11e9: 8b 04 24 mov (%esp),%eax
というのは、%esp
の指す先(スタックの一番上に積まれているデータ)、つまり復帰アドレス(=call命令によりpushされたEIP)を取得しているわけです。
ABIにもよりますが、この場合は関数の戻り値はEAXレジスタに入れるので、EAXレジスタに取得してきてret命令で復帰しています。
get_x()
関数に戻った後は、この取得したEIPの値からアクセスしたいアドレスを計算して、最終的なアドレスを決定して、アクセスします。
11b9: 05 23 2e 00 00 add $0x2e23,%eax
11be: 8d 80 2c 00 00 00 lea 0x2c(%eax),%eax
11c4: 8b 00 mov (%eax),%eax
最初にcall命令を実行したときに、スタックに積まれるEIPは0x11b9
です。(戻りアドレスが積まれるので、callの次の命令のアドレスになる)
それに、戻った後に加算される0x2e23
を足すと、0x11b9 + 0x2e23 = 0x3fdc
になります。
さらにlea命令で、加算が行われて、最終的なアドレスは 0x3fdc + 0x2c = 0x4008
となります。
では、実際に0x4008
に何が配置されるのかを見てみると、もちろんグローバル変数xが配置されている場所です。
salacia@Vega:~/pictest$ objdump -t pic
pic: file format elf32-i386
SYMBOL TABLE:
~省略~
00004000 l d .data 00000000 .data
0000400c l d .bss 00000000 .bss
~省略~
salacia@Vega:~/pictest$ objdump -s --start-address 0x4008 pic
pic: file format elf32-i386
Contents of section .data:
4008 44332211 D3".
補足
PIC/PIEの説明にはGOT(Global Offset Table)などへの理解も必要になりますが、今回は簡単な説明だけで割愛します。
GOTとは、グローバル変数を集めた領域のことです。
コンパイル時にオブジェクト単位でグローバル変数をまとめたテーブルを作っておきます。
リンカが実行ファイルを作るときに、それらをマージして大きなテーブルを作ります。
グローバル変数へのアクセスは、そのGOTテーブルの先頭アドレスに各変数へのオフセット値を加算して行います
このアクセスは、今回説明したPC相対参照を使って行われます。
上記の例ではadd命令とlea命令で2段階の加算が行われていましたが、add命令がGOTの先頭アドレスへの加算、lea命令で目的の変数へオフセット値を加算しているのだと思われます。
まとめ
x86では、EIPを使った相対参照がCPUでサポートされていないので、このようにソフトウェア的に相対参照が実現されます。
単にグローバル変数にアクセスするだけで、6命令を必要とします。
今回の例ではgccの最適化を無効にしているため、通常はもう少し効率的なコードが生成されるはずです。
なおx86_64で改善されて、EIP(RIP)を使った相対参照がCPUでサポートされるようになりました。
gcc -fpic -O0 -o pic_x64 -fno-stack-protector test.c
0000000000001129 <get_x>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 48 8d 05 d8 2e 00 00 lea 0x2ed8(%rip),%rax # 4010 <x>
1138: 8b 00 mov (%rax),%eax
113a: 5d pop %rbp
113b: c3 retq
x86_64に移行すると、このようにEIPを使った相対参照において命令数が削減できます。
(実際の速さで言えば、x86の複数命令のほうが速いのか、x86_64のRIP相対参照の2命令の方が速いのかは、それぞれにかかるサイクル数を確認する必要があります)
色々調べて書いてますが、間違ってるところあったらご指摘ください。