/dev/null

脳みそのL1キャッシュ

位置独立実行形式(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_BASEarch/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_bitsCONFIG_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分ランダマイズされていることがわかる。

おわりに

エントロピー低すぎだろ。ただし、一回の実行における総当り攻撃の回数が少なければ、それなりに守ってくれる?