Linux
Rust

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

はじめに

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

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

使い方

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

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 する必要があるかもしれません。

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

仕組み

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

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

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

基本的な流れ

libcのラッパーを提供しているnixptraceがあるのでそれを呼んでいきます。
Rustから扱いやすいようなラッパー関数が準備されていますが、いくつか足りないのでunsafeptraceを直接呼ぶ必要がある部分もあります。
(こちらのPRで追加している最中のようです)

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

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

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

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

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したりします。

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

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を直接呼んで何とかします。

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

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

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

std::io::Read実装

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

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

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

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

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

細かい処理

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

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

子プロセス

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

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はスタックで管理する必要があります。

// 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に捨てられるか、といったことはわかりません。
そのためリダイレクトを考慮して、実際に標準出力・標準エラー出力に書かれる値を得たい場合は、もうひと手間必要になります。

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

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

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と同じくスタック管理になります。