サンプルコード (1)
コードセクション (.text) にデータを定義して、値を読み込むサンプルを実行してみます。
<06a_text.asm>
testfunc 関数は、localdata のラベル位置のアドレスから、32bit 整数を読み込み、それを戻り値として、値を返します。
C ソースは、以前に使った test1.c をそのまま使います。
※-no-pie オプションがないと、エラーが出ます。詳細については後述します。
<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進数) が定義されているので、その値が格納されます。
ELF の場合、.text セクションは、「明示的な内容が格納されており、プログラム実行時にメモリにロードされ、実行権限があり、書き込み不可能」な領域として定義されています。
つまり、読み込み専用であれば、データも格納できるということです。
ただし、命令として実行されないような位置に出力してください。
ラベル名が参照された時は、そのラベルが定義された位置のアドレスの数値として置き換えられます。
オペランド指定において、[ ] で囲まれたものは、[ ] 内で指定されたアドレスのメモリ位置を示します。
mov eax, [localdata] は、localdata のアドレス位置のメモリから 32bit の数値を読み込んで、EAX に格納することになります。
ここでは、localdata のアドレス位置には、32bit 整数として 1234 (10進数) が定義されているので、その値が格納されます。
逆アセンブル (06a_text.o)
では、localdata のアドレス位置が実際にどうなっているか、06a_text.o を逆アセンブルして確認してみましょう。
※データ定義はコードセクション内にあるので、32bit の値の内容が命令として逆アセンブルされていますが、無視してください。
localdata のアドレスは、.text セクションからの相対位置として見ると、8 です。
mov 命令は、mov eax,DWORD PTR ds:0x0 となっています。
localdata のアドレスが指定されているはずですが、ここでは 0 になっています。
(ds: は、データセグメントを指定するという意味ですが、64bit モードでは DS は常に 0 になるので、絶対アドレスは 0 という意味になります)
アドレスが localdata の位置を指定していないのは、オブジェクトファイル内で、まだ絶対アドレスが確定していないためです。
これらのアドレスは、実行ファイルを生成する際に再配置されるため、最終的に、リンカによってアドレスの値 (オフセット値) が書き換えられることになります。
では、実際にどういう形で再配置されるのでしょうか。
$ 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 : 基本的なすべての情報を表示
再配置可能なアドレスは、.rela.text セクション (.rela + 各セクション名) に情報があります。
また、localdata などのシンボル名は、.symtab セクションに情報があります。
.rela.text セクション内に、.text セクションのオフセット 3 の位置の 32bit 値は、.text + 8 の値に置き換えるという情報があるので、このオブジェクトの .text セクションの絶対アドレスが確定した段階で、リンカによって指定位置の数値が書き換えられます。
そのため、代わりに 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 の方を逆アセンブルしてみます。
testfunc のアドレスは 0x401160、localdata のアドレスは 0x401168 になっています。
mov 命令のアドレス位置も、今度は 0x401168 として、ちゃんと localdata の位置を示しています。
リンカによってオフセット位置が書き換えられているのがわかります。
$ 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 セクションには、シンボルテーブルの情報があります。
関数やデータなどのシンボルの名前 (実際は、文字列テーブルのインデックス位置) や、そのシンボルのオフセット位置などの情報が格納されています。
これらのシンボルは、アドレスを再配置する時や、関数名からアドレスを参照する時などに必要になりますが、実行ファイルを実行するだけなら、この情報はもう必要ありません。
そのため、実行ファイルからシンボル情報を削除すると、ファイルサイズを削減できます。
C コンパイラでの実行ファイル生成時に -s オプションを指定すると、実行ファイルからシンボルテーブルと再配置情報を削除することができます。
もしくは、strip -s <file> で、後から削除することもできます。
関数やデータなどのシンボルの名前 (実際は、文字列テーブルのインデックス位置) や、そのシンボルのオフセット位置などの情報が格納されています。
$ 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 に注意する必要があります。
「PIC (Position Independent Code、位置独立コード)」は、メモリのどの位置にロードされても、問題なく実行できるような形のコードを意味します。
つまり、アドレスが、絶対位置ではなく、相対位置で指定されているコードのことです。
PIE は主に共有ライブラリで使われますが、gcc/clang では現在、セキュリティの観点から、通常の実行ファイルも、デフォルトで PIE で生成されるようになっています。
NASM と C コンパイラを連携して使いたい場合、この PIC/PIE に注意する必要があります。
PIE と 非PIE の違い
例えば、以下のような、グローバル変数を参照する C コードを、PIE と 非PIE で生成し、違いを確認してみます。
<test_pie.c>
-pie で PIE (デフォルト)、-no-pie で非 PIE (従来の実行ファイル) になります。
2つの実行ファイルでは、main 関数のアドレスが異なっています。
また、readelf コマンドで、ELF ヘッダの詳細値を表示してみると、ファイルのタイプが異なっています。
PIE の場合は、実際に実行した時に絶対アドレスが確定するため、main 関数の絶対アドレスは、実行する度に変化します。
そのため、表示されるアドレスは、相対位置となっています。
非 PIE の場合、アドレスは絶対位置で固定されているため、何度実行しても、main 関数のアドレスは毎回同じになります。
mov 命令のオフセット位置については、いずれも RIP レジスタからの相対位置になっているため、どちらも PIC です。
なお、NASM は、デフォルトで PIC に対応する形でアドレスを出力しないため、デフォルトで PIE 出力する C コンパイラと共に使用する場合、そのままではエラーが出ます。
<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 相対アドレスでの指定に置き換わります。
しかし、x64 の場合は、RIP レジスタを使って、相対位置を指定することで、PIE に対応することができます。
NASM では、ラベルを参照する際に REL キーワードを使うか ([rel LABEL] というような形で)、default rel の一行を加えることで、デフォルトで RIP 相対アドレスを使うように指定すると、ラベルの参照が、RIP 相対アドレスでの指定に置き換わります。
ソースコード
<06b_pie.asm>
06a_text.asm の先頭行に「default rel」を加えて、-no-pie オプションなしでコンパイルしてみてください。
エラーが出ることなく、PIE の実行ファイルを生成できます。
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 を、逆アセンブルしてみましょう。
今回は、mov 命令が mov eax,DWORD PTR [rip+0x1] になっています。
つまり、RIP レジスタの値に +1 した位置のメモリ位置を参照する形になります。
RIP レジスタは、命令ポインタです。次に実行される命令のアドレス位置が格納されています。
上記の MOV 命令が実行された時の RIP の値は、MOV の次の命令の位置なので、RET の位置となります。
ということは、MOV 命令が実行されている時の位置から見た localdata の位置は、次の RET 命令の位置から localdata までの相対位置となるため、RIP + 1 となります。
このように、RIP 相対アドレスでメモリ位置を指定した場合、現在実行されているコード位置からの相対位置で場所を指定することになるため、コードがメモリ上のどの位置にロードされても、正しく参照できるようになります。
ちなみに、CALL 命令で関数を呼び出したり、指定位置にジャンプする場合は、(64bit モードの場合) 常に相対位置で指定される形になるため、基本的に PIC を意識する必要はありません。
ただし、共有ライブラリの関数を呼び出す場合、それらのコードは実行ファイルとは別の場所にロードされるため、相対位置で直接指定することができません。
そのため、また別の方法が必要になります。
$ 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 の場合は、コードやデータが別のセグメントに分かれているため、この方法は使えません。
ただし、x86 の場合は、コードやデータが別のセグメントに分かれているため、この方法は使えません。