🎣

LD_PRELOADを駆使してロードする共有ライブラリを動的に変える(dlopen対応)

2024/06/02に公開

概要

  • 共有ライブラリの関数呼び出しをフックする方法について解説
  • LD_PRELOAD を使用した共有ライブラリのフック方法を紹介
  • dlopendlsym を使用してロードされた関数をフックする方法
  • 自身をロードして公開するテクニックにより、dlopen もフック可能

https://github.com/tbistr/so-intercept-hands-on

LD_PRELOADによる共有ライブラリのフック

Linuxでは、LD_PRELOAD を用いて共有ライブラリの関数をフックすることが一般的です。
LD_PRELOADに共有ライブラリのパスを指定すると、プログラム起動時に動的リンカが対象のライブラリを優先的にリンクします。
これにより、指定ライブラリ内の関数が同名の既存関数を置き換える形で動作します。

以下のC++プログラムは math.h を介して libm.sopow 関数と log 関数を呼び出します。

main.cpp
#include <iostream>
#include <math.h>

int main()
{
    double result;
    // Use variables to avoid compiler optimization
    auto x = 8.0;
    auto y = 2.0;
    result = pow(x, y);
    std::cout << "pow(8.0, 2.0) = " << result << std::endl;

    auto e = 2.718282;
    result = log(e);
    std::cout << "log(e) = " << result << std::endl;
}

プログラムの実行結果
$ g++ -O0 -o main main.cpp -lm && ./main
pow(8.0, 2.0) = 64
log(e) = 1

ここで、フックしたい関数(この例ではpow)と同名、同シグネチャの関数を定義して共有ライブラリを作成します。
このライブラリでは、powに渡される引数を勝手に倍にしてしまう処理を挟んでいます。
また、オリジナルのpowを呼び出すためにdlsymRTLD_NEXTフラグを使用しています。

intercept.cpp
#include <dlfcn.h>
#include <iostream>

static double (*original_pow)(double, double) = nullptr;

extern "C" double pow(double x, double y)
{
    // Load original pow() if not loaded yet
    if (!original_pow)
    {
        original_pow = reinterpret_cast<double (*)(double, double)>(dlsym(RTLD_NEXT, "pow"));
        if (!original_pow)
        {
            std::cout << "Error loading original pow function." << std::endl;
            std::exit(EXIT_FAILURE);
        }
    }

    // Do some interception
    x *= 2;
    y *= 2;

    std::cout << "\033[31m";
    std::cout << "Indercepted! Args are doubled: (" << x << ", " << y << ")" << std::endl;
    std::cout << "\033[0m";

    // Call original pow()
    return original_pow(x, y);
}

この共有ライブラリをビルド、LD_PRELOAD環墧変数に指定してプログラムを実行すると、pow関数の引数が2倍にされていることが確認できます。

処理をフックしたプログラムの実行結果
$ g++ -O0 -o main main.cpp -lm
$ g++ -O0 -shared -fPIC -o intercept.so intercept.cpp -ldl
$ LD_PRELOAD=./intercept.so ./main
Indercepted! Args are doubled: (16, 4)
pow(8.0, 2.0) = 65536
log(e) = 1

これが、共有ライブラリのフックの基本的な方法です。

dlopenによる共有ライブラリのロード

先述のプログラム(main.cpp)では、実行環境の動的リンカがプログラム起動時にlibm.soをリンクしていました。
一方でdlopen関数を使用することで、プログラム実行中に任意の共有ライブラリを動的にロードできます。

以下のプログラムでは、dlopenを使用してlibm.soをロードし、dlsympowlogのアドレスを取得して呼び出しています。

main_via_dlopen.cpp
#include <iostream>
#include <stdlib.h> // for exit()
#include <dlfcn.h>  // for dlopen(), dlsym()
// we don't need math.h anymore

int main()
{
    char *error;

    // load libm.so dynamically
    auto handle = dlopen("libm.so", RTLD_LAZY);
    if (!handle)
    {
        fprintf(stderr, "%s\n", dlerror());
        exit(EXIT_FAILURE);
    }
    dlerror();

    // get pow() address
    auto dl_pow = (double (*)(double, double))dlsym(handle, "pow");
    if ((error = dlerror()) != NULL)
    {
        fprintf(stderr, "%s\n", error);
        exit(EXIT_FAILURE);
    }

    // get log() address
    auto dl_log = (double (*)(double))dlsym(handle, "log");
    if ((error = dlerror()) != NULL)
    {
        fprintf(stderr, "%s\n", error);
        exit(EXIT_FAILURE);
    }

    // do same thing as main.cpp
    double result;
    auto x = 8.0;
    auto y = 2.0;
    result = dl_pow(x, y);
    std::cout << "pow(8.0, 2.0) = " << result << std::endl;

    auto e = 2.718282;
    result = dl_log(e);
    std::cout << "log(e) = " << result << std::endl;

    // unload libm.so
    dlclose(handle);
    return 0;
}

このプログラムの実行結果は先述のmain.cppと同じです。
どちらの方法でも同じ関数が呼び出されていることが確認できます。

dlopenを使ったプログラムの実行結果
$ g++ -O0 -o main_via_dlopen main_via_dlopen.cpp -ldl
$ ./main_via_dlopen
pow(8.0, 2.0) = 64
log(e) = 1

さて、LD_PRELOADを使ってmain_via_dlopenの中で使われている関数をフックできるでしょうか?

残念ながら、LD_PRELOADによるフックはdlopenでロードしたライブラリには効果がありません。
これは、LD_PRELOADがあくまでプログラム起動時のリンクに対して働くためです。

LD_PRELOADを使ってフックしてみる
$ LD_PRELOAD=./intercept.so ./main_via_dlopen
pow(8.0, 2.0) = 64
log(e) = 1

dlopenでロードしたライブラリをフックする

なんとかして、dlsymで検索される対象に自作のpow関数を含める必要があります。
そのための方法として、dlopenをフックして自作の共有ライブラリをロードする方法があります。

intercept_dl.cpp
#include <dlfcn.h>
#include <string>
#include <iostream>

static void *(*original_dlopen)(const char *filename, int flags) = nullptr;

extern "C" void *dlopen(const char *filename, int flags)
{
    if (!original_dlopen)
    {
        original_dlopen = reinterpret_cast<void *(*)(const char *, int)>(dlsym(RTLD_NEXT, "dlopen"));
        if (!original_dlopen)
        {
            std::cout << "Error loading original dlopen function." << std::endl;
            std::exit(EXIT_FAILURE);
        }
    }

    if (std::string(filename) == "libm.so")
    {
        // Set dummy variable, it has static address.
        static int dummy = 0xdeedbeef;
        Dl_info info;
        // Find .so info which contains dummy variable address (=ownself).
        if (dladdr(&dummy, &info))
        {
            // Load ownself instead of libm.so
            // This program have loaded libm.so implicitly (see ldd ./intercept_dl.so).
            // Thus we can expose symbols of original libm.so.
            // If you want to hook library which is not loaded yet, you should load it explicitly with RTLD_GLOBAL.
            // ex. original_dlopen("libm.so", RTLD_LAZY | RTLD_GLOBAL);
            return original_dlopen(info.dli_fname, flags);
        }
    }
    return original_dlopen(filename, flags);
}

注目すべきはdladdr関数の利用です。
staticに定義された変数dummyのアドレスを検索することで、自分自身のパスを取得しています。
これをpowを公開しているintercept.cppと一緒に共有ライブラリとしてビルド、main_via_dlopen.cppと一緒に実行すると、pow関数がフックされていることが確認できます。

intercept_dl.soを使ってフックしてみる
$ g++ -O0 -shared -fPIC -o intercept_dl.so intercept.cpp intercept_dl.cpp -ldl
$ LD_PRELOAD=./intercept_dl.so ./main_via_dlopen
Indercepted! Args are doubled: (16, 4)
pow(8.0, 2.0) = 65536
log(e) = 1

見事にdlopenでロードしたライブラリもフックすることに成功しました。

非フック対象関数の実行

さて、dlopenをフックすることでlibm.soの代わりにintercept_dl.soをロードできました。
しかし、libm.soの他の関数はどうなるでしょうか?
今回のプログラムでは、log関数をフックしていませんが、main_via_dlopen.cppから呼び出しています。
intercept_dl.soではlog関数を定義していないため、dlsymの結果libm.solog関数が呼び出されて欲しいところです。

実際のところ実行結果を見て分かるように、ちゃんとlibm.solog関数が呼び出されています。
これはintercept_dl.soが暗黙にlibm.soをロード、ロードしたシンボルを外部に公開しているためです。
lddコマンドでintercept_dl.soを調べるとlibm.soに依存していることが分かります。

intercept_dl.soの依存関係
$ ldd ./intercept_dl.so
linux-vdso.so.1 (0x0000ffff9d861000)
libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff9d80a000)
libstdc++.so.6 => /usr/lib/aarch64-linux-gnu/libstdc++.so.6 (0x0000ffff9d632000)
libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff9d60e000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff9d49a000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff9d831000)
libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff9d3ef000)

これはiostreamなどのライブラリがlibm.soをリンクしているためです。
intercept_dl.soのロード時にlibm.soも自動的にロードされ、intercept_dl.soの中で見つからなかったシンボルはlibm.soから探されるようになります。

しかし、libm.soがリンクされているのはたまたまであり、他のライブラリでは同じようなことが期待できません。
従って、明示的に非フック対象を含むライブラリをロード、公開しておくことが重要です。
その場合、dlopenのフラグにRTLD_GLOBALを指定することでロードしたライブラリのシンボルを他のライブラリに公開できます。
(dlopen("libm.so", RTLD_LAZY | RTLD_GLOBAL);)

まとめ

  • LD_PRELOADはプログラム起動時のリンクに対して働く
  • dlopenでロードしたライブラリはLD_PRELOADによるフックの対象外
  • dlopenをフックして自分自身をロードすることで、dlsymで検索される対象に自作の関数を含めることができる
  • 非フック対象関数を実行するためには、明示的に非フック対象を含むライブラリをロード、公開しておく
  • RTLD_GLOBALフラグを指定することでロードしたライブラリのシンボルを他のライブラリに公開できる

今回の例はマルチメディア系のアプリ等、実行環境によって動的にロードするライブラリが変わるような場合に有用です。
実際、このアイデアはOpenGLのトレースツールであるapitraceのコード中に発見しました。

また、今回の実行プログラムと実行環境はこちらのリポジトリで試すことができます。

GitHubで編集を提案
来栖川電算

Discussion