0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++向け軽量・高機能な関数フックライブラリ「NaughtyHook」の紹介

0
Posted at

はじめに

C++で開発をしていると、「他人が書いたライブラリの関数の挙動を少し変えたい」「ユニットテストでモックを作成せずに関数の戻り値を変えたい」「ソースコードがないバイナリの処理をハックしたい」といった場面に遭遇することがあります。

しかし、C++で関数フックを実現するには、OSごとのアセンブリ知識や実行可能メモリの書き換え技術などが必要になり、非常にハードルが高くなります。

そこで今回、C++14以降で利用できる、軽量かつ強力な関数フックライブラリ 「NaughtyHook」 を紹介します。

NaughtyHook とは

NaughtyHookは、特定の関数(フリー関数、クラスメソッド、インスタンスメソッド、システム関数など)の先頭命令を書き換えて、自身で定義した関数に処理をリダイレクトできるC++ライブラリです。

主な特徴

  1. シグナルベースのフッキング (SIGILL利用)
    通常、関数フックにはトランポリン関数(元関数の数命令を退避したジャンプコード)を用いることが多いです。しかしNaughtyHookは、対象関数の先頭をわざと「無効命令」に書き換え、OSが発行する SIGILL (Illegal Instruction) シグナルをキャッチしてプログラムカウンタ (PC/RIP) をフック関数へ移すというアプローチをとっています。これにより、1命令しかないような極端に短い関数でもフック対応しています。

  2. Apple Silicon (ARM64) と x86_64 の両対応
    macOS (Intel/Apple Silicon) および Linux (x86_64/arm64) 環境で動作します。特に最近のmacOSでは「W^X (Write XOR Execute)」という強力なメモリ保護がありますが、vm_remap を用いた動的書き換えと命令キャッシュ (I-Cache) の無効化により、透過的にバイパスしてフックを実現しています。

  3. C++らしい安全なインターフェース
    フックは RAII (Resource Acquisition Is Initialization) ベースで管理されます。makeHook() で返されるオブジェクトがスコープを抜けると、デストラクタの働きにより自動でフックが解除(元の命令に復元)されます。
    また、std::forward を利用した「完全転送」によって、巨大な構造体の無駄なコピーを避け、std::unique_ptr などのムーブオンリー型も安全に転送できます。

  4. 仮想関数 (Virtual method) のフックも可能
    C++のメンバ関数ポインタ (PMF) は、コンパイラやABI(Itanium ABIやARM ABI)によって構造が異なります。NaughtyHookは内部でこのABIの差異を吸収し、vtable(仮想関数テーブル)から実行時の実体アドレスを自動解決してのフックを可能にしています。

使い方・具体例

1. 基本的なフック(フリー関数の場合)

対象となる元の関数と同じシグネチャ(引数・戻り値)を持つフック関数を用意します。第一引数にはフックのコンテキストを持つ NaughtyHook::Hook クラスのポインタを受け取るようにします。

#include "NaughtyHook.hpp"
#include <iostream>

// 元の関数
int multiplyCore(int a, int b) {
    return a * b;
}

// フック先の関数
int multiplyHook(NaughtyHook::Hook<int(int, int)> *hook, int a, int b) {
    std::cout << "Hooked! Input: " << a << ", " << b << std::endl;
    // オリジナルの関数を呼び出すことも可能!引数を改ざんして渡すこともできる
    return hook->callOrgfunc(a + 1, b + 1);
}

int main() {
    std::cout << "Original: " << multiplyCore(2, 3) << std::endl; // 出力: 6
    
    {
        // フックを有効化 (戻り値はautoで受け取る)
        auto hook = NaughtyHook::makeHook(multiplyCore, multiplyHook);
        
        // フックされているので multiplyHook が呼ばれる
        std::cout << "Hooked:   " << multiplyCore(2, 3) << std::endl; // 出力: 12
    } // ← スコープを抜けるため、フックは自動的に解除される
    
    std::cout << "Restored: " << multiplyCore(2, 3) << std::endl; // 出力: 6
    return 0; // スコープのおかげで安全
}

C++14以降のジェネリックラムダを使うと、もっとシンプルに書くこともできます。

auto hook = NaughtyHook::makeHook(multiplyCore, [](auto* hook, auto... args) {
    std::cout << "Lambda hook!" << std::endl;
    return hook->callOrgfunc(args...);
});

2. インスタンスメソッドのフック

C++のフックで一番厄介なのがクラスのメンバ関数(インスタンスメソッド)です。暗黙の this ポインタが渡されるためですが、NaughtyHookでは第二引数に 対象クラスのポインタ を置くことで自然に記述できます。

class MyClass {
public:
    int base_val = 10;
    int add(int v) { return base_val + v; }
};

// 第二引数に対象クラスのポインタ (this) となる引数を受け取ります。
int hookAdd(NaughtyHook::Hook<int(MyClass*, int)> *hook, MyClass *obj, int v) {
    // メンバ変数へのアクセスも可能
    return hook->callOrgfunc(obj, v + 5); 
}

int main() {
    MyClass obj;
    // メンバ関数ポインタを指定してフック
    auto hook = NaughtyHook::makeHook(&MyClass::add, hookAdd);
    std::cout << obj.add(5) << std::endl; // 出力: 20
    return 0;
}

さらに、仮想関数 (virtual) のフックにも対応しています。仮想関数呼び出しが vtable を経由するため、makeHook の第三引数にインスタンスのポインタを渡すことで、実行時に正しく関数アドレスを解決します。

技術的ハイライト (コアな仕組み)

NaughtyHookの実装でこだわった・苦労したポイントを少しだけ紹介します。

Apple Siliconでのメモリ保護回避 (vm_remapトリック)

macOSではセキュリティ上の制約により、標準の mprotect だけでは実行コード領域 (__TEXT セグメント) を書き換えることができません。そこで vm_remap システムコールを活用し、実行可能領域の物理メモリを別の仮想アドレスに「コピーオンライト (COW)」で再マッピングして、そこに対してパッチを当てるというトリックを使っています。
さらに、データキャッシュと命令キャッシュの食い違いを防ぐため、sys_icache_invalidate を呼び、ISB (Instruction Synchronization Barrier) 命令を発行して同期も行っています。

仮想関数のアドレス抽出

C++のメンバ関数ポインタ(PMF)はアーキテクチャやABIにより構造が異なります。

  • x86_64 (Itanium ABI): 第一ワードの最下位ビット(LSB)が1なら仮想関数とみなし、値からオフセットを計算。
  • ARM64 (ARM ABI): 第二ワード(adj)の最下位ビットが1なら仮想関数。第一ワードがオフセット。

NaughtyHook内部では union を使ってこの構造体のバイト列を無理やり抽出し、条件分岐で正しい実際の関数のポインタを導き出しています。これにより、オーバーライドされている関数であっても正しくフックすることが可能です。

オリジナル関数コール機能での課題

本ライブラリは callOrgfuncで元関数を呼び出す仕組みを提供しています。
callOrgfunc の呼び出し方法は、呼び出し前に無効命令から「オリジナルのコード」に書き戻して実行し、直後に再度パッチを当てるという流れを踏んでいます。このため、この期間に他スレッドから同じ関数が呼ばれるとフックをすり抜けてしまうため、現在の callOrgfunc の利用はシングルスレッド前提としています。(トランポリン方式でARMレジスタのPC相対命令を動的リロケーションするのも難易度・パフォーマンス的な制約があるためです)。

開発の裏話:4年間のお蔵入りとGeminiによる復活

このプロジェクト、開始したのは約4年前になります。
当時、macOS環境で関数の実行コード(命令)を書き換える手法として、ビルド後のMach-Oバイナリのヘッダ領域を書き換えて無理やりパッチを当てる方法しか思いつきませんでした。「ビルド生成物に事後パッチを当てる」という強引なアプローチがどうしても自分の中で納得できず、そのまま長らくプライベートリポジトリとして放ったらかしにしていました。

しかし今回、AI(Gemini)に相談しながら検証を進めたところ、vm_remap などを駆使することで**「バイナリパッチなしで動的書き換えができるコード」**を完成させることができました。これなら!と納得できる形になったため、今回ようやくパブリックリリースに至った次第です。

ただ、この実行時に動的にメモリ保護をバイパスする手法も、近年のOSの厳しいセキュリティ要件を考えると「そのうちAppleに塞がれてしまうのでは…?」という一抹の不安は抱えています(笑)。動いている今のうちに、ぜひ遊んでみてください。

おわりに

NaughtyHookは「サクッと関数をすり替えたい」という時に非常に便利なC++ライブラリです。CMakeでの一発組み込みや、pkg-config にも対応させており、既存のプロジェクトへも導入しやすくなっています。

モックフレームワークの代わりにシンプルなフックアプローチを試してみたい方は、ぜひリポジトリを利用してみてください!

NaughtyHook のリポジトリはこちら (GitHub)
ビルド方法や詳細な使用例はリポジトリのREADME参照してください

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?