fishhook の実装を見てみた
はじめに
前回は Mach-O フォーマットの、特にセグメントやセクション、シンボルテーブル周りについての記事を書きました。
もともとの発端は fishhook という、Meta が作成したフックライブラリの実装に興味があって調べ始めたのがきっかけです。
ということで今回はこの fishhook
の実装がどうなっているのか、なぜ関数のフックができるのかについて書いていきたいと思います。
最後の更新が 3 年前ですが、フォーマットに準ずる実装のため問題が起きることはあまりないでしょう。問題が起きてしまうとするとそれは、数年前に作られたアプリ自体が動かなくなるような事態になってしまうためです。
How to use
まずはリポジトリの README に書いてある使い方コードから見てみましょう。どんなことができるのか分かるかと思います。
#import <dlfcn.h>
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "fishhook.h"
static int (*orig_close)(int);
static int (*orig_open)(const char *, int, ...);
int my_close(int fd) {
printf("Calling real close(%d)\n", fd);
return orig_close(fd);
}
int my_open(const char *path, int oflag, ...) {
va_list ap = {0};
mode_t mode = 0;
if ((oflag & O_CREAT) != 0) {
// mode only applies to O_CREAT
va_start(ap, oflag);
mode = va_arg(ap, int);
va_end(ap);
printf("Calling real open('%s', %d, %d)\n", path, oflag, mode);
return orig_open(path, oflag, mode);
} else {
printf("Calling real open('%s', %d)\n", path, oflag);
return orig_open(path, oflag, mode);
}
}
int main(int argc, char * argv[])
{
@autoreleasepool {
rebind_symbols((struct rebinding[2]){{"close", my_close, (void *)&orig_close}, {"open", my_open, (void *)&orig_open}}, 2);
// Open our own binary and print out fGst 4 bytes (which is the same
// for all Mach-O binaries on a given architecture)
int fd = open(argv[0], O_RDONLY);
uint32_t magic_number = 0;
read(fd, &magic_number, 4);
printf("Mach-O Magic Number: %x \n", magic_number);
close(fd);
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
サンプルコードが行っているのは open
と close
関数をフックして独自の処理を挟むということをしています。
そのためにまず、独自の実装である my_open
と my_close
を用意しさらにその中でオリジナルの関数を呼ぶということをしています。
問題修正
最初、サンプルコードをそのままコピペして実行しようとしたところエラーが出てしまいました。以下のように明示的にキャストしたら問題が解消されたのでメモとして残しておきます。
// 独自関数を void* 型にキャストした
rebind_symbols((struct rebinding[2]){{"close", (void*)my_close, (void**)&orig_close}, {"open", (void*)my_open, (void**)&orig_open}}, 2);
ちなみに rebind_symbols
関数に渡している rebinding
構造体は以下のように定義されています。
struct rebinding {
const char *name;
void *replacement;
void **replaced;
};
実行結果
試しに上記のサンプルコードを組み込んで実行した結果が以下です。確かにファイル I/O をフックできていますね。
Calling real open('/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/IL2CPP-Analize', 0)
Calling real open('/dev/urandom', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Managed/Metadata/global-metadata.dat', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/il2cpp.usym', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Managed/arm64/il2cpp.usym', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Managed/il2cpp.usym', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/RuntimeInitializeOnLoads.json', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/ScriptingAssemblies.json', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/globalgamemanagers', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Resources/unity default resources', 0)
Calling real open('/private/var/mobile/Containers/Data/Application/1BDA508D-C317-4D99-8E1C-7F9D282DA40A/tmp/CASESENSITIVETEST421230ace1f149c5b517f7ce01cd15ab', 2562, 438)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/globalgamemanagers.assets', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/globalgamemanagers.assets.resS', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Resources/unity_builtin_extra', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/Managed/Resources/mscorlib.dll-resources.dat', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/sharedassets0.assets', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/level0', 0)
Calling real open('/private/var/containers/Bundle/Application/C4565252-BF90-4C68-A881-4D6340451BA9/IL2CPP-Analize.app/Data/sharedassets0.assets.resS', 0)
以下から実際にコードを少しずつ読み進めていきましょう。
ヘッダの宣言・定義を見る
まずヘッダで宣言・定義されているのは前述の構造体ひとつと関数ふたつのみです。
該当箇所を抜粋します。
// ... 前略 ...
#if !defined(FISHHOOK_EXPORT)
#define FISHHOOK_VISIBILITY __attribute__((visibility("hidden")))
#else
#define FISHHOOK_VISIBILITY __attribute__((visibility("default")))
#endif
// ... 中略 ...
/*
* A structure representing a particular intended rebinding from a symbol
* name to its replacement
*/
struct rebinding {
const char *name;
void *replacement;
void **replaced;
};
/*
* For each rebinding in rebindings, rebinds references to external, indirect
* symbols with the specified name to instead point at replacement for each
* image in the calling process as well as for all future images that are loaded
* by the process. If rebind_functions is called more than once, the symbols to
* rebind are added to the existing list of rebindings, and if a given symbol
* is rebound more than once, the later rebinding will take precedence.
*/
FISHHOOK_VISIBILITY
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
/*
* Rebinds as above, but only in the specified image. The header should point
* to the mach-o header, the slide should be the slide offset. Others as above.
*/
FISHHOOK_VISIBILITY
int rebind_symbols_image(void *header,
intptr_t slide,
struct rebinding rebindings[],
size_t rebindings_nel);
// ... 後略 ...
このふたつの関数を使って動的ライブラリのリンク処理をフックして関数を差し替えていきます。
まずは公開されている関数 rebind_symbols
関数から見ていきましょう。関数の実装内にコメントで補足も追加しています。
static struct rebindings_entry *_rebindings_head;
// ---------------------------------------------------
// 変数名の nel は number of elements(要素数)の略
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
// _rebindings_head は static で定義されている構造体。
// リンクリストなのでその開始として宣言されていると思われる。
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
if (retval < 0) {
return retval;
}
// If this was the first call, register callback for image additions (which is also invoked for
// existing images, otherwise, just run on existing images
if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
return retval;
}
引数で受け取るのは構造体 struct rebinding
の配列とその要素数です。
/*
* A structure representing a particular intended rebinding from a symbol
* name to its replacement
*/
struct rebinding {
const char *name;
void *replacement;
void **replaced;
};
関数の冒頭で実行しているのが prepend_rebindings
関数で、与えられた rebinding 情報を追加する処理です。(ちなみに prepend
は「先頭に追加する」という意味)
static int prepend_rebindings(struct rebindings_entry **rebindings_head,
struct rebinding rebindings[],
size_t nel) {
// 新しい struct rebindings_entry 構造体用のメモリを確保
struct rebindings_entry *new_entry = (struct rebindings_entry *) malloc(sizeof(struct rebindings_entry));
if (!new_entry) {
return -1;
}
// 引数で渡された struct rebinding 構造体用のメモリを確保し、その先頭アドレスのポインタを得る
// nel (number of elements) は struct rebinding の数 = リバインドしたい関数の数
// つまり、ここではまとめてその情報を保持するサイズを確保していることになる
new_entry->rebindings = (struct rebinding *) malloc(sizeof(struct rebinding) * nel);
if (!new_entry->rebindings) {
free(new_entry);
return -1;
}
// 渡された struct rebinding 構造体配列の中身をコピー
memcpy(new_entry->rebindings, rebindings, sizeof(struct rebinding) * nel);
new_entry->rebindings_nel = nel;
// リンクリストなので次のポインタとして rebindings_head を設定する
new_entry->next = *rebindings_head;
// さらに、head が指し示す内容を自分自身として登録し直す
*rebindings_head = new_entry;
return 0;
}
struct rebindings_entry {
struct rebinding *rebindings;
size_t rebindings_nel;
struct rebindings_entry *next;
};
rebindings_entry
構造体はその名の通り、リバインドする関数群の情報を保持するものです。 rebindings
フィールドでその情報を、 rebindings_nel
で要素数を、そして next
で(リンクリストなので)次のエントリーへの参照を保持します。
Rebinding 処理の開始
rebindings_entry
によりエントリーの準備ができたら実際のリバインド処理を実行していきます。
初回実行時とそれ以外で処理が分岐します。最終的には同じことを実行するため初回実行想定で処理を見ていきましょう。
// If this was the first call, register callback for image additions (which is also invoked for
// existing images, otherwise, just run on existing images
if (!_rebindings_head->next) {
// 初回の場合はこちらが実行される
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
初回実行時は _rebindings_head->next
になにも設定されていないためこちらの分岐に進みます。
初回実行時はひとつだけの rebindings_entry
が設定されているだけで、当然 next
フィールドはなにも指していないため !_rebindings_head->next
が true
になるというわけですね。
_dyld_register_func_for_add_image 関数を読む
ここで実行している _dyld_register_func_for_add_image
関数は動的ライブラリが読み込まれた際( dlopen
が呼び出された際)に呼び出されるコールバックを登録する関数です。この機能は dyld
が提供してくれています。そもそもフックポイントを提供してくれているわけですね。
つまり、共有ライブラリの対象関数ポインタをこのタイミングで置き換えることで関数フックを実現しているというわけです。
ちなみにこの関数は dyld.h
にて宣言されています。以下抜粋です。
/*
* The following functions allow you to install callbacks which will be called
* by dyld whenever an image is loaded or unloaded. During a call to _dyld_register_func_for_add_image()
* the callback func is called for every existing image. Later, it is called as each new image
* is loaded and bound (but initializers not yet run). The callback registered with
* _dyld_register_func_for_remove_image() is called after any terminators in an image are run
* and before the image is un-memory-mapped.
*/
extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)) __OSX_AVAILABLE_STARTING(__MAC_10_1, __IPHONE_2_0);
extern void _dyld_register_func_for_remove_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)) __OSX_AVAILABLE_STARTING(__MAC_10_1, __IPHONE_2_0);
ということで次はこのコールバック自体の処理を見ていきましょう。
動的ライブラリ読み込み時のコールバック実行
前段でコールバックの登録が行われてることを説明しました。次はこのコールバックがなにをしているか見ていきましょう。コールバックに登録される関数は _rebind_symbols_for_image
で、これは fishhook.c
で定義されている関数となります。
static void _rebind_symbols_for_image(const struct mach_header *header, intptr_t slide) {
// 以下の関数を呼び出すだけ
rebind_symbols_for_image(_rebindings_head, header, slide);
}
ひとつの関数をラップしているだけですね。続いてその中で呼ばれている関数を見てみましょう。
やや長いですが、いったん全体を見てみましょう。適宜、なにをしているかもコメントしてあります。
static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide) {
Dl_info info;
if (dladdr(header, &info) == 0) {
return;
}
// Mach-O フォーマットではヘッダ情報のあとに Load Command が複数並ぶ構造になっている
// それを取り出し参照するための変数
segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
// header の次の位置から開始するため先頭からヘッダサイズ分オフセットさせている
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
// ヘッダで指定されている Load Command の数分ループして判定する
// i はコマンド数をインクリメント
// cur はコマンドサイズ分オフセットしていく。言い換えれば Load Command を次々参照していく
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
// 現在のポインタ位置を segment_command_t 型にキャスト
cur_seg_cmd = (segment_command_t *)cur;
// LC_SEGMENT_ARCH_DEPENDENT は fishhook.c 内で LC_SEGMENT_64 のエイリアスとして定義されていた
// -> #define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT_64
// つまり、Segment Load Command を見つけている
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
// 対象のセグメント名が LinkedEdit だったらそれを保存
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
}
// 対象コマンドがシンボルテーブルコマンドだったら保存
else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
}
// 対象コマンドがダイナミックシンボルテーブルコマンドだったら保存
else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
// Symbol Table と Dyanmic Symbol Table, LinkEdit Segment, Indirect Symbol table
// すべてが揃わなかったら終了
if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}
// Find base symbol/string table addresses
// linkedit_segment->fileoff を引いているのは、仮想アドレスの位置を「ファイル先頭」ではなく「__LINKEDIT」先頭と見立てるため
// その後の symtab_cmd->symoff は、ファイル先頭からのオフセットのためそれを補う目的で `fileoff` を「引いて」いる
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// Get indirect symbol table (array of uint32_t indices into symbol table)
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) { // LC_SEGMENT_ACHO_DEPENDENT == LC_SEGMENT or LC_SEGMENT_64
// __DATA or __DATA_CONST セグメント以外はスキップ
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
// 対象セグメント内のセクション分処理する
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
// 次のセクションにポインタを進める
section_t *sect = (section_t *)(cur + sizeof(segment_command_t)) + j;
// セクションタイプが S_LAZY_SYMBOL_POINTERS か S_NON_LAZY_SYMBOL_POINTERS の場合に処理
// 言い換えると(おそらく)ライブラリなどの間接呼び出しのみ差し替えを行っているのだと思われる
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
}
}
上から順に見ていきましょう。
冒頭で行われているのは関数ポインタから関数の名前と関数が定義されているファイルの解決を試みています。
Dl_info info;
if (dladdr(header, &info) == 0) {
return;
}
解決できなかった場合は return しています。
目的のロードコマンドを見つける
次の部分はやや長いですがやっていることはシンプルです。
以下のコードでやっていることを先に簡単に説明すると「Mach-O の Load Commands エリアから必要となるコマンドを探しその参照を保持する」ということをやっています。
上で書いたように、対象コマンドを検索しそれを保持するための変数を宣言しています。
// Mach-O フォーマットではヘッダ情報のあとに Load Command が複数並ぶ構造になっている
// それを取り出し参照するための変数
segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
以下は実際にコマンドを検索している部分です。順番に見ていきましょう。
まず行っているのは、Mach-O ヘッダのアドレス位置にヘッダ自身のサイズを足して Load Commands エリアのポインタ位置を求めています。
// header の次の位置から開始するため先頭からヘッダサイズ分オフセットさせている
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
前回の記事で紹介したセクション情報のところで記載した図を使って見てみると、以下の位置を求めていることになります。
+--------------------------------------+
| Mach Header |
+--------------------------------------+ <- Mach ヘッダ終了位置
| Load Command | つまりこの位置を求めている
| ---------------------------------- |
| Load Command |
| ---------------------------------- |
| Load Command |
| ---------------------------------- |
| ... |
+--------------------------------------+ <- Load Command 領域終了
上記で求めたポインタ位置からヘッダに記載されているコマンド数分ループして、読み出したコマンドが目的のものだったら変数に格納する、ということを行っています。
// ヘッダで指定されている Load Command の数分ループして判定する
// i はコマンド数をインクリメント
// cur はコマンドサイズ分オフセットしていく。言い換えれば Load Command を次々参照していく
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
// 現在のポインタ位置を segment_command_t 型にキャスト
cur_seg_cmd = (segment_command_t *)cur;
// LC_SEGMENT_ARCH_DEPENDENT は fishhook.c 内で LC_SEGMENT_64 のエイリアスとして定義されていた
// -> #define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT_64
// つまり Segment Load Command を見つけている
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
// 対象のセグメント名が LinkedEdit だったらそれを保存
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
}
// 対象コマンドがシンボルテーブルコマンドだったら保存
else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
}
// 対象コマンドがダイナミックシンボルテーブルコマンドだったら保存
else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
検索後に、目的のコマンドが見つかったかをチェックします。
// Symbol Table と Dyanmic Symbol Table, LinkEdit Segment, Indirect Symbol table
// すべてが揃わなかったら終了
if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}
これで目的のコマンドを検索することができました。次の処理に進みましょう。
シンボルテーブル位置を求める
次に行うのは、間接シンボルテーブルから目的の(リバインドしたい)関数があるかを検索します。そしてあった場合にそれを差し替える処理へと進みます。
まずはシンボルを検索する部分を見てみましょう。
冒頭で行っているのは Link Edit セグメント情報を使って目的のシンボルテーブルの位置を計算しています。ここは少し込み入った部分になるので丁寧に説明していきます。
(自分も理解がやや曖昧な部分もあるのでもし間違いなどあればコメントください)
// Find base symbol/string table addresses
// linkedit_segment->fileoff を引いているのは、仮想アドレスの位置を「ファイル先頭」ではなく「__LINKEDIT」先頭と見立てるため
// その後の symtab_cmd->symoff は、ファイル先頭からのオフセットのためそれを補う目的で `fileoff` を「引いて」いる
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
プロセスの起動手順
ここの説明を行う前に、少し前提を説明する必要があります。
まず、各アプリケーションはひとつのプロセスとして実行されます。プロセスが起動すると OS は対象プロセスのためにメモリを確保(アドレス空間の作成)し、アプリケーション(Mach-O ファイル)のヘッダをマップします。
そして次に dyld
(Dynamic Linker)に制御を移し、dyld は Mach-O のヘッダやロードコマンドの情報を元にアプリケーションが動作するための状態になるようにセットアップを行ったのち、アプリケーションのエントリポイントへ制御を移します。ここからアプリが実際に実行されるわけです。
この確保されたメモリのアドレスは物理メモリアドレスではなく仮想メモリアドレスとなります。これによって他プロセスのメモリ領域へのアクセスを禁止したり、物理メモリ量を超えて確保できたりなど様々な恩恵があります。
さて、この「仮想メモリ」というのが問題を複雑にします。Mach-O フォーマットでは様々な情報が「ファイル先頭からのオフセット」として保存されています。例えば、ファイル先頭から 1,000 バイト目にこういった文字列データがある、という具合です。しかし、仮想メモリアドレスにマップされたバイナリではこのオフセット情報は、そのままではまったく意味をなさないデータになってしまいます。
ファイルの先頭からのオフセットを保持しているだけなので、ファイル先頭に該当する実際にマップされた仮想アドレスを知る必要があります。それを行っているのが上の処理というわけです。
Link Edit セグメントには vmaddr
という、理想の仮想アドレスを示すフィールドがあります。別の言い方をすれば、dyld
に「この仮想アドレスにマップしてね」という指示ということです。
しかし通常、セキュリティ的な観点から vmaddr
のアドレスにそのままマップはされません。これが少し問題を複雑にします。攻撃者視点に立つと「どこのアドレスに攻撃用コードを仕込めばいいか」を考える必要があります。そして、仮に特定のアプリは必ず決まった仮想アドレスにマップされるとしましょう。すると攻撃者は容易に、ハックしたい関数のアドレスを取得することができてしまいます。
対象アプリが手に入ればそれを自分の環境で実行し、デバッグしてアドレスを見つければいいからです。
こうした問題の対応策として OS はプロセスをメモリにマップする際に ASLR (Address Space Layout Randomization) と呼ばれる、メモリ位置をランダムに変更する処理を加えます。これは起動ごとに変わるため、攻撃者は毎回ランダムに配置されるメモリ位置を調べるところから始めなければならず、ハッキングの難易度が上がるというわけです。
この ASLR によるオフセットを示しているのが計算の最初に登場している slide
なのです。スライドの名前の通り、目的の仮想アドレスがこの値分スライドしているわけなんですね。そのために加算しているというわけです。
さて、これだけで話が済めば「想定した仮想アドレスが一定の値スライドしたから足した」というだけで済むのですが、なぜかこのあとに - linkedit_segment->filoff
と、ファイルからのオフセットを「引いて」いるんですね。
なぜファイルオフセットを引くのか? 最初はとても疑問だったのですが、Mach-O フォーマットでは様々な情報がファイル先頭からのオフセットで指定されているということを思い出してください。そして、仮想アドレス + slideで求めているのは Link Edit セグメント位置の仮想アドレスである、という点です。
slide
の値は「理想的な仮想アドレスがどれだけズレたか」を示す値です。例えば __LINK_EDIT
セグメントの vmaddr
が 0x4000
を示していたとして、slide(つまりどれくらいズレたか)が 0x320
だとしましょう。すると、実際に __LINK_EDIT
が配置されるアドレスは 0x4320
になります。これが __LINK_EDIT
セグメントの実際のアドレスになります。そしてこの __LINK_EDIT
セグメントが格納されているファイルオフセットが fileoff
に保存されています。結果的に、マップされた仮想アドレスから fileoff
を引くことで、実質的な「ファイルの先頭アドレス」を得られる、というわけです。
図にすると以下のようになります。
...
| |
+------------------------+ <- Link Edit の vmaddr(理想の仮想アドレス)
| |
+------------------------+ <- Link Edit の fileoff
| |(つまりファイル先頭位置相当)
| |
+------------------------+ <- slide 位置(ASLR オフセット)
| Link Edit segment |(= 実際の Link Edit セグメントの位置)
| |
...
基準となるアドレス位置が計算できたところで次の処理を見ていきましょう。次に行っているのは、先ほど求めたベースアドレスからオフセットを用いて「シンボルテーブル」と「文字列テーブル」のふたつの位置を求めています。
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
linkedit_base
が先ほど求めたベースアドレスですね。そこから symoff
と stroff
を用いてそれぞれ「シンボルテーブル」と「文字列テーブル」のアドレスを求めています。
ここから、置き換えるべきシンボルを検索し見つかったら実装を差し替える処理に入っていきます。大枠の検索のループ自体はロードコマンドを検索したときとほぼ同じです。
// Get indirect symbol table (array of uint32_t indices into symbol table)
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) { // LC_SEGMENT_ACHO_DEPENDENT == LC_SEGMENT or LC_SEGMENT_64
// __DATA or __DATA_CONST セグメント以外はスキップ
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
// ... 中略 ...
// セクションのループは後述
}
}
違いとしては対象のコマンドがセグメントコマンドのみであることと、セグメントの種類が __DATA
か __DATA_CONST
に限定している点です。
上で「中略」とした部分についても見ていきましょう。
// 対象セグメント内のセクション分処理する
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
// 次のセクションにポインタを進める
section_t *sect = (section_t *)(cur + sizeof(segment_command_t)) + j;
// セクションタイプが S_LAZY_SYMBOL_POINTERS か S_NON_LAZY_SYMBOL_POINTERS の場合に処理
// 言い換えると(おそらく)ライブラリなどの間接呼び出しのみ差し替えを行っているのだと思われる
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
セクションのメタデータはセグメントコマンドの直下に配置されています。そのため、以下のようにすることでセクションのメタデータリストの先頭にポインタを進めることができます。
section_t *sect = (section_t *)(cur + sizeof(segment_command_t)) + j;
+ j
しているのは、section_t
型のデータが連続して並んでいるため、ポインタの加算処理で次のセクションのメタデータにアクセスできることが理由です。言い換えれば、セグメントコマンド直下から、section_t
型データを順番に取り出している、というわけです。
そして、対象セクションのタイプが S_LAZY_SYMBOL_POINTERS
か S_NON_LAZY_SYMBOL_POINTERS
のどちらかであった場合に実際に内容を差し替える処理を実行しています。
実装の差し替え
シンボルの走査と差し替え対象の検索が終わり、いよいよ実際に実装を差し替える処理を見ていきましょう。
この差し替え処理を行っているのが perform_rebinding_with_section
関数です。
まずはざっと全体を見てみましょう。気になる点については適宜コメントしました。
static void perform_rebinding_with_section(struct rebindings_entry *rebindings,
section_t *section,
intptr_t slide,
nlist_t *symtab,
char *strtab,
uint32_t *indirect_symtab) {
// indirect_symtab は間接シンボルテーブルの先頭のポインタ
// そして section->reserved1 はこのセクションが使う間接シンボルリストインデックスのオフセットを示している
// e.x) __DATA_CONST には __got セクションなどがある
// indirect_symbol_indices は配列となっていて、このセクションの各間接的シンボル参照がどの
// symtab(シンボルテーブル)の要素を指すかを示す
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
// section->addr はセクション内にあるシンボルポインタ群の実行時アドレス(相対値)で、
// slide を足すことで ASLR された実際のアドレスを得る
// これが、実際に間接参照される関数ポインタ群が格納された領域を示している。
// void** にキャストすることで「関数ポインタ(アドレス)が並んだ配列」としてアクセスできるようになる
// e.x) char* が文字列の配列として機能するのと同じ
//
// メモリイメージ
// [ ... slide + section->addr ... ]
// v
// indirect_symbol_bindings: [ &funcA, &funcB, &funcC, ... ] <- 関数ポインタの配列
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
// section->size はこのセクションのサイズ(バイト数)それを sizeof(void*) で割ることで
// 「このセクション内には何個のポインタ(関数アドレス)があるか」を求めることができる
// void* は関数ポインタとなるため、そのサイズがまさに関数ポインタひとつ分となる
// つまりこのループは「関数ポインタの数」だけループする
for (uint i = 0; i < section->size / sizeof(void *); i++) {
// i は関数ポインタの数分インクリメントされ、indirect_symbol_indices は関数シンボル参照が
// どの symtab の要素を指すかを示している。つまりインデックスを取り出している
uint32_t symtab_index = indirect_symbol_indices[i];
// 取り出したシンボルのインデックスが特殊な値の場合は対象外のためスキップしている
// 言い換えると、対象のシンボルはフック対象ではないのでスキップ、ということ
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
continue;
}
// symtab は nlist_t 構造体の配列。そこから、先ほど求めたインデックスを使ってアクセスしている
// n_un.n_strx は文字列テーブルへのインデックス。コメントでは「index into the string table」と書かれていた
// ※ nlist は name list の略
// ちなみに nlist の各フィールドに `n_` が付いているのはハンガリアン記法
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
// 文字列テーブルのアドレスに、上で求めたオフセットを追加して対象文字列の位置を求める
char *symbol_name = strtab + strtab_offset;
// シンボル名が少なくとも 2 文字以上あるかをチェックしている
// 言い換えると 1 文字目と 2 文字目が終端文字 '\0' でないことを確認している
// なぜこれが必要かと言うと、文字列テーブルは基本的に '\0' 区切りで文字列が並んでおり、
// さらに最初の文字が '\0' であるため、それを回避する目的だと思われる
// また、ChatGPT によると
// -----------------------------------------------------------------
// [ChatGPT]
// `fishhook` でのフック時は、先頭文字が `_` で始まることが多いため、このチェックは `symbol_name[1]` を `strcmp` するときに
// 無効なアドレス参照にならないか確認したり、シンボル名の形式を想定通りか判断するためと思われます。
// -----------------------------------------------------------------
bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
// rebindings_entry の配列の終端までループ処理
struct rebindings_entry *cur = rebindings;
while (cur) {
// rebindings_entry に含まれるリバインド数分ループする
for (uint j = 0; j < cur->rebindings_nel; j++) {
// ここで &symbol_name[1] のアドレスから比較しているのは、シンボル名は基本的に `_` で始まるためそれをスキップしている
if (symbol_name_longer_than_1 && strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
kern_return_t err;
// indirect_symbol_bindings は元々の関数ポインタ群。リバインドしたいシンボル名と一致し、かつそのポインタが置き換え対象と異なっていたら
// 既存の関数ポインタを replaced に保持し、あとから呼び出せるようにする
if (cur->rebindings[j].replaced != NULL && indirect_symbol_bindings[i] != cur->rebindings[j].replacement)
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
// ここで行っているのはメモリの保護属性の変更
// 本来、作業しているセクションは実行時は読み取り専用となるため、それを一時的に書き込み可能状態に変更している
// なお、mach_task_self() は現在のタスク(プロセス)を指している
//
// (uintptr_t)indirect_symbol_bindings は書き換える対象のメモリアドレスの開始位置
// そして section->size 分の範囲が変更範囲
/**
* 1. Moved the vm protection modifying codes to here to reduce the
* changing scope.
* 2. Adding VM_PROT_WRITE mode unconditionally because vm_region
* API on some iOS/Mac reports mismatch vm protection attributes.
* -- Lianfu Hao Jun 16th, 2021
**/
err = vm_protect (mach_task_self(), (uintptr_t)indirect_symbol_bindings, section->size, 0, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
// 保護属性の変更に成功したら処理する
if (err == KERN_SUCCESS) {
// ここで、実際に関数ポインタの参照を書き換えている
/**
* Once we failed to change the vm protection, we
* MUST NOT continue the following write actions!
* iOS 15 has corrected the const segments prot.
* -- Lionfore Hao Jun 11th, 2021
**/
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
}
goto symbol_loop;
}
}
cur = cur->next;
}
symbol_loop:;
}
}
まず最初に行っているのは「間接シンボルのインデックス配列のポインタ位置の計算」です。
// indirect_symtab は間接シンボルテーブルの先頭のポインタ
// そして section->reserved1 はこのセクションが使う間接シンボルリストインデックスのオフセットを示している
// e.x) __DATA_CONST には __got セクションなどがある
// indirect_symbol_indices は配列となっていて、このセクションの各間接的シンボル参照がどの
// symtab(シンボルテーブル)の要素を指すかを示す
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
セクションのメタデータには reserved1
というフィールドがあり、このフィールドには今回使用する間接シンボルのインデックス配列へのオフセットが保存されています。これを利用して位置を求めています。
これはのちほど、シンボルテーブルのインデックスとして利用されます。
続いてシンボルポインタ群のリストアドレスを求めます。
このリストは未解決のシンボルへのスタブポインタが格納されているリストです。
スタブポインタ
スタブポインタは、遅延バインディングを実現するための機構です。具体的には最初の段階ではこのポインタは dyld_stub_binder
関数を呼び出すポインタになっています。そしてこの関数の中で、実際の関数のポインタを見つけ(解決)、スタブポインタの値を解決した関数のポインタで置き換えます。こうすることで、実際の関数ポインタを探す処理を遅延させているというわけです。
これを差し替えることで今回実現したい動的ライブラリの参照をフックすることが可能となります。
// section->addr はセクション内にあるシンボルポインタ群の実行時アドレス(相対値)で、
// slide を足すことで ASLR された実際のアドレスを得る
// これが、実際に間接参照される関数ポインタ群が格納された領域を示している。
// void** にキャストすることで「関数ポインタ(アドレス)が並んだ配列」としてアクセスできるようになる
// e.x) char* が文字列の配列として機能するのと同じ
//
// メモリイメージ
// [ ... slide + section->addr ... ]
// v
// indirect_symbol_bindings: [ &funcA, &funcB, &funcC, ... ] <- 関数ポインタの配列
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
コメントの最後に書かれているのが関数ポインタ群の配列のイメージです。これを適宜、独自実装に差し替えることでフックを実現します。
さらに処理を進めましょう。
次に行っている for
ループは、上記の関数ポインタの数分ループすることです。
// section->size はこのセクションのサイズ(バイト数)それを sizeof(void*) で割ることで
// 「このセクション内には何個のポインタ(関数アドレス)があるか」を求めることができる
// void* は関数ポインタとなるため、そのサイズがまさに関数ポインタひとつ分となる
// つまりこのループは「関数ポインタの数」だけループする
for (uint i = 0; i < section->size / sizeof(void *); i++) {
{
// 略
}
section->size
には関数ポインタの数分の領域があり、それを関数ポインタのサイズ( sizeof(void*)
)で割ることで要素数を得ています。それをループ回数としているわけですね。
// i は関数ポインタの数分インクリメントされ、indirect_symbol_indices は関数シンボル参照が
// どの symtab の要素を指すかを示している。つまりインデックスを取り出している
uint32_t symtab_index = indirect_symbol_indices[i];
先ほど計算した「インデックスリスト」からインデックスを取り出します。このインデックスは、別で定義されているシンボルテーブルのインデックス番号となります。
// 取り出したシンボルのインデックスが特殊な値の場合は対象外のためスキップしている
// 言い換えると、対象のシンボルはフック対象ではないのでスキップ、ということ
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
continue;
}
取り出したインデックスが特殊な値の場合はスキップします。
// symtab は nlist_t 構造体の配列。そこから、先ほど求めたインデックスを使ってアクセスしている
// n_un.n_strx は文字列テーブルへのインデックス。コメントでは「index into the string table」と書かれていた
// ※ nlist は name list の略
// ちなみに nlist の各フィールドに `n_` が付いているのはハンガリアン記法
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
// 文字列テーブルのアドレスに、上で求めたオフセットを追加して対象文字列の位置を求める
char *symbol_name = strtab + strtab_offset;
ここで行っているのは、シンボルテーブルのインデックス位置にあるシンボルデータである nlist_t
構造体に定義されている n_un.n_strx
から、文字列テーブルの位置を求めています。そしてこの位置を、文字列テーブルの先頭に足すことで文字列自体を取り出します。
// シンボル名が少なくとも 2 文字以上あるかをチェックしている
// 言い換えると 1 文字目と 2 文字目が終端文字 '\0' でないことを確認している
// なぜこれが必要かと言うと、文字列テーブルは基本的に '\0' 区切りで文字列が並んでおり、
// さらに最初の文字が '\0' であるため、それを回避する目的だと思われる
// また、ChatGPT によると
// -----------------------------------------------------------------
// [ChatGPT]
// `fishhook` でのフック時は、先頭文字が `_` で始まることが多いため、このチェックは `symbol_name[1]` を `strcmp` するときに
// 無効なアドレス参照にならないか確認したり、シンボル名の形式を想定通りか判断するためと思われます。
// -----------------------------------------------------------------
bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
ここで行っているのは、対象文字列が「1 文字以上あるかどうか」のチェックです。詳細はコメントに書いた通りですが、ざっくり言うと(自分の理解では)シンボル名は少なくとも 2 文字以上が期待されるため、それ以外を弾いているのだと思います。
次に見ていくのは、まさに差し替え処理を行っている箇所です。やや長くなりますががんばって見ていきましょう。
// rebindings_entry の配列の終端までループ処理
struct rebindings_entry *cur = rebindings;
while (cur) {
// rebindings_entry に含まれるリバインド数分ループする
for (uint j = 0; j < cur->rebindings_nel; j++) {
// ここで &symbol_name[1] のアドレスから比較しているのは、シンボル名は基本的に `_` で始まるためそれをスキップしている
if (symbol_name_longer_than_1 && strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
kern_return_t err;
// indirect_symbol_bindings は元々の関数ポインタ群。リバインドしたいシンボル名と一致し、かつそのポインタが置き換え対象と異なっていたら
// 既存の関数ポインタを replaced に保持し、あとから呼び出せるようにする
if (cur->rebindings[j].replaced != NULL && indirect_symbol_bindings[i] != cur->rebindings[j].replacement)
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
// ここで行っているのはメモリの保護属性の変更
// 本来、作業しているセクションは実行時は読み取り専用となるため、それを一時的に書き込み可能状態に変更している
// なお、mach_task_self() は現在のタスク(プロセス)を指している
//
// (uintptr_t)indirect_symbol_bindings は書き換える対象のメモリアドレスの開始位置
// そして section->size 分の範囲が変更範囲
/**
* 1. Moved the vm protection modifying codes to here to reduce the
* changing scope.
* 2. Adding VM_PROT_WRITE mode unconditionally because vm_region
* API on some iOS/Mac reports mismatch vm protection attributes.
* -- Lianfu Hao Jun 16th, 2021
**/
err = vm_protect (mach_task_self(), (uintptr_t)indirect_symbol_bindings, section->size, 0, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
// 保護属性の変更に成功したら処理する
if (err == KERN_SUCCESS) {
// ここで、実際に関数ポインタの参照を書き換えている
/**
* Once we failed to change the vm protection, we
* MUST NOT continue the following write actions!
* iOS 15 has corrected the const segments prot.
* -- Lionfore Hao Jun 11th, 2021
**/
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
}
goto symbol_loop;
}
}
cur = cur->next;
}
symbol_loop:;
大事な点に絞って見ていきましょう。
// indirect_symbol_bindings は元々の関数ポインタ群。リバインドしたいシンボル名と一致し、かつそのポインタが置き換え対象と異なっていたら
// 既存の関数ポインタを replaced に保持し、あとから呼び出せるようにする
if (cur->rebindings[j].replaced != NULL && indirect_symbol_bindings[i] != cur->rebindings[j].replacement)
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
ここで行ってるのは rebindings[j].replaced
に値が設定されているときに、差し替え対象の(元の)関数を保持するようにしています。
言い換えると 「rebindings[j].replaced
に値が設定されている = 元の関数を使う」という意味になるわけです。
そしてその場合に元の関数のポインタである indirect_symbol_bindings[i]
を保存します。
/**
* 1. Moved the vm protection modifying codes to here to reduce the
* changing scope.
* 2. Adding VM_PROT_WRITE mode unconditionally because vm_region
* API on some iOS/Mac reports mismatch vm protection attributes.
* -- Lianfu Hao Jun 16th, 2021
**/
err = vm_protect (mach_task_self(), (uintptr_t)indirect_symbol_bindings, section->size, 0, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
ここで行っているのはメモリの保護属性の変更です。本来、作業しているセクションは実行時は読み取り専用となるためそれを一時的に書き込み可能状態に変更しています。
なお、mach_task_self()
は現在のタスク(プロセス)を指しています。
(uintptr_t)indirect_symbol_bindings
は書き換える対象のメモリアドレスの開始位置で、そこから section->size
分の範囲が変更範囲となっています。
言い換えれば、対象のセクションの関数シンボルテーブルを丸ごと VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY
に変更しているわけです。
そしてプロテクトの変更が成功したら対象のシンボルテーブルのポインタをカスタムの関数のポインタに置き換えます。
if (err == KERN_SUCCESS) {
/**
* Once we failed to change the vm protection, we
* MUST NOT continue the following write actions!
* iOS 15 has corrected the const segments prot.
* -- Lionfore Hao Jun 11th, 2021
**/
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
}
さいごに
fishhook
の実装を眺めてきました。処理自体は分かってしまえばシンプルなので実装自体はそんなに多くありません。とはいえ、シンボルテーブルや文字列テーブルなど、Mach-O フォーマットにある程度詳しくないと分からない部分もあります。
しかし、こうした処理が内部で行われていることが分かるとアプリの起動・実行についての解像度が上がるのでとても面白いですね。
最近はこうした低レイヤーなことを調べるのが面白くて、Frida というハックツールについても調べ始めています。もし理解して使えるようになったら、それの記事も書いてみようと思います。
Discussion