はじめに
システムコールのptrace
を使うと他プロセスの呼ぶシステムコールを覗き見ることが出来ます。
これを使って実行中の他プロセスの標準出力や標準エラー出力を読み取るライブラリを作ってみました。
使い方
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
システムコールが呼ばれたらそこからデータを持ってくるだけです。
以下の記事にとても詳しく書かれています。
(大変参考になりました。ありがとうございます。)
ただ実際にはいろいろと嵌る箇所があるのでポイントだけ書いていきます。
基本的な流れ
libcのラッパーを提供しているnixにptrace
があるのでそれを呼んでいきます。
Rustから扱いやすいようなラッパー関数が準備されていますが、いくつか足りないのでunsafe
なptrace
を直接呼ぶ必要がある部分もあります。
(こちらのPRで追加している最中のようです)
まずはプロセスにattach
して、setoptions
でPTRACE_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_write
でwrite
システムコールを探します。
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
を指定したプロセスがfork
やclone
を呼んで子プロセスを生成した場合、
子プロセスのシステムコールまでは監視できません。
また、リダイレクトで出力先を付け替えた場合にも対応できていません。
子プロセス
子プロセスまで監視するには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
に捨てられるか、といったことはわかりません。
そのためリダイレクトを考慮して、実際に標準出力・標準エラー出力に書かれる値を得たい場合は、もうひと手間必要になります。
リダイレクトに関連するシステムコールはfcntl
とdup2
です。
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と同じくスタック管理になります。