位置独立実行形式(PIE)によって確保できるエントロピー(32bitの場合)
はじめに
実行ファイルを位置独立実行形式(PIE: Position Independent Code)にすると、実行ファイルは位置独立となり、任意のメモリアドレスにロードできるようになる。このため、PIEな実行ファイルをロードするアドレスをランダム化することによって、ROPなどのコード再利用攻撃に対処することが可能である。
この記事では、実行ファイルをPIEにすることで、どれほどのエントロピーを確保できるのかについて調査した結果を記す。なお、今回は32bit archにのみ焦点を当てる。
調査環境
今回の調査に用いた環境を以下の通り。
$ uname -a Linux tanuki 5.3.0-46-generic #38~18.04.1-Ubuntu SMP Tue Mar 31 04:17:56 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux $ gcc --version gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0 Copyright (C) 2017 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.
PIEの効果の確認
準備
まず、以下のコードをPIEあり、なしのそれぞれの場合でコンパイルする。
// sample.c #include <stdio.h> int something(void) {} int main(void) { printf("%p\n", something); return 0; }
今回使ったGCC7.5.0はデフォルトでPIEな実行ファイルを吐くっぽいので、非PIEにする場合のみフラグが必要である。
$ gcc -m32 sample.c -o pie $ gcc -no-pie -m32 sample.c -o no_pie
checksec
コマンドを使うことで、PIEの有無を確認できる。
$ checksec ./pie Arch: i386-32-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled $ checksec ./no_pie Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
確認
以下を実行することで、PIEと非PIEな実行ファイルの違いを確認できる。
$ for i in `seq 1 5`; ./no_pie 0x8048426 0x8048426 0x8048426 0x8048426 0x8048426 $ for i in `seq 1 5`; ./pie 0x565e351d 0x5661b51d 0x565bb51d 0x5661751d 0x5658151d
実行結果からわかる通り、非PIEの場合はsomething()
のアドレスはすべて同じであり、PIEの場合はアドレスがすべて異なっている。これは、PIEの場合、実行のたびに.text
領域が異なるアドレスにロードされているためである。
エントロピーの調査
予想
PIEな実行ファイルの実行結果からランダム化されている部分とされていない部分があることがわかる。具体的には以下の???
の部分がランダムに変化していることがわかる。
0x56???51d
しかし、最上位の?は5と6の間でしか変化しないので、これは下の桁からの繰り上がりによる変化だと考えられる。このため、実際には8bit分のエントロピーしかないことが予想される。
ソースコードの調査
上記の予想が正しいのか、Linuxカーネルのソースコードを調査して確認する。ソースコードの閲覧にはbootlinというWebサイトを利用した。さっとLinuxカーネルのソースコードを調べたい時に便利なのでおすすめ。
Linuxカーネル内には、ELFファイルをロードするための関数load_elf_binary()
がある。これはfs/binfmt_elf.c
内に存在する。load_elf_binary()
関数内で、PIEに関連する部分のみを抜き出した結果を以下に示す。
/* * If we are loading ET_EXEC or we have already performed * the ET_DYN load_addr calculations, proceed normally. */ if (loc->elf_ex.e_type == ET_EXEC || load_addr_set) { elf_flags |= elf_fixed; } else if (loc->elf_ex.e_type == ET_DYN) { /* * This logic is run once for the first LOAD Program * Header for ET_DYN binaries to calculate the * randomization (load_bias) for all the LOAD * Program Headers, and to calculate the entire * size of the ELF mapping (total_size). (Note that * load_addr_set is set to true later once the * initial mapping is performed.) * * There are effectively two types of ET_DYN * binaries: programs (i.e. PIE: ET_DYN with INTERP) * and loaders (ET_DYN without INTERP, since they * _are_ the ELF interpreter). The loaders must * be loaded away from programs since the program * may otherwise collide with the loader (especially * for ET_EXEC which does not have a randomized * position). For example to handle invocations of * "./ld.so someprog" to test out a new version of * the loader, the subsequent program that the * loader loads must avoid the loader itself, so * they cannot share the same load range. Sufficient * room for the brk must be allocated with the * loader as well, since brk must be available with * the loader. * * Therefore, programs are loaded offset from * ELF_ET_DYN_BASE and loaders are loaded into the * independently randomized mmap region (0 load_bias * without MAP_FIXED). */ if (interpreter) { load_bias = ELF_ET_DYN_BASE; [*1] if (current->flags & PF_RANDOMIZE) load_bias += arch_mmap_rnd(); [*2] elf_flags |= elf_fixed; } else load_bias = 0;
[*1]では、ロード先のアドレスに加えるバイアスの基準値を設定している。ELF_ET_DYN_BASE
はarch/x86/include/asm/elf.h
内で定義されており、定数なので、今回は無視する。
続いて、関数の名前からして[*2]のarch_mmap_rnd()
関数はランダマイズに関係がありそうである。この関数はarch/x86/mm/mmap.c
内で以下のように定義されている。
static unsigned long arch_rnd(unsigned int rndbits) { if (!(current->flags & PF_RANDOMIZE)) return 0; return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT; } unsigned long arch_mmap_rnd(void) { return arch_rnd(mmap_is_ia32() ? mmap32_rnd_bits : mmap64_rnd_bits); }
mmap_is_ia32()
について見てみる。この関数はarch/x86/include/asm/elf.h
内で定義されている。
/* * True on X86_32 or when emulating IA32 on X86_64 */ static inline int mmap_is_ia32(void) { return IS_ENABLED(CONFIG_X86_32) || (IS_ENABLED(CONFIG_COMPAT) && test_thread_flag(TIF_ADDR32)); }
カーネルがx86_32向けにビルドされているか、もしくはカーネルがx86_64向けにビルドされており、かつIA32 COMPAT機能が有効になっており、スレッドフラグがTIF_ADDR32の場合に真になる。
なんだかややこしいが、今回はx86_64向けのカーネルを使っており、実行ファイルは32bitのものなので、恐らくこれは真になる。このため、arch_rnd()
関数の引数はmmap32_rnd_bits
になるだろう。
このmmap32_rnd_bits
はCONFIG_COMPAT
が有効の場合、mmap_rnd_compat_bits
のエイリアスになっている。mmap_rnd_compat_bits
は/proc/sys/vm/mmap_rnd_compat_bits
にマッピングされており、以下のようにユーザー空間から確認できる。
$ sudo cat /proc/sys/vm/mmap_rnd_compat_bits 8
mmap_rnd_compat_bits
が8なので、arch_rnd()
関数は引数に8を取った状態で呼び出される。arch_rnd()
関数内で特に重要なのが以下の部分。
return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT;
特にページサイズの設定を弄っていなければ、4KBページが利用されるので、PAGE_SHIFT
は12となる。rndbits
が8になることも踏まえて、上記のコードは以下のようになる。
return (get_random_long() & 0xff) << 12;
get_random_long()
関数より生成した乱数の8bit分のみ取り出して、12bit右にシフトする。これは上記で述べた予想に一致する。
0x56???51d
ということで、自分の環境において、デフォルトの設定では、PIEによって確保できるエントロピーは8bitであることを確認した。mmap_rnd_compat_bits
は自由に設定できるようになっているので、もっと高いエントロピーを確保したい場合は/proc/sys/vm/mmap_rnd_compat_bits
の内容を書き換えればいい。(と言っても、32bitのアドレス空間では、最大でも20bitしか確保できなそうなので、決して安全とは言えない…)
mmap_rnd_compat_bitsの変更
mmap_rnd_compat_bits
を変更してみて、より高いエントロピーを確保できるか確認してみる。とりあえず、値は12に設定してみた。
$ sudo cat /proc/sys/vm/mmap_rnd_compat_bits 12
確認してみる。
$ for i in `seq 1 5`; ./pie 0x56dff51d 0x56b9a51d 0x5679951d 0x5698b51d 0x5752c51d
ちゃんと12bit分ランダマイズされていることがわかる。
おわりに
エントロピー低すぎだろ。ただし、一回の実行における総当り攻撃の回数が少なければ、それなりに守ってくれる?