LoginSignup
1
0

More than 1 year has passed since last update.

x86でのPICの実現(PC相対参照について)

Last updated at Posted at 2023-01-20

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な場合とそうじゃない場合とで比べてみます。

test.c
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でサポートされるようになりました。

x86_64をターゲットにコンパイルした場合
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命令の方が速いのかは、それぞれにかかるサイクル数を確認する必要があります)

色々調べて書いてますが、間違ってるところあったらご指摘ください。

1
0
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0