データ定義 (2): PIE

サンプルコード (1)
コードセクション (.text) にデータを定義して、値を読み込むサンプルを実行してみます。

<06a_text.asm>
global testfunc
section .text

testfunc:
    mov eax, [localdata]
    ret

localdata:
    dd 1234

testfunc 関数は、localdata のラベル位置のアドレスから、32bit 整数を読み込み、それを戻り値として、値を返します。

C ソースは、以前に使った test1.c をそのまま使います。

$ nasm -f elf64 06a_text.asm
$ cc -no-pie -o test test1.c 06a_text.o

$ ./test
1234

※-no-pie オプションがないと、エラーが出ます。詳細については後述します。
解説
この場合、データは .text セクション内で定義されています。

ELF の場合、.text セクションは、「明示的な内容が格納されており、プログラム実行時にメモリにロードされ、実行権限があり、書き込み不可能」な領域として定義されています。
つまり、読み込み専用であれば、データも格納できるということです。

ただし、命令として実行されないような位置に出力してください。

ラベル名が参照された時は、そのラベルが定義された位置のアドレスの数値として置き換えられます。
オペランド指定において、[ ] で囲まれたものは、[ ] 内で指定されたアドレスのメモリ位置を示します。

mov eax, [localdata] は、localdata のアドレス位置のメモリから 32bit の数値を読み込んで、EAX に格納することになります。

ここでは、localdata のアドレス位置には、32bit 整数として 1234 (10進数) が定義されているので、その値が格納されます。
逆アセンブル (06a_text.o)
では、localdata のアドレス位置が実際にどうなっているか、06a_text.o を逆アセンブルして確認してみましょう。

$ objdump -d -M intel 06a_text.o

test.o:     ファイル形式 elf64-x86-64

セクション .text の逆アセンブル:

0000000000000000 <testfunc>:
   0:    8b 04 25 00 00 00 00     mov    eax,DWORD PTR ds:0x0
   7:    c3                       ret

0000000000000008 <localdata>:
   8:    d2 04 00                 rol    BYTE PTR [rax+rax*1],cl
    ...

※データ定義はコードセクション内にあるので、32bit の値の内容が命令として逆アセンブルされていますが、無視してください。

localdata のアドレスは、.text セクションからの相対位置として見ると、8 です。

mov 命令は、mov eax,DWORD PTR ds:0x0 となっています。
localdata のアドレスが指定されているはずですが、ここでは 0 になっています。
(ds: は、データセグメントを指定するという意味ですが、64bit モードでは DS は常に 0 になるので、絶対アドレスは 0 という意味になります)

アドレスが localdata の位置を指定していないのは、オブジェクトファイル内で、まだ絶対アドレスが確定していないためです。
これらのアドレスは、実行ファイルを生成する際に再配置されるため、最終的に、リンカによってアドレスの値 (オフセット値) が書き換えられることになります。

では、実際にどういう形で再配置されるのでしょうか。
readelf
objdump コマンドは、複数のフォーマットに対応しているため、各フォーマットの詳細な情報は表示できません。
そのため、代わりに readelf コマンドを使って、ELF フォーマットの詳細を表示してみます。

-a, -all : 基本的なすべての情報を表示

$ readelf -a 06a_text.o

...
セクションヘッダ:
  [番] 名前              タイプ           アドレス          オフセット
       サイズ            EntSize          フラグ Link  情報  整列
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  000001c0
       000000000000000c  0000000000000000  AX       0     0     16
  [ 2] .shstrtab         STRTAB           0000000000000000  000001d0
       000000000000002c  0000000000000000           0     0     1
  [ 3] .symtab           SYMTAB           0000000000000000  00000200
       0000000000000078  0000000000000018           4     4     8
  [ 4] .strtab           STRTAB           0000000000000000  00000280
       000000000000001d  0000000000000000           0     0     1
  [ 5] .rela.text        RELA             0000000000000000  000002a0
       0000000000000018  0000000000000018           3     1     8

...
再配置セクション '.rela.text' at offset 0x2a0 contains 1 entry:
  オフセット      情報           型             シンボル値    シンボル名 + 加数
000000000003  00020000000b R_X86_64_32S      0000000000000000 .text + 8
No processor specific unwind information to decode

Symbol table '.symtab' contains 5 entries:
  番号:      値         サイズ タイプ  Bind   Vis      索引名
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS 06a_text.asm
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000008     0 NOTYPE  LOCAL  DEFAULT    1 localdata
     4: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT    1 testfunc

再配置可能なアドレスは、.rela.text セクション (.rela + 各セクション名) に情報があります。
また、localdata などのシンボル名は、.symtab セクションに情報があります。

.rela.text セクション内に、.text セクションのオフセット 3 の位置の 32bit 値は、.text + 8 の値に置き換えるという情報があるので、このオブジェクトの .text セクションの絶対アドレスが確定した段階で、リンカによって指定位置の数値が書き換えられます。
逆アセンブル (test)
今度は、実行ファイルの test の方を逆アセンブルしてみます。

$ objdump -d -M intel ./test

...
0000000000401160 <testfunc>:
  401160:    8b 04 25 68 11 40 00     mov    eax,DWORD PTR ds:0x401168
  401167:    c3                       ret

0000000000401168 <localdata>:
  401168:    d2 04 00                 rol    BYTE PTR [rax+rax*1],cl
    ...

testfunc のアドレスは 0x401160、localdata のアドレスは 0x401168 になっています。

mov 命令のアドレス位置も、今度は 0x401168 として、ちゃんと localdata の位置を示しています。
リンカによってオフセット位置が書き換えられているのがわかります。
シンボルテーブル
.symtab セクションには、シンボルテーブルの情報があります。

関数やデータなどのシンボルの名前 (実際は、文字列テーブルのインデックス位置) や、そのシンボルのオフセット位置などの情報が格納されています。

$ readelf -a ./test
...
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test1.c
     2: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS 06a_text.asm
     3: 0000000000401168     0 NOTYPE  LOCAL  DEFAULT   14 localdata
     4: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS 
     5: 0000000000403df8     0 OBJECT  LOCAL  DEFAULT   21 _DYNAMIC
     6: 0000000000402008     0 NOTYPE  LOCAL  DEFAULT   17 __GNU_EH_FRAME_HDR
     7: 0000000000403fe8     0 OBJECT  LOCAL  DEFAULT   23 _GLOBAL_OFFSET_TABLE_
...

これらのシンボルは、アドレスを再配置する時や、関数名からアドレスを参照する時などに必要になりますが、実行ファイルを実行するだけなら、この情報はもう必要ありません。
そのため、実行ファイルからシンボル情報を削除すると、ファイルサイズを削減できます。

C コンパイラでの実行ファイル生成時に -s オプションを指定すると、実行ファイルからシンボルテーブルと再配置情報を削除することができます。
もしくは、strip -s <file> で、後から削除することもできます。
PIE
「PIE (Position Independent Executable、位置独立実行形式)」は、コードがメモリ上にロードされる時、どの位置にロードされても、問題なく実行できるフォーマットのことです。

「PIC (Position Independent Code、位置独立コード)」は、メモリのどの位置にロードされても、問題なく実行できるような形のコードを意味します。
つまり、アドレスが、絶対位置ではなく、相対位置で指定されているコードのことです。

PIE は主に共有ライブラリで使われますが、gcc/clang では現在、セキュリティの観点から、通常の実行ファイルも、デフォルトで PIE で生成されるようになっています。

NASM と C コンパイラを連携して使いたい場合、この PIC/PIE に注意する必要があります。
PIE と 非PIE の違い
例えば、以下のような、グローバル変数を参照する C コードを、PIE と 非PIE で生成し、違いを確認してみます。

<test_pie.c>
int a = 0;

int main(int argc,char **argv)
{
    return a;
}

-pie で PIE (デフォルト)、-no-pie で非 PIE (従来の実行ファイル) になります。

$ cc -pie -o test_pie test_pie.c
$ cc -no-pie -o test_nopie test_pie.c

逆アセンブル
test_pie と test_nopie を、逆アセンブルしてみます。

$ objdump -d -M intel test_pie
...
0000000000001119 <main>:
    1119:    55                       push   rbp
    111a:    48 89 e5                 mov    rbp,rsp
    111d:    89 7d fc                 mov    DWORD PTR [rbp-0x4],edi
    1120:    48 89 75 f0              mov    QWORD PTR [rbp-0x10],rsi
    1124:    8b 05 ea 2e 00 00        mov    eax,DWORD PTR [rip+0x2eea]  # 4014 <a>
    112a:    5d                       pop    rbp
    112b:    c3                       ret

$ objdump -d -M intel test_nopie
...
0000000000401106 <main>:
  401106:    55                       push   rbp
  401107:    48 89 e5                 mov    rbp,rsp
  40110a:    89 7d fc                 mov    DWORD PTR [rbp-0x4],edi
  40110d:    48 89 75 f0              mov    QWORD PTR [rbp-0x10],rsi
  401111:    8b 05 fd 2e 00 00        mov    eax,DWORD PTR [rip+0x2efd]   # 404014 <a>
  401117:    5d                       pop    rbp
  401118:    c3                       ret

2つの実行ファイルでは、main 関数のアドレスが異なっています。

また、readelf コマンドで、ELF ヘッダの詳細値を表示してみると、ファイルのタイプが異なっています。

$ readelf -h test_pie
...
型: DYN (Position-Independent Executable file)

$ readelf -h test_nopie
...
型: EXEC (実行可能ファイル)

PIE の場合は、実際に実行した時に絶対アドレスが確定するため、main 関数の絶対アドレスは、実行する度に変化します。
そのため、表示されるアドレスは、相対位置となっています。

非 PIE の場合、アドレスは絶対位置で固定されているため、何度実行しても、main 関数のアドレスは毎回同じになります。

mov 命令のオフセット位置については、いずれも RIP レジスタからの相対位置になっているため、どちらも PIC です。

なお、NASM は、デフォルトで PIC に対応する形でアドレスを出力しないため、デフォルトで PIE 出力する C コンパイラと共に使用する場合、そのままではエラーが出ます。
RIP 相対アドレス
NASM の場合は、[ ] 内でラベルを参照した場合、デフォルトで絶対アドレスを指定する形になるため、PIE に対応することができません。

しかし、x64 の場合は、RIP レジスタを使って、相対位置を指定することで、PIE に対応することができます。

NASM では、ラベルを参照する際に REL キーワードを使うか ([rel LABEL] というような形で)、default rel の一行を加えることで、デフォルトで RIP 相対アドレスを使うように指定すると、ラベルの参照が、RIP 相対アドレスでの指定に置き換わります。
ソースコード
<06b_pie.asm>
default rel
global testfunc
section .text

testfunc:
    mov eax, [localdata]
    ret

localdata:
    dd 1234

06a_text.asm の先頭行に「default rel」を加えて、-no-pie オプションなしでコンパイルしてみてください。

$ nasm -f elf64 06b_pie.asm
$ cc -o test test1.c 06b_pie.o

エラーが出ることなく、PIE の実行ファイルを生成できます。
逆アセンブル
生成された test を、逆アセンブルしてみましょう。

$ objdump -d -M intel test

0000000000001170 <testfunc>:
    1170:    8b 05 01 00 00 00        mov    eax,DWORD PTR [rip+0x1]  # 1177 <localdata>
    1176:    c3                       ret

0000000000001177 <localdata>:
    1177:    d2 04 00 00

今回は、mov 命令が mov eax,DWORD PTR [rip+0x1] になっています。
つまり、RIP レジスタの値に +1 した位置のメモリ位置を参照する形になります。

RIP レジスタは、命令ポインタです。次に実行される命令のアドレス位置が格納されています。

上記の MOV 命令が実行された時の RIP の値は、MOV の次の命令の位置なので、RET の位置となります。

ということは、MOV 命令が実行されている時の位置から見た localdata の位置は、次の RET 命令の位置から localdata までの相対位置となるため、RIP + 1 となります。

このように、RIP 相対アドレスでメモリ位置を指定した場合、現在実行されているコード位置からの相対位置で場所を指定することになるため、コードがメモリ上のどの位置にロードされても、正しく参照できるようになります。

ちなみに、CALL 命令で関数を呼び出したり、指定位置にジャンプする場合は、(64bit モードの場合) 常に相対位置で指定される形になるため、基本的に PIC を意識する必要はありません。

ただし、共有ライブラリの関数を呼び出す場合、それらのコードは実行ファイルとは別の場所にロードされるため、相対位置で直接指定することができません。
そのため、また別の方法が必要になります。

x64 の 64bit モードの場合、コードやデータはすべてメモリ上に連続した形でロードされるため、RIP 相対アドレスですべての範囲を参照することができます。
ただし、x86 の場合は、コードやデータが別のセグメントに分かれているため、この方法は使えません。