c-bata web

@c_bata_ のメモ。python多め

Mach APIとCPUレジスタ値の取得について

AbemaTV Advent Calendar の10日の記事です。 最近作っているツールの話をしようかと思ってましたがちょっと開発が間に合わなかったので、同期のiOSエンジニアから教えてほしいと言われたMach APIについて書きます。

あまりMach APIに関する資料は日本語・英語ともに多くないので、いざ使おうとするとドキュメントの情報が足りず苦労する。必要に応じてカーネルソースを読んだほうが早いことも多くあるため、この記事ではCPUレジスタ値の取り出しをベースに、カーネルソースを読む上で頭に入れておきたいMach APIのいくつかの概念についても解説する。

レジスタ値の取得 (Linuxの場合)

まずMacの話をする前にLinuxではどうしているかについて簡単に紹介しておく。

CPUレジスタにアクセスするとなると、Linuxのシステムでは ptrace() というシステムコールが利用される。 こちらのシステムコールは、特定のプロセスにアタッチしてメモリやCPUレジスタの中身をのぞいたり、書き換えたりすることができるため、 strace のようなシステムコールトレーサーや GDB のようなデバッガで利用されている。

最近だとGopherCon 2017のトークでもシステムコールトレーサーの実装を通したptraceの解説があったり、はてブホッテントリにも度々ptraceに関する記事が上がっていたので既にご存知の方も少なくないかもしれない。


GopherCon 2017: Liz Rice - A Go Programmer's Guide to Syscalls

日本語でもいくつか記事が見つかる。

ptraceについては英語・日本語ともに詳しい解説が既にありますが、これらのプログラムをMacで動かそうと思うと少し苦労する。 Darwinの提供している ptraceは、特定のプロセスにアタッチして処理を停止・再開を制御することはできますが、CPUレジスタの中身を覗いたり書き換えるための機能(PTRACE_GETREGSPTRACE_SETREGS)は存在しない。

Mach APIレジスタ値の取得

MacでもGDBとかを使えばレジスタの値は見れる。なのでもちろん PTRACE_GETREGSPTRACE_SETREGS の代わりになる機能が存在するはず。

f:id:nwpct1:20171211022715p:plain

ネットで検索してみると、 thread_get_state というAPIが検索でヒットした。

これらは Mach カーネル と呼ばれるカーネル基盤が提供していて、記事の中ではMach APIと呼ばれています。 Mach Kernelは マイクロカーネル として設計された (MachのGeneral Designや実装に関する話は、 Mach Overview に詳しくまとまっている)。 Uninformed - vol 4 article 3 によると、macOSで使用されているXNU(Appleが開発したOSカーネルDarwinの一部として公開されている)は、Mach KernelとともにBSDのコードを含んだ ハイブリッドカーネル と呼ばれるもの。しかしXNUのようにBSDMachを一緒に利用するハイブリッドカーネルでは、セキュリティポリシーの扱いが面倒になるらしい。 そこでMachは少し特殊なしくみでその問題を解決している。そのしくみについて勉強するうえでいくつか頭に入れて置かなければならない用語がある。

  • タスク(Tasks): リソース所有権の単位。いわゆるプロセスに近い。macOSのプロセスやPOSIXスレッド(pthreads)はMachのtaskと次の行で紹介するthreadの上で実装されているようだ。
  • スレッド(Threads): プロセス内のPCU実行単位。
  • メッセージ(Msgs): スレッド間の通信を提供するためにMachで使用されます。 メッセージは、データオブジェクトの集合で構成されています。 メッセージが作成されると、そのメッセージは、起動タスクが適切なポート権を持つポートに送信されます。 ポート権はタスク間でメッセージとして送信できます。 メッセージは宛先にキューイングされ、受信スレッドの自由度で処理されます。 Mac OS X では mach_msg() 関数を使用してポートとの間でメッセージを送受信する
  • ポート(Ports): カーネル制御通信チャネル。スレッド間でのメッセージのやりとりに使用する。ポート権限(Port rights)と呼ばれる権限をもつスレッドだけがそのポートにメッセージを送信できる。
  • ポートセット(Port Set): 名前の通りポートのコレクション。あるポートセットに所属するポートは全て同じメッセージキューを使用する。

Machのコンセプトとしては タスク(task) の起動や停止、タスクアドレス空間の操作等を行う際に、 ポート(port) に対して メッセージ(messages) を送信する。こうすることで、BSDのセキュリティ機能の影響を受けないようにしたらしい。 このことを頭に入れた上で、 thread_get_state について調べていこう。

thread_get_state の使い方を調べる

さてtaskやportといった概念を把握したところで、実際に thread_get_state を使ってプログラムカウンタの値をとってみる。 ちなみにx86x86_64のプログラムカウンタは、 Instruction Pointer と呼ばれていてx86_64では RIP レジスタがそれに相当するので、ソースコードを調べる際には RIP という単語が手がかりになりそう。

kern_return_t   thread_get_state
                (thread_act_t                     target_thread,
                 thread_state_flavor_t                   flavor,
                 thread_state_t                       old_state,
                 mach_msg_type_number_t         old_state_count);

http://web.mit.edu/darwin/src/modules/xnu/osfmk/man/thread_get_state.html

ドキュメントの解説によると、target_thread 引数で指定した特定のスレッドの実行状態(CPUレジスタなど)を取得することができるらしい。また第2引数の flavor で取得したい情報を指定するようだ。この説明からflavor の値をどれにするかによって kern_return_t 型の変数のどこかから目的のレジスタを取り出すことができそうだ。 しかし困ったことにドキュメントにはそれ以上の、説明が見当たらないのでDarwinの処理を追ってみる。

Darwin(XNU)カーネルソースコードmacOS 10.12.6 - Source から閲覧できる。 今回はGithubでもMirrorとして GitHub - apple/darwin-xnu: The Darwin Kernel (mirror) が公開されたのでそちらをcloneしてきた。

thread_get_state をみつける

cloneしたらまずは thread_get_state の処理を探してみる。

$ git clone git@github.com:apple/darwin-xnu.git
$ cd darwin-xnu
$ find . -name "*.c" | xargs grep -n "thread_get_state"
./osfmk/arm/status.c:79:machine_thread_get_state(
./osfmk/arm64/status.c:255:machine_thread_get_state(
./osfmk/chud/i386/chud_thread_i386.c:53:chudxnu_thread_get_state(
./osfmk/i386/pcb.c:1063:machine_thread_get_state(
./osfmk/kern/thread_act.c:456:thread_get_state(
...

引っかかった行を見ていくと次のコードが見つかった。

kern_return_t
thread_get_state(
    ...
        result = machine_thread_get_state(thread, flavor, state, state_count);

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/osfmk/kern/thread_act.c#L455-L503

machine_thread_get_state に渡しているため、もう少しほってみる。

machine_thread_get_state の処理を追う

$ git grep -n "machine_thread_get_state" *.c
osfmk/arm/status.c:79:machine_thread_get_state(
osfmk/arm64/status.c:255:machine_thread_get_state(
osfmk/i386/pcb.c:1063:machine_thread_get_state(
 ...

ARMではなさそうなので、 osfmk/i386/pcb.c が怪しそうだ。 中を見ると説明にあったとおり switch(flavor) とflavorの値に応じて何か処理が分岐している。

kern_return_t
machine_thread_get_state(
    thread_t thr_act,
    thread_flavor_t flavor,
    thread_state_t tstate,
    mach_msg_type_number_t *count)
{
    switch (flavor)  {
        ...
    }
}

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/osfmk/i386/pcb.c#L1056-L1479

x86_THREAD_STATE64 からの取得

ここでRIPを取る方法を調べるためにファイル内検索を書けてみるといくつか見つかった。まず1つは x86_THREAD_STATE64 を渡したときに RIPレジスタの値を取り出している。

     case x86_THREAD_STATE64: {
        x86_thread_state64_t    *state;
        x86_saved_state64_t *saved_state;
        ...
        state->rip = saved_state->isf.rip;
        ...

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/osfmk/i386/pcb.c#L1533-L1572

x86_THREAD_STATE からの取得

更にgrepで引っかかったところを読んでいると get_thread_state64 関数のなかでも、EIPにアクセスしていることが見て取れる。 machine_thread_get_state では次の行で get_thread_state64 を呼び出していることから、この処理が怪しそう。

static void
get_thread_state64(thread_t thread, x86_thread_state64_t *ts)
{
    ...
    ts->rip = saved_state->isf.rip;

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/osfmk/i386/pcb.c#L694-L724

     case x86_THREAD_STATE:
        {
        x86_thread_state_t  *state;
        ...
        if (thread_is_64bit(thr_act)) {
            ...
                get_thread_state64(thr_act, &state->uts.ts64);

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/osfmk/i386/pcb.c#L1329-L1354

これらのコードから少なくとも x86_THREAD_STATE もしくは x86_THREAD_STATE64 のどちらかをflavor引数で指定すればRIPレジスタの値がとれそうだ。

ソースコード

flavorで指定する値はわかったので、早速実装してみる。 forkして生成した子プロセスのpidからthreadの一覧を取得し、 get_thread_statex86_THREAD_STATE を指定してレジスタ値を取得すればいい。 ソースコード全体はこちら。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <mach/mach.h>
#include <assert.h>
#include <mach/mach_types.h>


int main(int argc, char *argv[], char *envp[]) {
    pid_t pid = fork();
    if (pid == 0) {
        sleep(4);
        return KERN_SUCCESS;
    }

    kern_return_t err;
    mach_port_t task;
    err = task_for_pid(mach_task_self(), pid, &task);
    if (err != KERN_SUCCESS) {
        fprintf(stderr, "task_for_pid() failed\n");
        exit(EXIT_FAILURE);
    }

    err = task_suspend(task);
    if (err != KERN_SUCCESS) {
        fprintf(stderr, "task_suspend() failed\n");
        exit(EXIT_FAILURE);
    }

    thread_act_array_t threads = NULL;
    mach_msg_type_number_t threadCount;
    err = task_threads(task, &threads, &threadCount);
    if (err != KERN_SUCCESS) {
        fprintf(stderr, "task_threads() failed\n");
        exit(EXIT_FAILURE);
    }
    assert(threadCount > 0);

    x86_thread_state_t state;
    mach_msg_type_number_t count = x86_THREAD_STATE_COUNT;
    err = thread_get_state(threads[0], x86_THREAD_STATE, (thread_state_t)&state, &count);
    if (err != KERN_SUCCESS) {
        fprintf(stderr, "thread_get_state() failed\n");
        exit(EXIT_FAILURE);
    }

    printf("RIP = %llx\n", state.uts.ts64.__rip);
    printf("RAX = %llx\n", state.uts.ts64.__rax);
    printf("RCX = %llx\n", state.uts.ts64.__rcx);
    printf("RDX = %llx\n", state.uts.ts64.__rdx);
    printf("RBP = %llx\n", state.uts.ts64.__rbp);
    printf("RSI = %llx\n", state.uts.ts64.__rsi);
    printf("RDI = %llx\n", state.uts.ts64.__rdi);
    printf("R8  = %llx\n", state.uts.ts64.__r8);
    printf("R9  = %llx\n", state.uts.ts64.__r9);

    err = task_resume(task);
    if (err != KERN_SUCCESS) {
        fprintf(stderr, "task_resume() failed\n");
        exit(EXIT_FAILURE);
    }

    mach_port_deallocate(mach_task_self(), task);
    exit(EXIT_SUCCESS);
}

実行結果は次のとおり。

$ gcc print-rip.c -o print-rip -g -O0 -Wall
$ sudo ./print-rip
RIP = 7fffc6fe736f
RAX = 0
RCX = c
RDX = ffffffffffffffff
RBP = 7fff59ae2a30
RSI = 7fffc706d070
RDI = 0
R8  = 1c
R9  = a0

とれた 🎉

おわりに

オブジェクトファイルのバイナリフォーマットもELFではなくMach-Oというものだったり、標準CライブラリもlibSystem.d.dyldという動的ローダーの中にあったり、デバッグシンボルもdSYMの中に生成されたりstatic libraryも作れなかったり(static link binaryやstatic archive libraryというものは作れるみたいです)、Linuxの環境で勉強したものとは結構違っていて、はまったときにgdbデバッグするのも一苦労でした。 今回は記事も少し長くなったので、そのあたりの話はもう少し調べて整理してから記事にしようかと思います。

参考資料