kubo39's blog

ただの雑記です。

partialRELRO/fullRELRO、あるいは-fno-pltの話

relroはすでにけっこう有名だけど、はわりとこのへんの話は知られていない気がしてきた。

relro

relro、すなわちrelocation read onlyは再配置情報を格納している領域を読み取り専用にして実行時に書き換えられないようにすることだ。

そもそも再配置(リロケーション)とはなにかというと、再配置情報に応じて適切なリンク後(リンカがアドレス解決した後)の値をオブジェクトファイルにいれる処理を指す。 紛らわしいね。

(TODO: ここでリロケーションの説明をしたい、、いや読者への課題にするか??) リロケーションの説明はこの資料がめちゃめちゃ詳しい。

再配置情報を格納している領域は .plt とか .got とかがそれにあたる。 ようはこのへんを読み取り専用にするというオプションであると覚えておけばいい。

ではなぜここを読み取り専用にする必要があるかというと、セキュリティ上の問題があるためだ。 バッファオーバーフローと並びよく知られている攻撃手法としてformat string attack(書式文字列攻撃)というものがある。 これはもとはメモリ内容の読み出しができるというものだったが、任意の位置のメモリ内容を書き換えてしまうことも可能である(GOT overwrite攻撃)。 ここで再配置領域は書き換え可能領域としてよく狙われてしまうのでGCCには -Wl,-z,relro を渡すことでリロケーションを読み取り専用にするオプションは追加される運びとなった。

lazy binding

上でrelroについて書いたが、実はこれは完全ではない。relroについてちゃんと知るためにはlazy bindingについて知る必要がある。

lazy bindingが使われるとき、動的リンカ(あるいはローダ)はシンボル名の検索をロード時まで遅延させ、関数呼び出しが行われるまでシンボル名の検索およびアドレス解決(リンカによるものではなくディスパッチ呼び出しのことが言いたい、、通じるか、、?)が遅延される。 これはいろいろ遅延読み込みにすることで初期のメモリ読み込みの時間をカットしてプログラムの起動時間を節約するというものだが、最近だとその利点が疑わしくなっているらしい(ここはよく知らない)。

で、現代においてはlazy bindingはセキュリティの足かせになっている。なぜならこの機構を機能させるためには仕組み上上で説明した再配置領域であるGOTが書き込み可能になっている必要があるためだ。正確にいうと、lazybindingを行うPLTのGOTスロットにかんしてはは書き換え可能になっていなければいけない。

lazy bindingを無効にする場合はGCC-Wl,-z,now を渡す。 一般的に -Wl,-z,relro をpartial RELRO、 -Wl,-z,relro -Wl,-z,now をfull RELROと呼んでいるようだ。

PLT

PLT(Procedure Linkage Table)はLazy Bindingを行うための機構である。 PLTを使った関数呼び出しが行われる場合関数本体に飛ぶのではなくPLT上の対応するコードに飛び、そこでGOTの対応するスロットから本体の絶対アドレスを取得してそこに飛ぶようになっている(間接呼び出し)。こうすることで未解決シンボルがあってもリンクすることが可能となる。

実行バイナリはレガシーなライブラリを呼び出す可能性を考慮する必要があるのでPLTを消すことはできないが(要検証)、 少なくとも x86 および x86_64 においてPICはPLTを必要とすることはない。x86 であればGOTにラベルの絶対アドレスが入っているし(GOTへの参照はプログラムカウンタとGOTへのオフセット計算を実行前にリンカがよしなにやる)、 x86_64 はRIP相対参照でプログラムカウンタとのオフセットによって解決する(CPUがサポートをしてやってくれる)。 PICの説明はこの資料がめちゃめちゃ詳しい。

(と思ったのだが、現代のLinux環境においてはGCC hardenedの影響によってほとんどのライブラリはPICでリコンパイルされ、コンパイラ/リンカもデフォルトでPIE/PICが渡されるようになっておりPLTの存在価値はなくなってきている)

そうなるとライブラリでPLTを使う理由はlazy bindingのためだが、上で書いたようにもはやメリットも疑わしく、relroを使う現代においてはもはや時代遅れとなっている。 さきに説明したGCC hardenedの影響でコンパイラもfull RELROをデフォルトで渡すようになっている。

というようなわけでGCCやClangは -fno-plt オプションでPLTを経由するような関数を生成しないようにできる。

PLT経由しないことによるメリットにはパフォーマンスの向上もある。 GCCやRustによると、1~3%ほどのパフォーマンス向上が見込めるとのことだ。

直感的にはPLTを経由しない場合コードのキャッシュローカリティが高まるので速くなるとはわかる。

(TODO: 理由を調べて書く、、?)

LDCで実験

LDC 1.21.0で実験してみる。

実験環境はUbuntu 18.04なのでGCC hardenedの影響を受けているので生成されるバイナリは全部PIC(かつ)PIEになっている(はず)。

特別なコードを書く必要がないのでhello worldでやる。

import std.stdio;

void main()
{
    writeln("Hello, World!");
}

ldc2コマンドでコンパイルしたときにfull RELROになることが確認できる。

$ ldc2 helloworld.d
$ readelf -l helloworld | grep GNU_RELRO
  GNU_RELRO      0x000000000009bc10 0x000000000009cc10 0x000000000009cc10
$ readelf -d helloworld | grep BIND_NOW
 0x000000000000001e (FLAGS)              BIND_NOW

といってもLDC側でデフォルトで特別にリンカになにか渡しているわけではない。

$ ldc2 -v helloworld.d | tail -1
/usr/bin/cc helloworld.o -o helloworld -fuse-ld=gold -L/home/kubo39/dlang/ldc-1.21.0/bin/../lib -lphobos2-ldc -ldruntime-ldc -Wl,--gc-sections -lrt -ldl -lpthread -lm -m64

リンカの渡してるオプションをみると、デフォルトで -Z now -Z relro あたりを渡していることが確認できる。

$ ldc2 --Xcc=--verbose helloworld.d
(...)
COLLECT_GCC=/usr/bin/cc
(...)
gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)
(...)
COLLECT_GCC_OPTIONS='-o' 'helloworld' '-fuse-ld=gold' '-v' '-L/home/kubo39/dlang/ldc-1.21.0/bin/../lib' '-m64' '-mtune=generic' '-march=x86-64'
 /usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccT0zIvB.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -fuse-ld=gold -z relro -o helloworld /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/home/kubo39/dlang/ldc-1.21.0/bin/../lib -L/home/kubo39/dlang/ldc-1.21.0/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/home/kubo39/dlang/ldc-1.21.0/lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. helloworld.o -lphobos2-ldc -ldruntime-ldc --gc-sections -lrt -ldl -lpthread -lm -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o

つまり我々は特に意識しないでもfull RELROなバイナリを生成していたのだ。(あえて-Wl,-z,relro -Wl,-z,nowを書いている意味とは、、?まあデフォルトでない環境もまだあるし)

Gentoo WikiをみるとGCC HardenedはSSP,PIE,RELRO,BIND_NOWがby defaultだった、なのでfull RELROになるわけか。

さて、 -fno-plt はリンカ側で対応できるわけではなくコンパイラがそういうふうなコード生成をしないとだめなんだけど、LDCは意外とうまく吐いてくれるかもしれない。

extern (C) char* getenv(const(char)* name);

char* callThroughPLT()
{
    return getenv("\0".ptr);
}

たぶんLLVM IRに Function Attrs: nonlazybind がついていればいい。

確認してみよう。

$ldc2 --output-ll plt.d
(...)
; [#uses = 1]
declare i8* @getenv(i8*) #0

; [#uses = 0]
; Function Attrs: uwtable
define i8* @_D3plt14callThroughPLTFZPa() #1 comdat {
  %1 = call i8* @getenv(i8* getelementptr inbounds ([2 x i8], [2 x i8]* @.str, i32 0, i32 0)) #0 ; [#uses = 1]
  ret i8* %1
}
(...)

なかった。

ただ上で述べたようにLDCはデフォルトでfull RELROであるのでlazy bindingなコードは不要である。 生成されるバイナリもPLTは使っていない。 そんなことはなかった。

追記

せっかくなのでLDCno-pltをサポートするパッチを書いた。