ptraceで他プロセスの出力を読み取るRustライブラリを作ってみた

# はじめに
システムコールの`ptrace`を使うと他プロセスの呼ぶシステムコールを覗き見ることが出来ます。
これを使って実行中の他プロセスの標準出力や標準エラー出力を読み取るライブラリを作ってみました。

https://github.com/dalance/proc-reader

# 使い方
`std::io::Read`を実装しているので
通常のファイルIOやネットワークIOと同じように扱えます。

```rust
extern crate nix;
extern crate proc_reader;
use nix::unistd::Pid;
use proc_reader::ProcReader;
use std::process::Command;
use std::io::Read;
use std::time::Duration;
use std::thread;

fn main() {
    // 読み取り先のプロセスを生成
    let mut child = Command::new("sh").arg("-c").arg("sleep 1; echo aaa").spawn().unwrap();

    // PidからProcReaderを作る
    let pid = Pid::from_raw(child.id() as i32);
    let mut reader = ProcReader::from_stdout(pid);

    // プロセスの実行完了まで待つ
    thread::sleep(Duration::from_secs(2));

    // ProcReaderからRead
    let mut line = String::new();
    let _ = reader.read_to_string(&mut line);
    assert_eq!( "aaa\n", line);
}
```

## `yama.ptrace_scope`について

Ubuntuなどいくつかのディストリビューションではセキュリティのため同じユーザ権限のプロセスでも`ptrace`による`attach`を禁止しています。
そのため以下でその機能を無効にするか、root権限で`attach` する必要があるかもしれません。

```console
$ echo 0 > /proc/sys/kernel/yama/ptrace_scope
```

# 仕組み

原理的には簡単で、`ptrace`で対象プロセスにアタッチして`write`システムコールが呼ばれたらそこからデータを持ってくるだけです。
以下の記事にとても詳しく書かれています。
（大変参考になりました。ありがとうございます。）

https://itchyny.hatenablog.com/entry/2017/07/31/090000

ただ実際にはいろいろと嵌る箇所があるのでポイントだけ書いていきます。

## 基本的な流れ

libcのラッパーを提供している[nix](https://github.com/nix-rust/nix)に`ptrace`があるのでそれを呼んでいきます。
Rustから扱いやすいようなラッパー関数が準備されていますが、いくつか足りないので`unsafe`な`ptrace`を直接呼ぶ必要がある部分もあります。
（こちらの[PR](https://github.com/nix-rust/nix/pull/666)で追加している最中のようです）

まずはプロセスに`attach`して、`setoptions`で`PTRACE_O_TRACESYSGOOD`を設定します。
これにより後で出てくる`WaitStatus::PtraceSyscall`が使えるようになります。

```rust
attach(pid);
match waitpid(pid, None) {
  WaitStatus::Stopped(_, Signal::SIGSTOP) => {
    setoptions(pid, Options::PTRACE_O_TRACESYSGOOD);
  }
}
syscall(pid);
```

注意点として、`setoptions`はプロセス停止中しか出来ません。実行中に行うとエラーになります。
`attach`すると一度`SIGSTOP`で止まるのでそこまで`waitpid`で待ってから実行します。
また、止まった状態のプロセスを先に進めるため`syscall`を呼んでおきます。

あとは対象プロセスでシステムコールが呼ばれる度にプロセスが停止するので`waitpid`で監視します。

```rust
loop {
  match waitpid(pid, None) {
    WaitStatus::PtraceSyscall(_) => {
      ... // システムコールが呼ばれた時の処理
    }
    WaitStatus::Exited(_, _) => {
      break;
    }
  }
  if exit {
    detach(pid);
    break;
  } else {
    syscall(pid);
  }
}
```

`waitpid`の戻り値が`WaitStatus::PtraceSyscall`なら`ptrace`による停止です。
もし`PTRACE_O_TRACESYSGOOD`をセットしていなければ`ptrace`以外の原因で止まった場合も含めて全て`WaitStatus::Stopped`で返ってきます。

`waitpid`後も監視を継続する場合は`syscall`を、終了する場合は`detach`を呼んで対象プロセスから切り離します。`detach`を呼べるのはプロセス停止中だけで、実行中に呼ぶと対象プロセスがabortしたりします。

システムコール呼び出しの中身を見ているのがこちらです。

```rust
let mut regs: user_regs_struct = unsafe { mem::zeroed() };
let regs_ptr = NonNull::new(&mut regs).unwrap();
unsafe {
    #[allow(deprecated)]
    let _ = ptrace(
        PTRACE_GETREGS,
        pid,
        ptr::null_mut(),
        regs_ptr.as_ptr() as *mut libc::c_void,
    );
}
```

呼び出し時の引数を格納している`user_regs_struct`を取得したいのですが、nixにはまだその関数が無いので`ptrace`を直接呼んで何とかします。

引数が分かれば何のシステムコールが呼ばれたのか判定できます。

```rust
if regs.orig_rax == libc::SYS_write as u64 {
    if regs.rdi == 1 {
      ... // 標準出力
    } else if regs.rdi == 2 {
      ... // 標準エラー出力
    }
}
```

`orig_rax`がシステムコール番号です。これは`libc::SYS_*`に定義されているので、`libc::SYS_write`で`write`システムコールを探します。
`rdi`が書き込み先の`fd`になっています。
シェルスクリプトのリダイレクト記述でお馴染みの、1が標準出力、2が標準エラー出力です。

## `std::io::Read`実装

`std::io::Read`を実装するには`read`だけ実装すればOKです。

```rust
fn read(&mut self, buf: &mut [u8]) -> std::result::Result<usize, std::io::Error> {
  ...
}
```

ただ`read`が呼ばれたときにシステムコールを見に行っても遅いので、別スレッドで常時システムコールを監視して出力をバッファにため、`read`ではそのバッファから読むようにしています。

また最後に`detach`するために`Drop`も実装しています。これによりreaderのlifetimeが切れたときに自動で`detach`と監視スレッドの終了処理が走ります。

```rust
impl Drop for ProcReader {
    fn drop(&mut self) {
        // mpsc::channel経由で監視スレッドのdetachを呼んでスレッド終了
        let _ = self.ctl.send(ProcReaderMsg::Stop);
        // スレッド終了待ち
        let _ = self.child.take().unwrap().join();
    }
}
```

## 細かい処理

ここまで説明した実装でも基本的な動作はするのですが、いくつか問題があります。
現時点で分かっているのは子プロセスとリダイレクトです。

まず子プロセスですが、最初に`pid`を指定したプロセスが`fork`や`clone`を呼んで子プロセスを生成した場合、
子プロセスのシステムコールまでは監視できません。
また、リダイレクトで出力先を付け替えた場合にも対応できていません。

### 子プロセス

子プロセスまで監視するには`setoptions`で以下のオプションを設定する必要があります。

```rust
let opt = Options::PTRACE_O_TRACESYSGOOD | Options::PTRACE_O_TRACECLONE
    | Options::PTRACE_O_TRACEFORK
    | Options::PTRACE_O_TRACEVFORK;
setoptions(pid, opt);
```

かなり増えていますがどれも意味は同じで、例えば`PTRACE_O_TRACECLONE`を設定すると対象プロセスが`clone`を
呼んだとき、生成される子プロセスも自動的に監視対象になります。
また、子プロセスに制御が移った後は`waitpid`で待つプロセスは子プロセスになるので
`pid`はスタックで管理する必要があります。

```rust
// pid stack
let mut pids = Vec::new();
pids.push(pid);

loop {
    let mut pid = *pids.last().unwrap();
    match waitpid(pid, None) {
        WaitStatus::PtraceSyscall(_) => {
            ...

            if (sys_clone || sys_fork || sys_vfork) {
                pid = Pid::from_raw(regs.rax as i32);
                pids.push(pid);
                continue;
            }
            ...
        }
        WaitStatus::Exited(_, _) => {
             pids.pop();
             if pids.is_empty() {
                 break;
             } else {
                 pid = *pids.last().unwrap();
             }
        }
    }
}
```

### リダイレクト

`write`システムコールの引数で標準出力か標準エラー出力か判別できると書きましたが、ここで分かるのはあくまで
元のプログラムがどちらに書こうとしているかであって、リダイレクトの結果最終的にどちらに書かれるか、あるいは`/dev/null`に捨てられるか、といったことはわかりません。
そのためリダイレクトを考慮して、実際に標準出力・標準エラー出力に書かれる値を得たい場合は、もうひと手間必要になります。

リダイレクトに関連するシステムコールは`fcntl`と`dup2`です。
`fcntl`は引数で与えるコマンドによって様々な動作をしますが、関係あるのは`F_DUPFD`コマンドで、ファイルディスクリプタをコピーするものです。
`dup2`も同じくファイルディスクリプタのコピー（とクローズ）です。

例えば標準出力を標準エラー出力にリダイレクトする場合は

```console
echo aaa 1&>2
```

システムコール呼び出しの流れは以下のようになります。

```
// 標準エラー出力のファイルディスクリプタをfd=10にバックアップ
fcntl F_DUPFD 2 -> 10
// 標準出力のファイルディスクリプタを標準エラー出力に上書き
dup2 1 2
// fd=2は標準出力のファイルディスクリプタが書かれたので、標準出力で出る
write 2
// バックアップからリストア
dup2 10 2
```

という訳で、リダイレクトを正しく扱うにはこれらのシステムコールも見て、ファイルディスクリプタテーブルの中身を推測すればいいです。
ただし、実行中のプロセスに途中から接続するのでどうしても正確にファイルディスクリプタテーブルの中身を得ることは出来ません。

例えば上の例で最初のfcntlの後に接続したとすると、最後のdup2によるリストアで標準エラー出力のファイルディスクリプタがリストアされたのか、openしたファイルのディスクリプタが10だったのか区別がつきません。
このあたりのうまい解決はちょっと思いつきませんでした。

また子プロセスとも関連しますが、forkやSIGCHILD付きのcloneではファイルディスクリプタテーブルは親プロセスからのコピーになります。
そのため子プロセス終了時はfork前のテーブルを復元する必要があり、pidと同じくスタック管理になります。