ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

RustでBPF CO-RE - execsnoop(8) を移植してみる

前回の続きです。

udzura.hatenablog.jp

今回はlibbpf-toolsにあるexecsnoopのコマンドライン部分をRustに移植する。まず、Rustのworkspaceを使って以下のように execsnoop クレートを作成した。 bpf/ ディレクトリにBPFのプログラムを置く。

.
├── Cargo.toml
├── LICENSE
├── README.md
└── execsnoop/
    ├── Cargo.toml
    └── src
        ├── bpf/
        └── main.rs

bpf/ に配置する、 execsnoop.bpf.c は以下をベースにコピーしてくればいいが、 execsnoop.h で定義された構造体や定数は同じファイルに展開しておく。

github.com

合わせて、コンパイルできるように同じディレクトリに vmlinux.h を置いておく。本来はカーネルビルド時にできるものを置いておく感じなのだろうか? いったん以下のファイルと同じものをコピーした。

github.com

この段階でまずBPFプログラムだけコンパイルし、オブジェクトを作る。また、skelも作る。

$ cd execsnoop
$ cargo libbpf build
$ ls -l ../target/bpf/
total 992
-rw-r--r-- 1 vagrant vagrant 1015216 Jan  7 10:52 execsnoop.bpf.o

$ cargo libbpf gen
Warning: unrecognized map: .maps
Warning: unrecognized map: license
$ ls -l src/bpf/
total 2692
-rw-r--r-- 1 vagrant vagrant    3887 Jan  5 13:21 execsnoop.bpf.c
-rw-r--r-- 1 vagrant vagrant    6622 Jan  7 10:52 execsnoop.skel.rs
-rw-r--r-- 1 vagrant vagrant     189 Jan  7 10:52 mod.rs
-rw-r--r-- 1 vagrant vagrant 2734479 Jan  5 11:02 vmlinux.h

Rustプログラム側ではこのskelを用いて、コマンドからBPFプログラムをアタッチ、Perf Bufferから出てくるデータを表示すればOK。 ... ところでexecsnoopの元の実装は、フォーマット系のオプションがややこしいため一旦全部出す、パラメータ系だけサポートする、という感じにする。

実装と説明のコメント

最終的にこういう感じの実装。まずは全体を。

// Rust port of execsnoop.c
// See also: https://github.com/iovisor/bcc/blob/master/libbpf-tools/execsnoop.c

use core::mem;
use core::time::Duration;
use std::str;
use std::convert::TryFrom;

use chrono::Local;
use anyhow::Result;
use libbpf_rs::PerfBufferBuilder;
use plain::Plain;
use structopt::StructOpt;

#[macro_use]
extern crate lazy_static;

mod bpf;
use bpf::*;

// オプション用の構造体をStructOptで定義
#[derive(Debug, StructOpt)]
struct Command {
    /// Trace this UID only
    #[structopt(short = "u", default_value = "-1", value_name = "UID")]
    uid: i32,
    /// Include failed exec()s
    #[structopt(short = "x")]
    fails: bool,
    /// Maximum number of arguments parsed and displayed
    #[structopt(long = "max-args", default_value = "20", value_name = "MAX_ARGS")]
    max_args: i32,
}

// Perf Bufferから送られるイベントを受け取るための構造体
// Cのレイアウトにする。詳細は後述
#[repr(C)]
#[derive(Default)]
struct Event {
    pub comm: [u8; 16],
    pub pid: i32,
    pub tgid: i32,
    pub ppid: i32,
    pub uid: i32,
    pub retval: i32,
    pub args_count: i32,
    pub args_size: u32,
}
unsafe impl Plain for Event {}

// 経過時間を計測するためのタイマーを、lazy_staticでstaticに生成する。
mod timer {
    lazy_static! {
        pub static ref TIMER: std::time::Instant = {
            std::time::Instant::now()
        };
    }
}

// perfのイベントハンドラ
fn handle_event(_cpu: i32, data: &[u8]) {
    let now = Local::now();
    // 上記の timer::TIMER は、こうやって呼び出すたびに、起動時からの経過Durationを返してくれる。
    // BPF toolsでは頻出のパターンなので便利。
    let elap = timer::TIMER.elapsed().as_nanos() as f32 / (1000*1000*1000) as f32;
    let mut event: Event = Event::default();
    let event_size = mem::size_of_val(&event);

    // この辺の Event のアンパックや args の扱いは後述...
    plain::copy_from_bytes(&mut event, data).expect("Data buffer was too short");
    let comm = str::from_utf8(&event.comm).unwrap().trim_end_matches('\0');
    let args: Vec<&str> = str::from_utf8(&data[event_size..]).unwrap().trim_end_matches('\0').split('\0').collect();

    // 表示部分
    println!(
        "{:8} {:<8.3} {:<6} {:16} {:<6} {:<6} {:3} {:?}",
        now.format("%H:%M:%S"),
        elap,
        event.uid,
        comm,
        event.pid,
        event.ppid,
        event.retval,
        args
    );
}

// こちらはPerf Bufferのイベントロスト時のフック
fn handle_lost_events(cpu: i32, count: u64) {
    eprintln!("Lost {} events on CPU {}", count, cpu);
}

// main
// なお、下の println! はこの警告に対応すると逆に意図が分かりづらいので、無視指定。
#[allow(clippy::print_literal)]
fn main() -> Result<()> {
    let opts: Command = Command::from_args();

    // Builder の生成とオープン
    let mut skel_builder: ExecsnoopSkelBuilder = ExecsnoopSkelBuilder::default();
    let mut open_skel: OpenExecsnoopSkel = skel_builder.open()?;
    // パラメータを埋め込み
    if opts.uid >= 0 {
        let uid: u32 = TryFrom::try_from(opts.uid)?;
        open_skel.rodata().targ_uid = uid;
    } else {
        open_skel.rodata().targ_uid = u32::MAX;
    }
    if opts.fails {
        open_skel.rodata().ignore_failed = 0
    } else {
        open_skel.rodata().ignore_failed = 1
    }
    open_skel.rodata().max_args = opts.max_args;

    // ロードとアタッチ
    let mut skel = open_skel.load()?;
    skel.attach()?;

    // ヘッダを表示。この辺は、BCCなんかと同じノリ。
    println!(
        "{:8} {:8} {:6} {:16} {:6} {:6} {:3} {:}",
        "TIME", "TIME(s)", "UID", "PCOMM", "PID", "PPID", "RET", "ARGS"
    );
    // lazy_staticなタイマーをここで初期化するため、ダミーで elapsed() を呼ぶ。
    timer::TIMER.elapsed(); // To initialize static timer
    // Perf Bufferのオープン
    let perf = PerfBufferBuilder::new(skel.maps().events())
        .sample_cb(handle_event)
        .lost_cb(handle_lost_events)
        .build()?;

    // あとはloopでポーリング
    loop {
        perf.poll(Duration::from_millis(100))?;
    }
}

細かい箇所の説明

Skel の利用とパラメータの変更

bpfのプログラムから生成されるSkelは、大体以下のような感じで使うことになる。

let mut builder = HogeSkelBuilder::default();
let mut open_skel = builder.open()?;
open_skel.rodata().foo_arg = opts.foo_arg;
//...
let mut skel = open_skel.load()?;
skel.attach()?;

この際、ロード時のオプションを渡す箇所は、BPFプログラム側の const な変数に対応して生成される。execsnoopなら以下。

const volatile bool ignore_failed = true;
const volatile uid_t targ_uid = INVALID_UID;
const volatile int max_args = DEFAULT_MAXARGS;
// 実はなぜかstatic const struct event empty_event = {} も
// 可変なパラメータ扱いになってしまうが、触れないこと。

この定義を検知してskelで以下のような構造体を生成する。

    #[derive(Debug, Copy, Clone)]
    #[repr(C)]
    pub struct rodata {
        pub ignore_failed: u8,
        pub targ_uid: u32,
        pub max_args: i32,
        pub empty_event: event,
    }

openしたskelのメソッド rodata() からこの構造体にアクセスできるためそこで値を埋めていけばOK。

Perf Bufferから戻ってきたデータの扱い

今回のexecsnoopでは、以下のC構造体に対応するRustの構造体に対し、 plain クレート経由でデータをアンパックしてあげれば利用できる。

#define TASK_COMM_LEN 16
#define ARGSIZE  128
#define TOTAL_MAX_ARGS 60
#define FULL_MAX_ARGS_ARR (TOTAL_MAX_ARGS * ARGSIZE)
struct event {
  char comm[TASK_COMM_LEN];
  pid_t pid;
  pid_t tgid;
  pid_t ppid;
  uid_t uid;
  int retval;
  int args_count;
  unsigned int args_size;
  char args[FULL_MAX_ARGS_ARR];
};

ただ、このレイアウトにそのまま対応するRustの構造体を定義しようとして、以下のように記述すると難儀する。

#[repr(C)]
#[derive(Default)]
struct Event {
    pub comm: [u8; 16],
    pub pid: i32,
    pub tgid: i32,
    pub ppid: i32,
    pub uid: i32,
    pub retval: i32,
    pub args_count: i32,
    pub args_size: u32,
    pub args: [u8; 7680],
}

まず pub args: [u8; 7680] という定義が長すぎるようで、 Default注釈できない。このままではインスタンスの初期化に難儀する羽目になる。

さらにいうと、実は、今回はargsは可変長のような扱いになるようで、 handle_event(_cpu: i32, data: &[u8]) に渡ってくる data の長さがまちまちになってしまう。したがって、plainの仕様により、もし Event::default() で確保したサイズより短いサイズの data が来てしまった場合、 plain::copy_from_bytes は失敗する。

今回は後ろの args メンバはアンパックする範囲に含めず、 args_size までをアンパックして利用することにした。逆に plain::copy_from_bytes のコピー先、 Event::default() 構造体のサイズよりもデータのほうが長い場合は、データの後ろを無視するだけなので問題がない。

#[repr(C)]
#[derive(Default)]
struct Event {
    pub comm: [u8; 16],
    pub pid: i32,
    pub tgid: i32,
    pub ppid: i32,
    pub uid: i32,
    pub retval: i32,
    pub args_count: i32,
    pub args_size: u32,
}
unsafe impl Plain for Event {}

残りの args は以下のように切り出して生の [u8] のスライスとしてアクセスできる。

let mut event: Event = Event::default();
let event_size = mem::size_of_val(&event);
let raw_args = &data[event_size..];

当然というか、これは \0 区切りのバイト列であるので、あとは以下のように扱えばよろしい。

let args: Vec<&str> = str::from_utf8(raw_args)
    .unwrap()
    .trim_end_matches('\0')
    .split('\0')
    .collect();

実際の動作

ビルドすると以下のように、普通に execsnoop として動作する。 -u-x--max-args も動作します。

f:id:udzura:20210107231258p:plain
Rust製execsnoop

$ sudo ../target/debug/execsnoop --help
execsnoop 0.1.0

USAGE:
    execsnoop [FLAGS] [OPTIONS]

FLAGS:
    -x               Include failed exec()s
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
        --max-args <MAX_ARGS>    Maximum number of arguments parsed and displayed [default: 20]
    -u <UID>                     Trace this UID only [default: -1]

まとめ

本記事で、RustでのBPF (CO-RE) toolの実装例として、execsnoopの実装をポートした。また、RustとBPFでどのようにパラメータやデータをやり取りするかの留意点も書いた。

BPFプログラムは既存のものを利用したため、今後はいくつか簡単でもオリジナルのツールを実装していきたい。

今回の実装はここにアップしてます:

github.com