📜

他プロセス内のシンボルを検索してアドレスを取得する

2024/12/28に公開

KeyVisual

はじめに

【Mach-O】動的ライブラリを他プロセスに注入して実行するという Zenn の本を書きました。

この本では、起動している他プロセスに対してシェルコードを注入して動的ライブラリを読み込ませる方法について書いています。

今回は応用編として、他プロセス内のライブラリイメージを取得してそこから目的のシンボルアドレスを得る方法について書いていきたいと思います。

動的ライブラリの注入と対象シンボルを特定することができればかなり色々なことができるようになるのではないかなと思います。

この記事で紹介しているコードは GitHub にもアップしてあるので細かい点についてはそちらをご覧ください。

https://github.com/edom18/mach-o-attach-process-sample/blob/main/display-symbols.c

Mach-O について

以前、Mach-O フォーマットについても記事を書いています。今回は Mach-O フォーマットの構造を利用してデータを検索していくため、Mach-O についての知識があまりない方は「Mach-O フォーマットを調べがてら otool 風出力をしてみる」も合わせてご覧ください。

概要

まずは処理の全体像を把握するために概要と流れを説明します。

通常、プロセスはライブラリを多数ロードしてそれを管理しています。また、仮想メモリアドレス空間の仕組みと ASLR(Address Space Layout Randomization)により、簡単には各シンボルのアドレスが分からないようになっています。

そこで、対象プロセスの読み込んでいるイメージリストなどの情報からライブラリの読み込み位置、スライド量などを求めて対象のシンボルアドレスを見つけ出す必要があります。

これを実現するのに行う手順は以下です。

  1. 対象プロセスのタスクを得る
  2. dyld_all_image_infos 構造体の情報を得る
  3. ロードされているライブラリリストから目的のライブラリを見つける(今回は dyld.dylib を見つける)
  4. 対象ライブラリのヘッダ情報を元にセグメントを検索
  5. __LINKEDIT セグメント情報を元にスライド量を求める
  6. __LINKEDIT セグメント情報を元に目的のシンボル位置を求める(今回は dlopen を見つける)

順を追って見ていきましょう。


対象プロセスのタスクを得る

諸々の条件を満たしている状態で以下を実行し対象のタスクを得ます。

対象プロセス(タスク)を得る
// pid の取得は任意。ここではコマンドライン引数で渡す想定
pid_t pid = (pid_t)atoi(argv[1]);

kern_return_t kr;
mach_port_t task;
kr = task_for_pid(mach_task_self(), pid, &task);

以降はここで取得したタスクを利用して情報を検索していきます。

dyld_all_image_infos 構造体の情報を得る

dyld_all_image_infos 構造体の情報を対象プロセスから得るには手始めに task_dyld_info 構造体を取得します。

task_dyld_info を task_info 関数を使って取得
// タスク情報から dyld_all_image_infos のアドレス取得
struct task_dyld_info dyld_info;

// mach_msg_type_number_t は unsigned int のエイリアス
// TASK_DYLD_INFO_COUNT の定義は以下
//      #define TASK_DYLD_INFO_COUNT    \
	            (sizeof(task_dyld_info_data_t) / sizeof(natural_t))
mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT;

// task_info_t は int のエイリアス
// TASK_DYLD_INFO の定義は以下
//      #define TASK_DYLD_INFO 17
kr = task_info(task, TASK_DYLD_INFO, (task_info_t)&dyld_info, &count);

task_dyld_info 構造体には dyld_all_image_infos 構造体のアドレスとサイズが保存されているためこれを用いて dyld_all_image_infos 構造体を取得します。

mach_vm_read 関数を使って対象プロセス空間から情報を読み込み
mach_vm_address_t infos_addr = dyld_info.all_image_info_addr;
mach_vm_size_t infos_size = dyld_info.all_image_info_size;

// dyld_all_image_info 構造体を読み込み
struct dyld_all_image_infos local_infos;
vm_offset_t readMem = 0;
mach_msg_type_number_t dataCnt = 0;
kr = mach_vm_read(task, infos_addr, sizeof(local_infos), &readMem, &dataCnt);

// 読み取ったデータを自分のアドレス空間のメモリにコピー
memcpy(&local_infos, (void*)readMem, sizeof(local_infos));

mach_vm_read 関数を利用して dyld_all_image_infos 構造体のデータを読み取ります。

ちなみに memcpy でコピーしている理由は、mach_vm_read で読み取ったデータは一時バッファに保存されるためコピーする必要があるためです。

dyld_image_info 構造体の配列を取得する

次に行うのは dyld_image_info 構造体の配列を取得します。dyld_all_image_infos はイメージ全体のメタデータなので、実際のデータは別に保存されています。これを読み出します。

dyld_image_info 配列を取得
// dyld_all_image_infos には配列数が保存されているためその分のサイズのメモリを読み出す
size_t arraySize = local_infos.infoArrayCount * sizeof(struct dyld_image_info);
kr = mach_vm_read(task, (mach_vm_address_t)local_infos.infoArray, arraySize, &readMem, &dataCnt);

// 自分のメモリ空間上にコピー
struct dyld_image_info* local_array = (struct dyld_image_info*)malloc(arraySize);
memcpy(local_array, (void*)readMem, arraySize);

ロードされたイメージから目的のものを見つける

検索に必要な情報がそろったので検索していきましょう。

目的のイメージを見つける
// 対象ライブラリのデータの参照を保持するための変数
// 今回は libdyld.dylib 情報の参照を保持する
struct dyld_image_info* target_dyld_info;

// ライブラリパスを読み込む文字列配列のメモリを確保する
#define CHUNK_SIZE 256
char remote_path[CHUNK_SIZE];

// ロードされたイメージ情報を列挙して検索し、
// 見つかったr=らその参照を保持する
for (uint32_t i = 0; i < local_infos.infoArrayCount; i++)
{
    printf("Image %u:\n", i);
    printf("    LoadAddress: %p\n", local_array[i].imageLoadAddress);
    get_image_path(task, local_array[i].imageFilePath, remote_path);
    printf("    Image Path: %s\n", remote_path);

    // 取得した文字列に libdyld が含まれているかチェック
    const char* target_lib_name = "libdyld";
    if (strstr(remote_path, target_lib_name) != 0)
    {
        printf("Found the dyld at [%s]\n", local_array[i].imageFilePath);
        target_dyld_info = (struct dyld_image_info*)&local_array[i];
        break;
    }
}

get_image_path は独自関数です。内部でなにをしているか見ていきましょう。

get_image_path 関数

この関数が行っているのは対象のパス文字列を取得することです。

通常の文字列であれば printf 関数で簡単に出力できますし、strcmp などで簡単に比較できます。ではなぜわざわざ独自関数を実装しないとならないかというと、dyld_image_info 構造体が持っている imageFilePathconst char* 型ですが、このポインタが示しているのは「対象プロセスの仮想メモリアドレス空間」のアドレスなのです(※)。つまり、そのまま読み出そうとしてもこのポインタが指すアドレスには当然ながら文字列は存在しません。

ではどうするかというと、構造体データを読み取ったのと同じように mach_vm_read 関数を用いて対象文字列を対象プロセスの仮想メモリアドレス空間から読み出す必要があるのです。そして残念なことに imageFilePath はポインタであり文字列のサイズが分かりません。

そのため対象プロセスのメモリから 1 文字ずつ読み出していき、それが null 終端文字だったらそこで読み出しを止める、ということをしないとなりません。それを行っているのが get_image_path 関数というわけです。

ということで処理を見てみましょう。

get_image_path 関数
void get_image_path(mach_port_t task, const char* imageFilePath, char* remote_path)
{
    size_t total_read = 0;

    while (total_read < MAX_PATH_LENGTH)
    {
        vm_offset_t readMem;
        mach_msg_type_number_t dataCnt;
        mach_vm_address_t read_addr = (mach_vm_address_t)((uintptr_t)imageFilePath + total_read);

        size_t to_read = CHUNK_SIZE;
        if (total_read + CHUNK_SIZE > MAX_PATH_LENGTH)
        {
            to_read = MAX_PATH_LENGTH - total_read;
        }

        kern_return_t kr = mach_vm_read(task, read_addr, to_read, &readMem, &dataCnt);
        if (kr != KERN_SUCCESS)
        {
            break;
        }

        // readMem は mach_vm_read で確保された領域へのポインタ
        memcpy(remote_path + total_read, (void*)readMem, dataCnt);

        // Null ターミネータチェック
        char* null_pos = memchr(remote_path + total_read, '\0', dataCnt);
        if (null_pos)
        {
            // Null 終端発見
            size_t str_len = (null_pos - remote_path);
            remote_path[str_len] = '\0';
            return;
        }

        total_read += dataCnt;
    }
}

処理自体はそこまで複雑ではないのでざっくりなにをしているかを解説します。

前述したように対象プロセスの仮想メモリアドレス空間から 1 文字ずつ読み出し、それを自プロセス空間に文字列として追加しています。

そして 1 文字取り出すごとに null 終端文字がないかを確認して存在していたらそこで処理を止めて文字列として確定する、ということをしています。


この関数を呼び出した先で libdyld.dylib を見つけたらその参照を保持しておきます。

目的のライブラリかをチェック
// 取得した文字列に libdyld が含まれているかチェック
const char* target_lib_name = "libdyld";
if (strstr(remote_path, target_lib_name) != 0)
{
    printf("Found the dyld at [%s]\n", local_array[i].imageFilePath);
    target_dyld_info = (struct dyld_image_info*)&local_array[i];
    break;
}

ヘッダ情報から対象セグメントなどを検索する

無事に libdyld.dylib が見つかったらそのヘッダから目的のシンボルを見つけます。

ヘッダ情報を読み込む
// 見つけた libdyld.dylib のヘッダを読み込む
mach_vm_size_t mach_header_size = sizeof(struct mach_header_64);
kr = mach_vm_read(task, (mach_vm_address_t)target_dyld_info->imageLoadAddress, mach_header_size, &readMem, &dataCnt);

struct mach_header_64* dyld_header = malloc(sizeof(struct mach_header_64));
memcpy(dyld_header, (void*)readMem, dataCnt);

// 読み込んだデータが間違いなく Mach-O オブジェクトかをチェック
if (dyld_header->magic == MH_MAGIC_64)
{
    printf("This must be dyld image!\n");
}

ヘッダが問題なく読み込めたら、ここから対象のセグメント・セクションなどを調べます。
全体を掲載するとかなり長くなってしまうので部分的に抜粋して解説していきます。

まずは検索に必要なポインタやメモリ確保を行います。

メモリ確保とポインタの設定
// ポインタを進めながら検索していくため、現在のポインタ位置を保持する変数を定義し、ロードコマンドの先頭位置にセットする
vm_address_t cur = (vm_address_t)target_dyld_info->imageLoadAddress + sizeof(struct mach_header_64);

// 見つかった情報を保持するためのメモリを確保する
struct segment_command_64* linkedit = (struct segment_command_64*)malloc(sizeof(struct segment_command_64));
struct segment_command_64* seg_cmd = (struct segment_command_64*)malloc(sizeof(struct segment_command_64));
struct symtab_command* symtab = (struct symtab_command*)malloc(sizeof(struct symtab_command));
struct load_command* lc = (struct load_command*)malloc(sizeof(struct load_command));
struct nlist_64* symtab_array = NULL;

// ロードコマンドの数
printf("Command number: %d\n", dyld_header->ncmds);

ロードコマンドを検索

ヘッダ情報からロードコマンドを逐次チェックして必要なデータを見つけます。
※ エラーチェックなどは省略します。

ロードコマンドの検索
uint64_t slide = 0;
mach_vm_size_t size = sizeof(struct load_command);
for (int i = 0; i < dyld_header->ncmds; i++)
{
    // ロードコマンドを読み出す
    kr = mach_vm_read(task, cur, size, &readMem, &dataCnt);
    memcpy(lc, (void*)readMem, dataCnt);

    // セグメントコマンドだったら処理
    if (lc->cmd == LC_SEGMENT_64)
    {
        // segment_command_64 構造体として対象アドレスからデータを読み出し
        mach_vm_size_t seg_cmd_size = sizeof(struct segment_command_64);
        kr = mach_vm_read(task, cur, seg_cmd_size, &readMem, &dataCnt);
        memcpy(seg_cmd, (void*)readMem, dataCnt);

        // 対象セグメントが __TEXT セグメントだった場合
        // テキストセグメントを見つける目的は ASLR のアドレスのスライド量を求めるため
        if (strcmp(seg_cmd->segname, SEG_TEXT) == 0)
        {
            printf("Found text segment. [%s]\n", seg_cmd->segname);
            printf("Text segment vmaddr: 0x%llx\n", seg_cmd->vmaddr);
            printf("dyld loaded address: %p\n", target_dyld_info->imageLoadAddress);
            slide = (uint64_t)target_dyld_info->imageLoadAddress - seg_cmd->vmaddr;
            printf("Slide: %llu\n", slide);
        }
        // __LINKEDIT セグメント情報を取得
        else if (strcmp(seg_cmd->segname, SEG_LINKEDIT) == 0)
        {
            memcpy(linkedit, seg_cmd, seg_cmd_size);
            printf("Found link edit segment. [%s]\n", seg_cmd->segname);
            printf("    vmaddr: 0x%llx\n", linkedit->vmaddr);
            printf("   fileoff: %llu\n", linkedit->fileoff);
        }
    }
    // シンボルテーブルコマンドだったらテーブル情報を取得
    else if (lc->cmd == LC_SYMTAB)
    {
        mach_vm_size_t symtab_size = sizeof(struct symtab_command);
        kr = mach_vm_read(task, cur, symtab_size, &readMem, &dataCnt);
        memcpy(symtab, (void*)readMem, dataCnt);

        printf("Symbol table info:\n");
        printf("    symoff: 0x%x\n", symtab->symoff);
        printf("    stroff: 0x%x\n", symtab->stroff);
    }

    cur += lc->cmdsize;
}

やや長いですがここで行っているのは以下のデータを取得しています。

  • ASLR のスライド量
  • __LINKEDIT セグメント情報
ASLR とスライド量

ASLR(Address Space Layout Randomization)とは、セキュリティ観点などから Mach カーネルがプロセスの仮想メモリアドレス空間を設定する際に、ランダムな値だけスライドさせる仕組みです。プロセスが起動するたびにランダムに設定されるため、対象プログラムのアドレス位置を推測することがむずかしくなります。

そして今回は対象プロセスに読み込まれてる情報を検索する必要があるためこのスライド量を求める必要があるわけです。

__LINKEDIT セグメントにはシンボルテーブルやストリングテーブルなどの位置の情報が保存されているためシンボル名検索で利用するために取得しています。

シンボルテーブルとストリングテーブルを読み出す

__LINKEDIT セグメントの情報を取得したのでそこからシンボルテーブルとストリングテーブルを対象プロセスから読み出します。

シンボルテーブルはその名の通り、ライブラリ内のシンボルのリストを保持するテーブルです。しかしシンボルテーブルが保持しているデータはシンボルのメタデータで、シンボル名自体はストリングテーブルに格納されているためストリングテーブルも同時に読み出します。

シンボルテーブルとストリングテーブルを読み出す
// スライド量を元にベースアドレスを求める
uintptr_t linkedit_base = slide + (linkedit->vmaddr - linkedit->fileoff);
printf("Link edit base address: 0x%lx\n", linkedit_base);

// シンボルテーブルのアドレスを求める
mach_vm_address_t symtab_addr = (mach_vm_address_t)(linkedit_base + symtab->symoff);

// シンボルテーブルサイズを求めて、対象プロセスから読み出す
mach_vm_size_t symtab_size = (mach_vm_size_t)symtab->nsyms * sizeof(struct nlist_64);
kr = mach_vm_read(task, symtab_addr, symtab_size, &readMem, &dataCnt);
symtab_array = (struct nlist_64*)malloc((size_t)symtab_size);
memcpy(symtab_array, (void*)readMem, dataCnt);

// ストリングテーブルのアドレスを求める
uintptr_t str_addr = (uintptr_t)(linkedit_base + symtab->stroff);

// ストリングテーブルを読み出す
kr = mach_vm_read(task, str_addr, symtab->strsize, &readMem, &dataCnt);
char* strtab = (char*)malloc(symtab->strsize);
memcpy(strtab, (void*)readMem, dataCnt);

スライドと __LINKEDIT セグメントの情報から対象のテーブル位置を求めます。

テーブル情報を得たらいよいよシンボルを検索します。

対象のシンボルを見つける

必要なデータがそろったので目的のシンボル位置を見つけます。今回は dlopen のシンボル位置を求めてみます。

シンボルの検索
printf("Symbol numbers: %u\n", symtab->nsyms);

// シンボルテーブルの内容を走査して目的のシンボルを見つける
struct nlist_64* dlopen_sym = NULL;
for (uint32_t i = 0; i < symtab->nsyms; i++)
{
    uint32_t strx = symtab_array[i].n_un.n_strx;
    const char* symname = strtab + strx;
    printf("Symbol name: %s\n", symname);

    if (symname && strcmp(symname, "_dlopen") == 0)
    {
        dlopen_sym = &symtab_array[i];
    }
}

if (dlopen_sym)
{
    // nlist_64 構造体が示すアドレスも ASLR 適用前のためスライドを足す必要がある
    uint64_t dlopen_sym_addr = slide + dlopen_sym->n_value;
    printf("Found dlopen symbol!\n");
    printf("dlopen symbol address: 0x%llx\n", dlopen_sym_addr);

    // dlopen シンボルが見つかったので目的の処理をする
}

シンボルテーブルに格納されているシンボルの数は symtab->nsyms フィールドに格納されているため、この数分ループしてシンボル名を検索します。

シンボル名自体は前述の通り、ストリングテーブルに格納されているためシンボルテーブルからメタデータを取り出しストリングテーブルからシンボル名を取り出して検索します。

シンボルのメタデータを保持しているのは nlist_64 構造体です。この構造体のフィールドに n_strx というフィールドがあり、これがストリングテーブルのオフセットを示しています。

そのためストリングテーブルの先頭アドレスにこのオフセットを足すことで目的のシンボル名を見つけることが出来ます。

さいごに

シンボル名の検索についての解説は以上です。基本的にやっていることは Mach-O オブジェクトの検索ですが、他プロセスから情報を取得する場合はスライドなどの少し複雑な事情を考慮しないとならない点に注意が必要です。しかし逆を返すとスライド量さえ求めてしまえば自プロセスで行う作業とほぼ同じになるのでフォーマットの仕組みが分かっていればさらに色々な情報を取り出すことができると思います。

こうして低レイヤーな部分を理解してくると普段のアプリ起動などの挙動を見る際にも視野が広がるのでとてもいいですね。今後もさらに色々調べていこうと思っています。

Discussion