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

追記

All about Procedure Linkage Table | MaskRay

上のブログ記事でlazy bindingが遅い原因として、

  1. 現在広く使われている共有ライブラリの仕組みはSunOSのものが原型となっており、共有ライブラリ内でモジュールの区別がない。そのためリンカやローダはシンボルの参照先の区別ができず、ローダはバインディングを一括して解決する必要がある。(結局上で言ってる読み込み時間のカットができていない)

  2. 上に関連して、すべてのシンボルテーブル(実行バイナリのものと、リンクしている共有ライブラリのもの)をグローバルなシンボルテーブルに追加してしまえばシンボル検索を最適化できることが考えられるが、メモリコストや、想定される、もしくは想定外の複雑性をもたらしてしまう可能性がある。少なくとも多くのランタイムリンカはこの機能を有してはいない。

で、現代においては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を行うための機構である。(いやeager 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をサポートするパッチを書いた。

Linux環境でSharedObjectsを使う

とくになにかあるわけじゃなくてSharedObjectsの使い方の備忘録として。まあこういうのドキュメントないので。。

SharedObjectsというのはLinuxでだけ使えるやつで、実行プログラムがロードしている共有ライブラリを取得するために使うものである。 ようは dl_iterate_phdr(3) といえば通じる人には通じる。

以下はプロセスがロードしている共有ライブラリの名前と各セグメントの仮想メモリ上の位置を表示するプログラム。 界隈あるあるとして(??)、relocationを考慮して実際の仮想アドレスを表示するようにしている。

// druntimeのSharedObjectsを使えるのはLinuxだけ
version(linux):

import core.stdc.stdio;

// SharedObjectsを定義している
import core.internal.elf.dl;

// プログラムヘッダの定義とかが欲しいので
import core.sys.linux.elf;

void main()
{
    foreach (object; SharedObjects)
    {
        // std.stdioはここでは使えない
        printf("%s\n", object.name.ptr);

        foreach (ref phdr; object)
        {
            string name = () {
                final switch (phdr.p_type)
                {
                case PT_NULL: return "NULL";
                case PT_LOAD: return "LOAD";
                case PT_DYNAMIC: return "DYNAMIC";
                case PT_INTERP: return "INTERP";
                case PT_NOTE: return "NOTE";
                case PT_SHLIB: return "SHLIB";
                case PT_PHDR: return "PHDR";
                case PT_TLS: return "TLS";
                case PT_NUM: return "NUM";
                case PT_LOOS: return "LOOS";
                case PT_GNU_EH_FRAME: return "GNU_EH_FRAME";
                case PT_GNU_STACK: return "GNU_STACK";
                case PT_GNU_RELRO: return "GNU_RELRO";
                }
                assert(false);
            } ();

            // dl_iterate_phdr(3) を参照
            ulong actualVMA = phdr.p_vaddr + object.info.dlpi_addr;
            printf("    %p: segment %s\n", cast(void*)actualVMA, name.ptr);
        }
    }
}

getFunctionAttributesでlive関数属性をとれるようにした

これ https://github.com/dlang/dmd/pull/11049

単にtraits.dを修正するだけではだめだった、というのはlive関数属性は名前修飾の定義も実装もされていなかったので、typeof内で普通の関数と区別されていなかったのである。

具体的に言うと以下のようになってしまう。

alias tuple(T...) = T;

struct S1
{
    int f1() @live { return 42; }
    int f2() { return 42; }
}

static assert(__traits(getFunctionAttributes, typeof(S1.f1)) == tuple!("@live", "@system"));
static assert(__traits(getFunctionAttributes, typeof(S1.f2)) == tuple!("@live", "@system"));

struct S2
{
    long f2() { return 0; }
    long f1() @live { return 42; }
}

static assert(__traits(getFunctionAttributes, typeof(S2.f1)) == tuple!("@system"));
static assert(__traits(getFunctionAttributes, typeof(S2.f2)) == tuple!("@system"));

という小ネタがおもしろかったというだけの話。

D言語のvulkan bindingであるEruptedDをいれる

以前挑戦してだめだった vulkan 利用、実はIntel HD用のパッケージを入れればいけるのでは?となったので試す。

$ sudo apt install -y mesa-vulkan-drivers

おお、どうやら動いたっぽい。

$ dub run :devices
Performing "debug" build using /home/kubo39/dlang/dmd-2.091.0/linux/bin64/dmd for x86_64.
erupted:devices 2.0.54+v1.2.134: building configuration "application"...
Linking...
Running ./erupted_devices 
Before vkEnumeratePhysicalDevices
After vkEnumeratePhysicalDevices

Found 1 physical device(s)
==========================

Physical device 0: Intel(R) HD Graphics 520 (Skylake GT2)
API Version: 1.1.102
Driver Version: 79699976
Device type: VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU

Queue Family 0
        Queues in Family         : 1
        Queue timestampValidBits : 36
        VK_QUEUE_GRAPHICS_BIT
        VK_QUEUE_COMPUTE_BIT
        VK_QUEUE_TRANSFER_BIT

VK_QUEUE_GRAPHICS_BIT found at queue family index 0

Logical device created
Graphics queue retrieved

Scope exit: draining work and destroying logical device
Scope exit: destroying instance

これからはvulkanや!

emacsのD言語環境 (2020.03.07版)

【2020.04.08追記】 dlsが非推奨になったので以下の内容はすべて古くなってしまいました。なんてこった。。

https://forum.dlang.org/post/usqhsdtwfhrterzpstst@forum.dlang.org


注意: この内容はもう古いです。

またまたemacsD言語環境をいじってLSP仕様にしました。時代はLanguage Server Protocolや!

設定は以下のような感じです。 コーディングスタイル/インデントルールはdmdやdruntimeで使われている書式にあわせています。

(use-package d-mode
  :ensure t
  :hook
  (d-mode . (lambda ()
              (c-set-style "bsd")
              (setq c-basic-offset 4)
              (setq tab-width 4)
              (lsp)))
  :commands d-mode
  )

emacs側に関してはlsp-modeuse-packageの使用を前提としています。 lsp-modeはLSPを使うので必要として(まあeglotでもいいのですが)、use-packageを使うと可読性が高い感じで設定が書けるのでよいです。

D言語のLSPサーバとしてはdlsを使っています。 LSPと通信する必要があるので dub fetch dls して dub run dls:bootstrap してできたバイナリにパスを通してやる必要があります。 好きなとこにおいてもいいのですが、dlsが最新のバイナリに追従しやすいようにdls-latestにsymlinkを作ってくれるので普通にそこにパスを通しています。

ちなみにlsp-register-clientまわりの設定は現時点でlsp-modeが公式にdlsをサポートしてくれていないので書いていますが、サポートされれば要らなくなるはずです。

dls対応のパッチがマージされたので ~/.dub/packages/.bin/dls-latest/dls にdlsを置いている場合は設定を書く必要はなくなりました。

追記(2020.3.9):

いろいろあった結果PATHで設定して通して、って感じになり、通したければユーザに設定してもらうかんじになった。 ドキュメントは https://emacs-lsp.github.io/lsp-mode/lsp-mode.html#lsp-dls

DMDはmain関数(not _Dmain)を実行する前になにをするのか

たとえば自前で pragma(crt_constructor) とか使った場合にどういう順で実行されるんだろう、とか。

こういうコードを用意して、

enum body = "import std.stdio;writeln(__PRETTY_FUNCTION__);";

pragma(crt_constructor)
extern (C) void crt_constructor()
{
    mixin(body);
}

void main()
{
    mixin(body);
}

一応環境

$ dmd --version| head -1
DMD64 D Compiler v2.090.1
$ uname -mrsv
Linux 4.15.0-76-generic #86-Ubuntu SMP Fri Jan 17 17:24:28 UTC 2020 x86_64

コンパイルして .init_array セクションをみてみるとこんな感じ。

$ dmd hoge.d
$ LANG=C readelf -Wx .init_array ./hoge

Hex dump of section '.init_array':
  0x00291dd0 70590400 00000000 70530400 00000000 pY......pS......
  0x00291de0 7c530400 00000000 f0d80600 00000000 |S..............
  0x00291df0 bcfb0600 00000000 54fc0600 00000000 ........T.......

適当にアドレスひっかけて(リトルエンディアンなことに注意)、nmコマンドでシンボルをひいてみる。

一番最初のやつは .init_array の先頭を表すやつ。

$ nm ./hoge| grep 291dd0
0000000000291dd0 t __init_array_start

他のやつは関数ポインタっぽいやつ。frame_dummyはgccが入れてるやつ。

$ nm ./hoge| grep 00045370
0000000000045370 t frame_dummy
$ nm ./hoge| grep 0004537c
000000000004537c t
000000000004537c W crt_constructor
$ nm ./hoge| grep 0006fbbc
000000000006fbbc t
000000000006fbbc W _d_register_conservative_gc
$ nm ./hoge| grep 0006d8f0
000000000006d8f0 t
000000000006d8f0 W _d_register_manual_gc
$ nm ./hoge| grep 0006fc54
000000000006fc54 W _d_register_precise_gc

こうしてみると基本はユーザ定義のやつがGCの初期化処理より先に呼ばれるので、GC使うコードは書けないはず。リンク順に依存してしまうので絶対ではないだろうけど。

そのスタックを食べたのはだれ? ~travisでdubがSEGVし続けたワケ~

あらすじ

ある日のこと、Travis上のプロジェクトがdub使ってるとSEGVで落ちる、というチケットが。さらにスレッドが進み、どうもいろいろなパッケージで同様の問題が起きているとのこと。

LinuxでしかおきないぞとかdubでParallel GC(注:mark&sweep gcのmarkingのフェーズが並列)使ったときしか再現しないっぽいとか再現条件が特定されていく中でParallel GC内のスレッドでスタックオーバーフローが起きているのがどうやら原因だと判明。

といってもこの問題、Parallel GCの内部実装の問題ではない。OSスレッド、それもglibcのpthread実装に関連した問題だった。

この問題はglibcでは静的なTLSブロックがスレッドのスタックのtopに配置されることに起因する。 スレッド実装では最小スタックサイズを保証するために PTHREAD_STACK_MIN よりも小さいサイズが与えられた場合切り上げが行われるのだが、glibcの場合は静的TLSブロックのサイズがこの定数に加算されない仕組みになっている。 (静的TLSブロックサイズを考慮した __pthread_get_minstack という関数があるがこれはglibcバージョン2.15以降で利用可能となる)

Parallel GC内で生成してるスレッドのスタックサイズはアプリケーションに影響を及ぼさないようにするためか0x4000(=16kb)ほどしか設定されてなく、そこそこでかい静的TLSブロックサイズになると簡単にSEGVが起きてしまう状態になっていた。このIssueでは4ページ(0x4000)のスタック領域を確保しているはずが(ガード領域の1ページを除き) 1ページ以上(0x1100)の領域が静的TLSデータに食べられてしまい、利用可能なスタック領域がほんとうにわずかになっていることが確認できる。 おまけにD言語ではグローバルな変数はデフォルトでスレッドローカルストレージに配置されスレッド生成時に暗黙にコピーが走るためバグが顕現しやすいという特性もあったのでさまざまなプロジェクトで影響が出てしまった。

この問題はParallel GCの作者が書いた 静的TLSブロックサイズを考慮する修正パッチ がすでにマージされていてdmd-2.090.1がリリースされているのでひとまず解決した。

影響範囲

Parallel GCを使っている場合 (かつDMD <= 2.090.0) のすべてのバージョンで起きうる。 また自分でスレッド生成を管理している場合なども当然注意が必要。

ワークアラウンド

ランタイムのバグが修正済みであるDMD 2.090.1を使うのがよいのだが、それができない場合

  • プログラム実行時に --DRT-gcopt=parallel:0 を指定する
  • コード内で
// parallel gcが追加されたのは 2.087: https://dlang.org/changelog/2.087.0.html#gc_parallel
// 修正は2.090.1で取り込まれたがパッチバージョンは考慮できないのでとりあえず 2.091を与える
static if (__VERSION__ >= 2087 && __VERSION__ < 2091)
    extern (C) __gshared string[] rt_options = [ "gcopt=parallel:0" ];

という宣言をするとParallel GCを無効にできるので、ひとまずParallel GC起因のものは抑制できる。

自前でスレッドを使いたい場合スタックサイズを余裕をもって大きくしておくか、もしくはスタックサイズとして0を渡すとシステムデフォルトのスタックサイズ(静的TLSブロックが考慮されている)を設定することができる。

おまけ

今回自分は修正パッチのレビューから参加したのでここまでの流れを追うの(特に英語)がしんどかったが、とにかく解決してほっとした。原因究明と修正にあたってくれたGeod24氏とrainers氏に感謝。

ちなみにこの修正、地味に引数に与えたスタックサイズと静的TLSブロックの合計が PTHREAD_STACK_MIN より小さい場合に PTHREAD_STACK_MIN のサイズまでしか切り上げされないというコーナーケースがあるので、追加パッチ を書いた。こちらもマージ済だけど影響範囲は微小と思われるのでリリースは2.091.0になりそうだ。